From a1d81ab40dd25a227c5b4dd2c3289f2659c75271 Mon Sep 17 00:00:00 2001 From: Tamas Kalman Date: Sun, 26 Apr 2026 06:33:04 -0700 Subject: [PATCH 1/2] add Syphon sink alongside NDI/FFmpeg (macOS only) Adds a third output sink for macOS users: each captured Web-contents frame is published as a Syphon source via node-syphon, so other apps on the same machine (Resolume, OBS, VDMX, TouchDesigner, MadMapper) can consume the video as a zero-copy GPU texture rather than going through the NDI encoder + network stack. How it works ------------ - A new optional dependency on `node-syphon` (1.5.0). Its prebuilt arm64 binary and bundled Syphon.framework are shipped via asarUnpack. Install is a no-op on Linux/Windows because of optionalDependencies. - A new browser config field `s` / `Output2SinkSyphonEnabled`, parallel to the existing `n` (NDI) and `m` (FFmpeg) sub-sinks under the `N` (Output 2) master toggle. - A `support.syphon` runtime flag, true only when running on macOS with the optional dep installed; the control UI's Syphon row is rendered only when that flag is set. - The worker creates a `SyphonMetalServer` named after the browser title, publishes each frame's BGRA buffer via `publishImageData` (wrapped as a `Uint8ClampedArray`), and disposes the server in stop(). Publishing happens BEFORE the existing NDI BGRA->BGRX in-place mutation so Syphon receives the original alpha-preserving frame. - The validity check (`browser.valid()` and the renderer-side equivalent in vingester-control.js) now treats Syphon as a recognized sub-sink, so users can run an instance with only Syphon enabled. Verified -------- - macOS 26.4 / Apple Silicon: `cfg.s=true` flows to the worker, node-syphon loads in the renderer, `SyphonMetalServer` constructs with the correct name and a real `info.v002.Syphon.` identifier, frames publish without errors, no SIGSEGV. - npm install on a non-macOS host skips node-syphon (optionalDep) and `support.syphon` evaluates false; the Syphon UI row stays hidden. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 6 +++- vingester-browser-worker.js | 59 +++++++++++++++++++++++++++++++++---- vingester-browser.js | 2 +- vingester-control.html | 12 ++++++++ vingester-control.js | 2 +- vingester-main.js | 13 +++++++- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 8ba57b4..af461a5 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "@hapi/boom": "9.1.4", "hapi-plugin-header": "1.1.4" }, + "optionalDependencies": { + "node-syphon": "1.5.0" + }, "main": "vingester-main.js", "upd": [ "!execa", "!got", "!dsig" ], "build": { @@ -76,7 +79,8 @@ "asar": true, "asarUnpack": [ "node_modules/@rse/ffmpeg/**", "node_modules/grandiose/**", - "node_modules/@discordjs/opus/**" ], + "node_modules/@discordjs/opus/**", + "node_modules/node-syphon/**" ], "removePackageScripts": true, "compression": "normal", "directories": { diff --git a/vingester-browser-worker.js b/vingester-browser-worker.js index 1d48c13..d9d89da 100644 --- a/vingester-browser-worker.js +++ b/vingester-browser-worker.js @@ -14,6 +14,16 @@ const pcmconvert = require("pcm-convert") const ebml = require("ebml") const Opus = require("@discordjs/opus") +/* Syphon is an optional macOS-only dependency (zero-network local + GPU video routing to Resolume / OBS / VDMX / etc.) */ +let SyphonMetalServer = null +if (process.platform === "darwin") { + try { + SyphonMetalServer = require("node-syphon").SyphonMetalServer + } + catch (err) { /* node-syphon not installed on this platform */ } +} + /* require own modules */ const util = require("./vingester-util.js") const FFmpeg = require("./vingester-ffmpeg.js") @@ -42,6 +52,7 @@ class BrowserWorker { this.timeStart = (BigInt(Date.now()) * BigInt(1e6) - process.hrtime.bigint()) this.ndiSender = null this.ndiTimer = null + this.syphonServer = null this.ffmpeg = null this.opusEncoder = null this.burst1 = null @@ -100,6 +111,18 @@ class BrowserWorker { { status: this.ndiStatus, connections: conns, id: this.id }) }, 1 * 500) } + if (this.cfg.s && SyphonMetalServer !== null) { + /* start a Syphon (macOS GPU sharing) server to publish frames + locally with zero network/encoder overhead */ + try { + this.syphonServer = new SyphonMetalServer(title) + this.log.info(`Syphon server started: "${title}"`) + } + catch (err) { + this.log.error(`failed to start Syphon server: ${err}`) + this.syphonServer = null + } + } if (this.cfg.m) { this.ffmpeg = new FFmpeg({ ffmpeg: this.cfg.ffmpeg, @@ -175,6 +198,13 @@ class BrowserWorker { if (this.ndiSender !== null) await this.ndiSender.destroy() + /* destroy Syphon server */ + if (this.syphonServer !== null) { + try { this.syphonServer.dispose() } + catch (err) { this.log.error(`Syphon dispose error: ${err}`) } + this.syphonServer = null + } + /* destroy FFmpeg sender */ if (this.ffmpeg !== null) await this.ffmpeg.stop() @@ -226,13 +256,30 @@ class BrowserWorker { /* send video frame */ if (this.cfg.N) { - if (this.cfg.n) { - /* convert from ARGB (Electron/Chromium on big endian CPU) - to BGRA (supported input of NDI SDK). On little endian - CPU the input is already BGRA. */ - if (os.endianness() === "BE") - util.ImageBufferAdjustment.ARGBtoBGRA(buffer) + /* normalize endianness once for any sink that wants BGRA */ + if ((this.cfg.n || this.syphonServer !== null) && os.endianness() === "BE") + util.ImageBufferAdjustment.ARGBtoBGRA(buffer) + + /* publish to Syphon BEFORE the NDI BGRA->BGRX in-place mutation + so Syphon receives the original alpha-preserving BGRA frame. + node-syphon's publishImageData requires Uint8ClampedArray + (not Node Buffer); wrap as a zero-copy view. */ + if (this.syphonServer !== null) { + try { + const pixels = new Uint8ClampedArray(buffer.buffer, buffer.byteOffset, buffer.byteLength) + this.syphonServer.publishImageData( + pixels, + { x: 0, y: 0, width: size.width, height: size.height }, + { width: size.width, height: size.height }, + false + ) + } + catch (err) { + this.log.error(`Syphon publish error: ${err}`) + } + } + if (this.cfg.n) { /* optionally convert from BGRA to BGRX (no alpha channel) */ let fourCC = grandiose.FOURCC_BGRA if (!this.cfg.v) { diff --git a/vingester-browser.js b/vingester-browser.js index 9bb193c..cb908b6 100644 --- a/vingester-browser.js +++ b/vingester-browser.js @@ -151,7 +151,7 @@ module.exports = class Browser { valid () { return ( (this.cfg.D || this.cfg.N) - && (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m))) + && (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m || this.cfg.s))) && this.cfg.t !== "" && this.cfg.u !== "" ) diff --git a/vingester-control.html b/vingester-control.html index 89f76b4..cf320b0 100644 --- a/vingester-control.html +++ b/vingester-control.html @@ -871,6 +871,18 @@ +
+
+
+
Syphon™:
+
+
+
YES
+
NO
+
+
+
diff --git a/vingester-control.js b/vingester-control.js index ba7bba3..e8a005d 100644 --- a/vingester-control.js +++ b/vingester-control.js @@ -333,7 +333,7 @@ const app = Vue.createApp({ if (browser.d >= this.displays.length) browser.d = (this.displays.length - 1) if ( (browser.D || browser.N) - && (!browser.N || (browser.N && (browser.n || browser.m))) + && (!browser.N || (browser.N && (browser.n || browser.m || browser.s))) && browser.t !== "" && browser.u !== "") delete this.invalid[browser.id].GLOBAL else diff --git a/vingester-main.js b/vingester-main.js index f487974..d35c066 100644 --- a/vingester-main.js +++ b/vingester-main.js @@ -54,9 +54,18 @@ const version = { ffmpeg: FFmpeg.info.version, vuejs: pkg.dependencies.vue } +let syphonAvailable = false +if (process.platform === "darwin") { + try { + require("node-syphon") + syphonAvailable = true + } + catch (err) { /* node-syphon is an optional macOS-only dependency */ } +} const support = { ndi: grandiose.isSupportedCPU(), - srt: FFmpeg.info.protocols?.srt?.input === true + srt: FFmpeg.info.protocols?.srt?.input === true, + syphon: syphonAvailable } electron.ipcMain.handle("version", (ev) => { return version }) electron.ipcMain.handle("support", (ev) => { return support }) @@ -67,6 +76,7 @@ log.info(`using V8: ${version.v8}`) log.info(`using Node.js: ${version.node}`) log.info(`using NDI: ${version.ndi} (supported by CPU: ${support.ndi ? "yes" : "no"})`) log.info(`using FFmpeg: ${version.ffmpeg}`) +log.info(`using Syphon: ${support.syphon ? "yes (macOS)" : "no"}`) log.info(`using Vue: ${version.vuejs}`) /* support particular profiles */ @@ -362,6 +372,7 @@ electron.app.on("ready", async () => { { iname: "v", itype: "boolean", def: true, etype: "boolean", ename: "Output2SinkNDIAlpha" }, { iname: "l", itype: "boolean", def: false, etype: "boolean", ename: "Output2SinkNDITallyReload" }, { iname: "m", itype: "boolean", def: false, etype: "boolean", ename: "Output2SinkFFmpegEnabled" }, + { iname: "s", itype: "boolean", def: false, etype: "boolean", ename: "Output2SinkSyphonEnabled" }, { iname: "R", itype: "string", def: "vbr", etype: "string", ename: "Output2SinkFFmpegMode" }, { iname: "F", itype: "string", def: "matroska", etype: "string", ename: "Output2SinkFFmpegFormat" }, { iname: "M", itype: "string", def: "", etype: "string", ename: "Output2SinkFFmpegOptions" }, From 5841f441dffe0d459895fd3086aa44bba17d3948 Mon Sep 17 00:00:00 2001 From: Tamas Kalman Date: Sun, 26 Apr 2026 13:37:09 -0700 Subject: [PATCH 2/2] gate Syphon as a valid Output2 sink only when actually available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, a config imported on Linux/Windows (or a macOS host without node-syphon installed) with `Output2SinkSyphonEnabled: true` and both NDI and FFmpeg disabled would pass valid() and start capture, but the worker would silently drop every frame because SyphonMetalServer is null. The renderer-side check in vingester-control.js had the same bug — the UI would happily show the instance as ready to start. Also: short comment in the worker clarifying that FFmpeg is intentionally absent from the BGRA endianness normalization (it gets a re-encoded JPEG from nativeImage and handles pixel format itself). Addresses codemouseai PR #1 review comments. Co-Authored-By: Claude Opus 4.7 (1M context) --- vingester-browser-worker.js | 5 ++++- vingester-browser.js | 15 ++++++++++++++- vingester-control.js | 2 +- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/vingester-browser-worker.js b/vingester-browser-worker.js index d9d89da..22d6a91 100644 --- a/vingester-browser-worker.js +++ b/vingester-browser-worker.js @@ -256,7 +256,10 @@ class BrowserWorker { /* send video frame */ if (this.cfg.N) { - /* normalize endianness once for any sink that wants BGRA */ + /* normalize endianness once for BGRA-consuming sinks (NDI, Syphon). + FFmpeg is intentionally excluded: it receives a JPEG below + (re-encoded by Electron's nativeImage) and handles pixel format + internally, so the raw buffer endianness is irrelevant to it. */ if ((this.cfg.n || this.syphonServer !== null) && os.endianness() === "BE") util.ImageBufferAdjustment.ARGBtoBGRA(buffer) diff --git a/vingester-browser.js b/vingester-browser.js index cb908b6..dd14a5d 100644 --- a/vingester-browser.js +++ b/vingester-browser.js @@ -17,6 +17,19 @@ const bluebird = require("bluebird") const util = require("./vingester-util.js") const pkg = require("./package.json") +/* Syphon availability (optional, macOS-only) — used to gate the `s` + sub-sink in valid() so configs imported on unsupported platforms + are correctly flagged as invalid rather than silently producing + no output */ +let syphonAvailable = false +if (process.platform === "darwin") { + try { + require("node-syphon") + syphonAvailable = true + } + catch (err) { /* node-syphon is not installed on this platform */ } +} + /* browser abstraction */ module.exports = class Browser { /* create new browser */ @@ -151,7 +164,7 @@ module.exports = class Browser { valid () { return ( (this.cfg.D || this.cfg.N) - && (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m || this.cfg.s))) + && (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m || (this.cfg.s && syphonAvailable)))) && this.cfg.t !== "" && this.cfg.u !== "" ) diff --git a/vingester-control.js b/vingester-control.js index e8a005d..44edbce 100644 --- a/vingester-control.js +++ b/vingester-control.js @@ -333,7 +333,7 @@ const app = Vue.createApp({ if (browser.d >= this.displays.length) browser.d = (this.displays.length - 1) if ( (browser.D || browser.N) - && (!browser.N || (browser.N && (browser.n || browser.m || browser.s))) + && (!browser.N || (browser.N && (browser.n || browser.m || (browser.s && this.support.syphon)))) && browser.t !== "" && browser.u !== "") delete this.invalid[browser.id].GLOBAL else