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..22d6a91 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,33 @@ 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 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) + + /* 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..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.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.html b/vingester-control.html index 89f76b4..cf320b0 100644 --- a/vingester-control.html +++ b/vingester-control.html @@ -871,6 +871,18 @@ +