Add Syphon sink (macOS) alongside NDI/FFmpeg#1
Conversation
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.<UUID>` 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) <[email protected]>
| 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") |
There was a problem hiding this comment.
The endianness guard now covers both NDI (cfg.n) and Syphon (syphonServer !== null), but does not cover the FFmpeg sink (cfg.m). The original code had the same omission — FFmpeg's frame path apparently doesn't need the ARGB→BGRA normalization. That's fine if FFmpeg handles its own pixel format internally, but it's worth a comment here to signal the asymmetry is intentional and not an oversight for future sink authors who read this code.
| if ((this.cfg.n || this.syphonServer !== null) && os.endianness() === "BE") | |
| /* normalize endianness once for BGRA-consuming sinks (NDI, Syphon); | |
| FFmpeg receives raw Chromium output and handles pixel format internally */ | |
| if ((this.cfg.n || this.syphonServer !== null) && os.endianness() === "BE") | |
| util.ImageBufferAdjustment.ARGBtoBGRA(buffer) |
Fix prompt (copy to your AI agent)
In vingester-browser-worker.js around line 260, add a short inline comment to the endianness guard that explicitly notes FFmpeg is excluded intentionally — it accepts raw Chromium pixel output and handles format conversion itself. This prevents future contributors from misreading the condition as a bug.
There was a problem hiding this comment.
Good call — added in #2 (merged as 529cc1a). The block now has an inline comment that names FFmpeg explicitly and explains why it's not in the BGRA-conversion list (it gets a JPEG from nativeImage.toJPEG() and handles pixel format itself), so a future reader doesn't have to reverse-engineer the asymmetry.
| false | ||
| ) | ||
| } | ||
| catch (err) { |
There was a problem hiding this comment.
A per-frame log.error on a hot paint path will flood logs if Syphon publishing fails repeatedly (e.g. GPU overload, Metal error). NDI has the same pattern so this is consistent, but at 30+ fps a single stuck error will generate thousands of log lines per minute. Consider rate-limiting or switching to a log.warn after the first occurrence.
Fix prompt (copy to your AI agent)
In vingester-browser-worker.js, the catch block inside the Syphon publishImageData call (around line 277) logs every failed frame. Add a simple flag (e.g. `this.syphonWarnLogged`) that is set on the first error and reset on success, so repeated failures log at debug level rather than error level to avoid flooding the log file at video frame rates.
There was a problem hiding this comment.
Skipping this one for now, with reasoning: the NDI sink right above has the same per-frame log.error pattern, and so does the FFmpeg path. Adding the rate-limit only on Syphon would just create the opposite asymmetry — Syphon would be the sink that quietly swallows errors while the others still flood.
The right fix is symmetric across all three sinks (a small WeightedAverage-style throttler shared by NDI/FFmpeg/Syphon catch blocks, or moving to log.warn after the first occurrence everywhere). That's a bigger refactor than this PR's scope and worth its own change.
Tracked it as a known follow-up in the upstream PR I just opened: rse/vingester#97.
| 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))) |
There was a problem hiding this comment.
valid() now accepts s as satisfying the required Output2 sink, but it does not check whether Syphon is actually available. On non-macOS (or if node-syphon is not installed), N=true, s=true, n=false, m=false passes validation and starts capture with no usable sink.
| && (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m || this.cfg.s))) | |
| Gate `s` by availability in the validity condition, e.g. require `(this.cfg.s && process.platform === "darwin")` (or, better, a computed `support.syphon` flag) instead of bare `this.cfg.s`. |
Fix prompt (copy to your AI agent)
Update browser validation so Syphon only counts as an Output2 sink when it is truly supported. In `vingester-browser.js`, modify `valid()` to require Syphon availability (platform/support flag) before treating `cfg.s` as satisfying the sink requirement.
There was a problem hiding this comment.
Real bug, fixed in #2 (merged as 529cc1a). valid() now requires Syphon to actually be available before counting cfg.s as a satisfied sink:
&& (!this.cfg.N || (this.cfg.N && (this.cfg.n || this.cfg.m || (this.cfg.s && syphonAvailable))))syphonAvailable is set at module load via the same try/require("node-syphon") pattern the worker uses, gated on process.platform === "darwin". So a config imported on Linux/Windows (or a macOS host where the optional dep didn't install) with s=true, n=false, m=false is now correctly flagged invalid rather than silently dropping every frame.
Thanks for catching this — it was the worst kind of bug (silent success).
| 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))) |
There was a problem hiding this comment.
The renderer-side invalid-state check mirrors the same issue: it treats browser.s as a valid sink without checking support.syphon. A profile imported from macOS can be shown as valid on unsupported platforms even though no Output2 sink can run.
| && (!browser.N || (browser.N && (browser.n || browser.m || browser.s))) | |
| Change the condition so only `(browser.s && this.support.syphon)` satisfies the sink requirement, keeping UI validity consistent with runtime capabilities. |
Fix prompt (copy to your AI agent)
In `vingester-control.js`, update the GLOBAL validity check to count Syphon only when support is available (`this.support.syphon`). This should match the runtime sink availability rules.
There was a problem hiding this comment.
Same root cause as the comment on vingester-browser.js:154, fixed in #2 (merged as 529cc1a). The renderer-side check now uses the existing this.support.syphon flag (already populated from main via ipcRenderer.invoke("support")):
&& (!browser.N || (browser.N && (browser.n || browser.m || (browser.s && this.support.syphon))))So the UI's GLOBAL validity now matches the runtime behavior on every platform.
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, etc. — can consume the video locally without going through the NDI encoder + network stack.
Why Syphon next to NDI
For local pipelines into VJ / streaming software on the same Mac, Syphon is the right primitive; NDI keeps doing what it does best (cross-machine).
Changes
[email protected]. Prebuilt darwin-arm64 + bundledSyphon.frameworkare shipped viaasarUnpack. Listed inoptionalDependenciesso installs on Linux/Windows skip it cleanly.s/Output2SinkSyphonEnabled, parallel ton(NDI) andm(FFmpeg) under theN(Output 2) master toggle.support.syphonis set totrueonly on macOS with the dep installed; the new UI row is rendered only when the flag is set.vingester-browser-worker.js):SyphonMetalServeronce at module load on darwincfg.sis truepublishImageData(wrapped asUint8ClampedArray— that's what node-syphon expects)BGRA→BGRXmutation so Syphon gets the original alpha-preserving framestop()browser.valid()and the renderer-side equivalent now treat Syphon as a recognized sub-sink, so you can run an instance with only Syphon enabled (no NDI / no FFmpeg).support.syphon.Verified locally
On macOS 26.4 / Apple Silicon:
cfg.s=trueflows from the YAML config through to the workernode-syphonloads inside the Electron 18 renderer andSyphonMetalServerconstructs with the correct name (realinfo.v002.Syphon.<UUID>identifier)data:URLOn a non-macOS host (smoke):
npm installskipsnode-syphon(optionalDependency)support.syphonevaluates false → the new UI row stays hiddenTradeoffs / future work
This is the "Path 1" of the two implementation paths I considered:
webContents.beginFrameSubscription) and upload it to GPU vianode-syphon.publishImageData. Loses the GPU→CPU→GPU roundtrip vs. true zero-copy, but skips NDI's SpeedHQ encoding (a real CPU win) and gets sub-frame latency vs NDI's 1–3 frames. Works today on Electron 18.Path 1 is enough to make Vingester useful for VJs / live-video setups today.
Built on top of
This branches off
masterof ktamas77/vingester, which already includes the macOS arm64 fix (thegrandiosepin bump inpackage.json). The arm64 fix has been submitted upstream as rse/vingester#96.