Skip to content

Add Syphon output sink (macOS, opt-in)#97

Open
ktamas77 wants to merge 2 commits into
rse:masterfrom
ktamas77:upstream/feat/syphon-sink
Open

Add Syphon output sink (macOS, opt-in)#97
ktamas77 wants to merge 2 commits into
rse:masterfrom
ktamas77:upstream/feat/syphon-sink

Conversation

@ktamas77
Copy link
Copy Markdown

Adds a third Output 2 sink alongside NDI and FFmpeg: a macOS-only Syphon server that publishes captured Web frames as a Syphon source via node-syphon. Other macOS apps on the same machine — Resolume, OBS (with the Syphon plugin), VDMX, TouchDesigner, MadMapper — can then consume the video locally without going through the NDI encoder + network stack.

Why add a Syphon sink

For local same-Mac pipelines into VJ/streaming software, Syphon is the right primitive:

Syphon NDI
Transport Shared GPU surface (IOSurface + Mach ports) CPU readback → SpeedHQ encode → socket → decode
Localhost latency <5 ms 1–3 frames
CPU cost ~0% NDI's encoder isn't free at high frame rates
Reach Same Mac only Network-wide, cross-platform

NDI keeps doing what it does best (cross-machine routing). This just gives macOS users a much faster local option.

Cross-platform philosophy: opt-in only, no impact elsewhere

I'm aware the project values tri-platform parity. This PR is structured so it cleanly degrades on Windows and Linux:

  • node-syphon is in optionalDependencies, not dependenciesnpm install skips it cleanly on Win/Linux.
  • A runtime support.syphon flag is true only on macOS and only when the dep loaded — gated by process.platform === "darwin" plus a try/require.
  • The new "Syphon" UI row is rendered only when support.syphon is true (v-show="… && support.syphon"), so the control window on Win/Linux is byte-identical to before.
  • valid() (main process) and the renderer-side mirror only count cfg.s as a valid Output 2 sub-sink when Syphon is actually available — so a YAML config imported on an unsupported host with Output2SinkSyphonEnabled: true is correctly flagged invalid rather than silently dropping every frame.

A natural counterpart on Windows is Spout, which has the same role and is also wrapped by some Node bindings. Happy to look at adding Spout in a follow-up PR if you'd like; I kept this PR macOS-only to keep the surface small and reviewable, and because Syphon is what I have a working setup for.

Implementation details

  • New config field s / Output2SinkSyphonEnabled, parallel to n (NDI) and m (FFmpeg).
  • vingester-browser-worker.js: try-requires SyphonMetalServer once at module load on darwin; creates one server per worker named after the browser title; publishes each captured frame's BGRA buffer via publishImageData (wrapped as Uint8ClampedArray — that's what node-syphon requires); disposes in stop(). The publish happens before the existing NDI in-place BGRA→BGRX mutation so Syphon receives the original alpha-preserving frame.
  • vingester-main.js: detects node-syphon availability and exposes support.syphon to the renderer via the existing support IPC handle; logs Syphon availability at startup alongside NDI/FFmpeg.
  • vingester-control.html: new SINK row with a YES/NO toggle, gated on support.syphon.
  • The existing cfg.N master toggle still gates the entire Output 2 block; Syphon is just one of three checkbox-level sub-sinks underneath it.
  • An inline comment in the worker explains why FFmpeg is intentionally excluded from the BGRA endianness normalization (it gets a JPEG from nativeImage.toJPEG() and handles pixel format itself).

Diff size

File +/−
package.json +5 −1
vingester-main.js +12 −1
vingester-browser.js +14 −1
vingester-browser-worker.js +56 −6
vingester-control.js +1 −1
vingester-control.html +12
Total +100 −10

License

node-syphon is GPL-3.0+, compatible with Vingester's GPL-3.0-only.

Verified locally

  • macOS 26.4 / Apple Silicon: cfg.s=true flows from YAML → main → worker; node-syphon loads inside the Electron 18 renderer; SyphonMetalServer constructs with the correct name and a real info.v002.Syphon.<UUID> identifier; frames publish at the configured FPS without errors; clean shutdown on stop.
  • Receiver-app smoke: works as a Syphon source for typical receivers on the same machine.
  • Bundling: node_modules/node-syphon/** is asarUnpacked so syphon.node and Syphon.framework (universal2) are both extracted next to the runtime.

Known follow-ups (not in this PR)

  • A symmetric per-frame error rate-limit for all three sinks (NDI / FFmpeg / Syphon currently each log.error per failed frame, which can flood at 30+ fps if a sink gets stuck). This is a pre-existing pattern; happy to send a separate PR if you'd like it.
  • A Windows Spout equivalent for cross-platform parity.

ktamas77 and others added 2 commits April 26, 2026 13:40
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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