Skip to content

Add Syphon sink (macOS) alongside NDI/FFmpeg#1

Merged
ktamas77 merged 1 commit into
masterfrom
feat/syphon-sink
Apr 26, 2026
Merged

Add Syphon sink (macOS) alongside NDI/FFmpeg#1
ktamas77 merged 1 commit into
masterfrom
feat/syphon-sink

Conversation

@ktamas77

Copy link
Copy Markdown
Owner

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

Syphon NDI
Transport Shared GPU texture (IOSurface + Mach ports) CPU readback → SpeedHQ encode → socket → decode
Memory copies Effectively zero Multiple
Latency on localhost <5 ms 1–3 frames
Reach Same Mac only Network-wide, cross-platform

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

  • New optional dependency: [email protected]. Prebuilt darwin-arm64 + bundled Syphon.framework are shipped via asarUnpack. Listed in optionalDependencies so installs on Linux/Windows skip it cleanly.
  • New config field: s / Output2SinkSyphonEnabled, parallel to n (NDI) and m (FFmpeg) under the N (Output 2) master toggle.
  • Runtime feature flag: support.syphon is set to true only on macOS with the dep installed; the new UI row is rendered only when the flag is set.
  • Worker (vingester-browser-worker.js):
    • Try-requires SyphonMetalServer once at module load on darwin
    • Creates a server named after the browser title when cfg.s is true
    • Publishes each frame's BGRA buffer via publishImageData (wrapped as Uint8ClampedArray — that's what node-syphon expects)
    • Publishes before the existing NDI in-place BGRA→BGRX mutation so Syphon gets the original alpha-preserving frame
    • Disposes the server in stop()
  • Validity check: 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).
  • UI: New row under SINK with a YES/NO toggle, gated on support.syphon.

Verified locally

On macOS 26.4 / Apple Silicon:

  • cfg.s=true flows from the YAML config through to the worker
  • node-syphon loads inside the Electron 18 renderer and SyphonMetalServer constructs with the correct name (real info.v002.Syphon.<UUID> identifier)
  • Frames publish with no errors at 15 fps from a data: URL
  • Worker stays alive; no SIGSEGV; clean dispose on stop

On a non-macOS host (smoke):

  • npm install skips node-syphon (optionalDependency)
  • support.syphon evaluates false → the new UI row stays hidden
  • Existing NDI/FFmpeg paths are unchanged

Tradeoffs / future work

This is the "Path 1" of the two implementation paths I considered:

  • Path 1 (this PR): take Vingester's existing CPU-side BGRA buffer (from webContents.beginFrameSubscription) and upload it to GPU via node-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 2 (future): hand Chromium's compositor IOSurface directly to a Syphon Metal server — true zero-copy. Requires Electron 40+ shared-texture paint events or custom Chromium integration; not feasible without a major Electron upgrade.

Path 1 is enough to make Vingester useful for VJs / live-video setups today.

Built on top of

This branches off master of ktamas77/vingester, which already includes the macOS arm64 fix (the grandiose pin bump in package.json). The arm64 fix has been submitted upstream as rse/vingester#96.

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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread vingester-browser.js
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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
&& (!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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread vingester-control.js
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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
&& (!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.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ktamas77 ktamas77 merged commit 5869b53 into master Apr 26, 2026
@ktamas77 ktamas77 deleted the feat/syphon-sink branch April 26, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant