Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
62 changes: 56 additions & 6 deletions vingester-browser-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion vingester-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 !== ""
)
Expand Down
12 changes: 12 additions & 0 deletions vingester-control.html
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,18 @@
</div>
</div>
</div>
<div class="row" v-show="!browser._ && browser.N && support.syphon">
<div class="group"></div>
<div class="sub-group"></div>
<div class="label label-kind">Syphon&trade;:</div>
<div class="field"
v-tippy="{ placement: 'top', content: 'Click to ' + (browser.s ? 'disable' : 'enable') + ' Syphon sink (macOS GPU sharing for local apps like Resolume, OBS, VDMX).' }">
<div class="toggle" v-on:click="toggle(browser, 's', [ true, false ])">
<div class="toggle-option" v-bind:class="{ selected: browser.s === true }"><span class="icon"><i class="fas fa-check-circle"></i></span> YES</div>
<div class="toggle-option" v-bind:class="{ selected: browser.s === false }"><span class="icon"><i class="fas fa-times-circle"></i></span> NO</div>
</div>
</div>
</div>
<div class="row" v-show="!browser._ && browser.N">
<div class="group"></div>
<div class="sub-group"></div>
Expand Down
2 changes: 1 addition & 1 deletion vingester-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 && this.support.syphon))))
&& browser.t !== "" && browser.u !== "")
delete this.invalid[browser.id].GLOBAL
else
Expand Down
13 changes: 12 additions & 1 deletion vingester-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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 */
Expand Down Expand Up @@ -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" },
Expand Down