@jamezjb/node-red-ndi-preview 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ // Node-RED's side of the NDI host (lib/host.js): starts it, talks to it, and restarts it if it dies or hangs.
3
+ //
4
+ // Safety rules for starting a process:
5
+ // - the program is named explicitly: this Node.js binary (process.execPath) running the absolute path of
6
+ // lib/host.js, with no extra Node flags; never "this program again";
7
+ // - the child carries the NDI_PREVIEW_HOST marker, and this module refuses to start a host from inside a host;
8
+ // - at most one host per Node-RED; restarts back off (1 s, 2 s, 4 s ... 30 s) and stop altogether after
9
+ // MAX_CRASHES crashes inside CRASH_WINDOW_MS, with a clear status, until Node-RED is restarted.
10
+ const { fork } = require("child_process");
11
+ const { EventEmitter } = require("events");
12
+ const path = require("path");
13
+ const { prepareNdiConfig } = require("./ndi-config");
14
+ const { unsupportedReason } = require("./addon");
15
+
16
+ const HOST_SCRIPT = path.join(__dirname, "host.js");
17
+ const MAX_CRASHES = 5;
18
+ const CRASH_WINDOW_MS = 60000;
19
+ const PING_MS = 3000;
20
+ const HEARTBEAT_MS = 10000;
21
+
22
+ // Every host this process started, so that none outlives Node-RED: a host stuck in native code cannot see its IPC
23
+ // channel close, so it is ended here when Node-RED exits.
24
+ const running = new Set();
25
+ process.once("exit", () => { for (const c of running) { try { c.kill("SIGKILL"); } catch (e) { /* gone */ } } });
26
+
27
+ const toBuffer = (u8) => (Buffer.isBuffer(u8) ? u8 : Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength));
28
+
29
+ class HostClient extends EventEmitter {
30
+ constructor({ configDir, log, maxCrashes = MAX_CRASHES, backoffBaseMs = 1000, marginMs = 10000, heartbeatMs = HEARTBEAT_MS } = {}) {
31
+ super();
32
+ if (process.env.NDI_PREVIEW_HOST === "1") throw new Error("refusing to start an NDI host from inside the NDI host");
33
+ this.configDir = configDir;
34
+ this.maxCrashes = maxCrashes;
35
+ this.backoffBaseMs = backoffBaseMs;
36
+ this.marginMs = marginMs;
37
+ this.heartbeatMs = heartbeatMs;
38
+ this.log = log || { info() {}, warn() {} };
39
+ this.child = null;
40
+ this.seq = 0;
41
+ this.pending = new Map();
42
+ this.live = new Map(); // stream id -> liveStart arguments, replayed after a restart
43
+ this.crashes = [];
44
+ this.nextStartAt = 0;
45
+ this.restartTimer = null;
46
+ this.failed = null; // a sentence once the host has given up
47
+ this.runtimePromise = null;
48
+ this.stopping = false;
49
+ this.heartbeat = null;
50
+ this.setMaxListeners(0);
51
+ }
52
+
53
+ get pid() { return this.child ? this.child.pid : null; }
54
+
55
+ // -> the running child, starting it (after any backoff) when needed
56
+ async _ensure() {
57
+ if (this.stopping) throw Object.assign(new Error("the NDI host has been stopped"), { code: "EHOSTEXIT" });
58
+ if (this.failed) throw Object.assign(new Error(this.failed), { code: "EHOSTFAILED" });
59
+ if (this.child) return this.child;
60
+ const wait = this.nextStartAt - Date.now();
61
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
62
+ if (this.stopping) throw Object.assign(new Error("the NDI host has been stopped"), { code: "EHOSTEXIT" });
63
+ if (this.failed) throw Object.assign(new Error(this.failed), { code: "EHOSTFAILED" });
64
+ if (!this.child) this._spawn();
65
+ return this.child;
66
+ }
67
+
68
+ _spawn() {
69
+ const cfg = this.configDir ? prepareNdiConfig(this.configDir) : { env: {}, applied: false, reason: "no folder" };
70
+ const env = { ...process.env, ...cfg.env, NDI_PREVIEW_HOST: "1", ELECTRON_RUN_AS_NODE: "1" };
71
+ const child = fork(HOST_SCRIPT, [], {
72
+ execPath: process.execPath,
73
+ execArgv: [],
74
+ env,
75
+ serialization: "advanced",
76
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
77
+ windowsHide: true,
78
+ });
79
+ this.child = child;
80
+ this.log.info(`NDI host started (pid ${child.pid})${cfg.applied ? ", TCP receive on" : ""}`);
81
+ let lines = 0;
82
+ const pipe = (stream) => stream.on("data", (d) => {
83
+ for (const line of String(d).split(/\r?\n/)) {
84
+ if (line.trim() && lines++ < 200) this.log.warn("NDI host: " + line.trim());
85
+ }
86
+ });
87
+ pipe(child.stdout); pipe(child.stderr);
88
+ child.on("message", (m) => this._onMessage(m));
89
+ running.add(child);
90
+ child.on("error", (e) => {
91
+ this.log.warn("NDI host error: " + e.message);
92
+ if (!child.pid || child.exitCode !== null) this._onExit(child, null, null, e.message); // never started
93
+ });
94
+ child.on("exit", (code, signal) => this._onExit(child, code, signal));
95
+ for (const [id, args] of this.live) this._replay(id, args);
96
+ this._armHeartbeat();
97
+ }
98
+
99
+ // a live stream that was running before a restart: start it again, and tell its node if that fails
100
+ _replay(id, args) {
101
+ this._send({ op: "liveStart", stream: id, ...args }).catch((e) => {
102
+ if (this.live.get(id) === args) this.emit("liveStatus", id, "error", e.message);
103
+ });
104
+ }
105
+
106
+ // While live previews run, nothing else may ask the host anything, so a hang would go unnoticed: ping it.
107
+ _armHeartbeat() {
108
+ clearInterval(this.heartbeat);
109
+ this.heartbeat = setInterval(() => {
110
+ if (this.child && this.live.size) this._checkAlive(this.child);
111
+ }, this.heartbeatMs);
112
+ this.heartbeat.unref?.();
113
+ }
114
+
115
+ _onMessage(m) {
116
+ if (!m || typeof m !== "object") return;
117
+ if (m.event === "frame") { this.emit("frame", m.stream, toBuffer(m.jpeg), m.meta); return; }
118
+ if (m.event === "liveStatus") { this.emit("liveStatus", m.stream, m.state, m.error); return; }
119
+ const p = this.pending.get(m.id);
120
+ if (!p) return;
121
+ this.pending.delete(m.id);
122
+ clearTimeout(p.timer);
123
+ if (m.ok) {
124
+ const r = m.result;
125
+ if (r && r.jpeg) r.jpeg = toBuffer(r.jpeg);
126
+ p.resolve(r);
127
+ } else {
128
+ p.reject(Object.assign(new Error(m.error), { code: m.code }));
129
+ }
130
+ }
131
+
132
+ _onExit(child, code, signal, error) {
133
+ running.delete(child);
134
+ if (this.child !== child) return;
135
+ this.child = null;
136
+ clearInterval(this.heartbeat);
137
+ const why = error ? error : signal ? `signal ${signal}` : `code ${code}`;
138
+ for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(Object.assign(new Error(`the NDI host stopped (${why})`), { code: "EHOSTEXIT" })); }
139
+ this.pending.clear();
140
+ if (this.stopping) return;
141
+ const now = Date.now();
142
+ this.crashes = this.crashes.filter((t) => now - t < CRASH_WINDOW_MS).concat(now);
143
+ if (this.crashes.length >= this.maxCrashes) {
144
+ this.failed = `the NDI host stopped ${this.crashes.length} times in a minute, so it is not restarted; restart Node-RED to try again`;
145
+ this.log.warn(this.failed);
146
+ this.emit("state", "failed", this.failed);
147
+ return;
148
+ }
149
+ const backoff = Math.min(30000, this.backoffBaseMs * 2 ** (this.crashes.length - 1));
150
+ this.nextStartAt = now + backoff;
151
+ this.log.warn(`the NDI host stopped (${why}); restarting in ${Math.round(backoff / 100) / 10} s`);
152
+ this.emit("state", "restarting", `the NDI host stopped (${why})`);
153
+ this.runtimePromise = null;
154
+ if (this.live.size) {
155
+ clearTimeout(this.restartTimer);
156
+ this.restartTimer = setTimeout(() => { this._ensure().catch(() => {}); }, backoff);
157
+ this.restartTimer.unref?.();
158
+ }
159
+ }
160
+
161
+ _send(msg, timeoutMs = 10000, watch = true) {
162
+ return new Promise((resolve, reject) => {
163
+ const child = this.child;
164
+ if (!child || !child.connected) { reject(Object.assign(new Error("the NDI host is not running"), { code: "EHOSTEXIT" })); return; }
165
+ const id = ++this.seq;
166
+ const timer = setTimeout(() => {
167
+ this.pending.delete(id);
168
+ reject(Object.assign(new Error("the NDI host did not answer in time"), { code: "ETIMEOUT" }));
169
+ if (watch) this._checkAlive(child);
170
+ }, timeoutMs);
171
+ timer.unref?.();
172
+ this.pending.set(id, { resolve, reject, timer });
173
+ try { child.send({ ...msg, id }); } catch (e) {
174
+ clearTimeout(timer); this.pending.delete(id);
175
+ reject(Object.assign(new Error("the NDI host is not running"), { code: "EHOSTEXIT" }));
176
+ }
177
+ });
178
+ }
179
+
180
+ // a request timed out: if the host does not answer a ping either, it is hung, so end it (it then restarts)
181
+ _checkAlive(child) {
182
+ if (child !== this.child || child.checking) return;
183
+ child.checking = true;
184
+ this._send({ op: "ping" }, PING_MS, false).then(
185
+ () => { child.checking = false; },
186
+ () => {
187
+ if (child === this.child) {
188
+ this.log.warn("the NDI host is not responding; ending it");
189
+ child.kill("SIGKILL");
190
+ }
191
+ });
192
+ }
193
+
194
+ async request(op, args = {}, timeoutMs = 10000) {
195
+ await this._ensure();
196
+ return this._send({ op, ...args }, timeoutMs);
197
+ }
198
+
199
+ // -> { ok:true, version, path } | { ok:false, error, help?, url? }. Never rejects.
200
+ runtime({ refresh = false } = {}) {
201
+ const why = unsupportedReason();
202
+ if (why) return Promise.resolve({ ok: false, error: why, unsupported: true });
203
+ if (refresh || !this.runtimePromise) {
204
+ const p = this.request("hello", {}, 15000).catch((e) => ({ ok: false, error: e.message, host: true, failed: e.code === "EHOSTFAILED" }));
205
+ this.runtimePromise = p;
206
+ p.then((rt) => { if (!rt.ok && rt.host && this.runtimePromise === p) this.runtimePromise = null; });
207
+ }
208
+ return this.runtimePromise;
209
+ }
210
+
211
+ grab(args) {
212
+ const timeoutMs = Math.max(200, Number(args.timeoutMs) || 3000);
213
+ return this.request("grab", args, timeoutMs + this.marginMs);
214
+ }
215
+
216
+ sources(waitMs = 2000) { return this.request("sources", { waitMs }, waitMs + this.marginMs); }
217
+
218
+ drop(owner) { return this.child ? this._send({ op: "drop", owner }).catch(() => {}) : Promise.resolve(); }
219
+
220
+ async liveStart(stream, args) {
221
+ this.live.set(stream, args);
222
+ await this._ensure();
223
+ // stopped while the host was (re)starting: never start a stream nobody owns any more
224
+ if (this.live.get(stream) !== args) return { stream, cancelled: true };
225
+ // (if the host was just started, it already got this stream from the replay; the host treats the repeat as a no-op)
226
+ return this._send({ op: "liveStart", stream, ...args });
227
+ }
228
+
229
+ liveStop(stream) {
230
+ this.live.delete(stream);
231
+ return this.child ? this._send({ op: "liveStop", stream }).catch(() => {}) : Promise.resolve();
232
+ }
233
+
234
+ status() { return this.request("status"); }
235
+
236
+ // end the host (Node-RED is stopping, or a test is done)
237
+ stop() {
238
+ this.stopping = true;
239
+ clearTimeout(this.restartTimer);
240
+ clearInterval(this.heartbeat);
241
+ this.live.clear();
242
+ const child = this.child;
243
+ if (!child) return Promise.resolve();
244
+ return new Promise((resolve) => {
245
+ const t = setTimeout(() => { child.kill("SIGKILL"); }, 3000);
246
+ child.once("exit", () => { clearTimeout(t); resolve(); });
247
+ try { child.disconnect(); } catch (e) { child.kill("SIGKILL"); }
248
+ });
249
+ }
250
+ }
251
+
252
+ // One host per Node-RED: both node types share it.
253
+ let shared = null;
254
+ function getClient(RED) {
255
+ if (!shared) {
256
+ const userDir = (RED && RED.settings && RED.settings.userDir) || require("os").tmpdir();
257
+ shared = new HostClient({
258
+ configDir: path.join(userDir, "ndi-preview"),
259
+ log: { info: (s) => RED.log.info("[ndi-preview] " + s), warn: (s) => RED.log.warn("[ndi-preview] " + s) },
260
+ });
261
+ }
262
+ return shared;
263
+ }
264
+
265
+ // end the shared host (tests, or a Node-RED that is shutting down its runtime)
266
+ async function stopShared() {
267
+ const c = shared;
268
+ shared = null;
269
+ if (c) await c.stop();
270
+ }
271
+
272
+ module.exports = { HostClient, getClient, stopShared, HOST_SCRIPT, MAX_CRASHES };
package/lib/host.js ADDED
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ // The NDI host: a child process that does all the NDI work for one Node-RED, so that a crash or a hang in the NDI
3
+ // Runtime can never take Node-RED down. It loads the add-on and the user's NDI Runtime, keeps the shared receivers,
4
+ // grabs frames and encodes the JPEGs. Node-RED talks to it over the IPC channel (lib/host-client.js).
5
+ //
6
+ // This process never starts another process. It exits when Node-RED goes away (the IPC channel closes).
7
+
8
+ if (process.env.NDI_PREVIEW_HOST !== "1" || typeof process.send !== "function") {
9
+ // Only lib/host-client.js may start this file, with the marker and an IPC channel. Anything else (a test runner,
10
+ // a user typing `node host.js`) gets a refusal, never a running host.
11
+ process.stderr.write("lib/host.js is started by Node-RED NDI Preview itself and cannot be run on its own.\n");
12
+ process.exit(2);
13
+ }
14
+
15
+ const { loadAddon } = require("./addon");
16
+ const { ReceiverPool } = require("./receivers");
17
+ const { frameToJpeg, fourccName } = require("./frame");
18
+
19
+ const RUNTIME_HELP = "Install the NDI 6 Runtime (or NDI Tools) from https://ndi.video/tools/ on the Node-RED computer, then restart Node-RED";
20
+
21
+ let addon = null;
22
+ let runtime = null;
23
+ let pool = null;
24
+
25
+ function loadRuntime() {
26
+ if (runtime && runtime.ok) return runtime;
27
+ try {
28
+ if (!addon) addon = loadAddon();
29
+ const r = addon.load(process.env.NDI_PREVIEW_RUNTIME || undefined);
30
+ runtime = r.ok ? r : { ...r, help: RUNTIME_HELP };
31
+ } catch (e) {
32
+ runtime = { ok: false, error: "could not load the NDI preview add-on: " + ((e && e.message) || e), help: RUNTIME_HELP };
33
+ }
34
+ if (runtime.ok && !pool) pool = new ReceiverPool(addon);
35
+ return runtime;
36
+ }
37
+
38
+ // process.send that never throws: the channel can close at any moment (Node-RED stopping)
39
+ function send(msg, cb) {
40
+ try {
41
+ if (process.connected) { process.send(msg, cb ? () => cb() : undefined); return; }
42
+ } catch (e) { /* channel closed */ }
43
+ if (cb) cb();
44
+ }
45
+
46
+ class HostError extends Error {
47
+ constructor(code, message) { super(message); this.code = code; }
48
+ }
49
+
50
+ function needRuntime() {
51
+ const rt = loadRuntime();
52
+ if (!rt.ok) throw new HostError("ENORUNTIME", rt.error);
53
+ return rt;
54
+ }
55
+
56
+ const clampInt = (v, lo, hi, dflt) => {
57
+ const n = Math.round(Number(v));
58
+ return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : dflt;
59
+ };
60
+
61
+ function sourceOf(m) {
62
+ const name = typeof m.source?.name === "string" ? m.source.name.trim() : "";
63
+ const address = typeof m.source?.address === "string" ? m.source.address.trim() : "";
64
+ if (!name && !address) throw new HostError("ENOSOURCE", "no NDI source set");
65
+ return { name, address };
66
+ }
67
+
68
+ function frameMeta(f) {
69
+ return {
70
+ width: f.xres, height: f.yres,
71
+ fps: f.frameRateD ? Math.round((f.frameRateN / f.frameRateD) * 100) / 100 : null,
72
+ fourCC: fourccName(f.fourCC),
73
+ lastFrame: f.timestamp > 0 ? new Date(Math.round(f.timestamp / 10000)).toISOString() : new Date().toISOString(),
74
+ };
75
+ }
76
+
77
+ // ---- operations -------------------------------------------------------------------------------------------------
78
+
79
+ async function opGrab(m) {
80
+ const rt = needRuntime();
81
+ const source = sourceOf(m);
82
+ const bandwidth = m.bandwidth === "highest" ? "highest" : "lowest";
83
+ const width = clampInt(m.width, 32, 1920, 320);
84
+ const quality = clampInt(m.quality, 10, 95, 70);
85
+ const timeoutMs = clampInt(m.timeoutMs, 200, 60000, 3000);
86
+ const idleMs = clampInt(m.idleMs, 0, 3600000, 30000);
87
+ const owner = String(m.owner || "anonymous");
88
+ const r = pool.get(source, bandwidth, m.label);
89
+ const token = Symbol("grab");
90
+ pool.hold(r, token); // not swept while we wait for the first frame
91
+ const t0 = Date.now();
92
+ try {
93
+ const f = await r.waitFrame(timeoutMs);
94
+ const j = frameToJpeg(f, width, quality);
95
+ return {
96
+ jpeg: j.jpeg,
97
+ meta: {
98
+ ...frameMeta(f), bandwidth, runtime: rt.version,
99
+ thumbWidth: j.width, thumbHeight: j.height, bytes: j.jpeg.length, grabMs: Date.now() - t0, ...r.stats(),
100
+ },
101
+ };
102
+ } finally {
103
+ pool.keep(r, owner, idleMs);
104
+ pool.release(r, token);
105
+ }
106
+ }
107
+
108
+ async function opSources(m) {
109
+ needRuntime();
110
+ const waitMs = clampInt(m.waitMs, 200, 10000, 2000);
111
+ const f = addon.findOpen();
112
+ try {
113
+ await new Promise((res) => setTimeout(res, waitMs));
114
+ return addon.findSources(f).map((s) => ({ name: s.name, address: s.address || "" }));
115
+ } finally {
116
+ addon.findClose(f);
117
+ }
118
+ }
119
+
120
+ // ---- live previews ----------------------------------------------------------------------------------------------
121
+
122
+ const streams = new Map(); // stream id -> { r, timer, ... }
123
+ let inflight = 0; // frame messages not yet handed to the IPC channel
124
+
125
+ function stopStream(id) {
126
+ const s = streams.get(id);
127
+ if (!s) return false;
128
+ clearInterval(s.timer);
129
+ streams.delete(id);
130
+ pool.release(s.r, s.holder, false);
131
+ return true;
132
+ }
133
+
134
+ function opLiveStart(m) {
135
+ const rt = needRuntime();
136
+ const id = String(m.stream || "");
137
+ if (!id) throw new HostError("EBADARG", "no stream id");
138
+ const source = sourceOf(m);
139
+ const bandwidth = m.bandwidth === "highest" ? "highest" : "lowest";
140
+ const fps = clampInt(m.fps, 1, 15, 10);
141
+ const width = clampInt(m.width, 32, 1280, 640);
142
+ const quality = clampInt(m.quality, 10, 95, 60);
143
+ const noVideoMs = clampInt(m.timeoutMs, 1000, 60000, 5000);
144
+ const params = JSON.stringify([source, bandwidth, fps, width, quality, noVideoMs, m.label || ""]);
145
+ const running = streams.get(id);
146
+ if (running && running.params === params) return { stream: id, fps, width, quality, bandwidth }; // already running
147
+ stopStream(id);
148
+ const r = pool.get(source, bandwidth, m.label);
149
+ const s = { r, holder: Symbol(id), params, lastTs: null, lastNewAt: Date.now(), state: "starting", frames: 0 };
150
+ pool.hold(r, s.holder);
151
+ const status = (state, error) => {
152
+ if (s.state === state) return;
153
+ s.state = state;
154
+ send({ event: "liveStatus", stream: id, state, error: error || null });
155
+ };
156
+ s.timer = setInterval(() => {
157
+ let f;
158
+ try { f = r.poll(); } catch (e) { status("error", (e && e.message) || String(e)); return; }
159
+ const now = Date.now();
160
+ if (!f || f.timestamp === s.lastTs) {
161
+ if (now - s.lastNewAt > noVideoMs) {
162
+ const { connections } = r.stats();
163
+ status("novideo", connections ? "connected, but no video is arriving" : "source not found, or it did not answer");
164
+ }
165
+ return;
166
+ }
167
+ s.lastTs = f.timestamp;
168
+ s.lastNewAt = now;
169
+ if (inflight >= 2) return; // Node-RED is behind: skip this frame, never queue
170
+ let j;
171
+ try { j = frameToJpeg(f, width, quality); } catch (e) { status("error", (e && e.message) || String(e)); return; }
172
+ s.frames++;
173
+ status("live");
174
+ inflight++;
175
+ send({ event: "frame", stream: id, jpeg: j.jpeg, meta: { ...frameMeta(f), thumbWidth: j.width, thumbHeight: j.height, bytes: j.jpeg.length, bandwidth, runtime: rt.version } },
176
+ () => { inflight--; });
177
+ }, Math.round(1000 / fps));
178
+ streams.set(id, s);
179
+ return { stream: id, fps, width, quality, bandwidth };
180
+ }
181
+
182
+ function opLiveStop(m) { return { stopped: stopStream(String(m.stream || "")) }; }
183
+
184
+ function opDrop(m) { if (pool) pool.drop(String(m.owner || "")); return {}; }
185
+
186
+ function opStatus() {
187
+ return { runtime, receivers: pool ? pool.openCount() : 0, streams: streams.size, pid: process.pid };
188
+ }
189
+
190
+ const OPS = {
191
+ hello: () => loadRuntime(),
192
+ ping: () => ({ pong: Date.now() }),
193
+ grab: opGrab,
194
+ sources: opSources,
195
+ liveStart: opLiveStart,
196
+ liveStop: opLiveStop,
197
+ drop: opDrop,
198
+ status: opStatus,
199
+ };
200
+
201
+ process.on("message", async (m) => {
202
+ if (!m || typeof m !== "object" || m.id === undefined) return;
203
+ const fn = OPS[m.op];
204
+ try {
205
+ if (!fn) throw new HostError("EBADOP", `unknown operation ${m.op}`);
206
+ const result = await fn(m);
207
+ send({ id: m.id, ok: true, result });
208
+ } catch (e) {
209
+ send({ id: m.id, ok: false, code: e.code || "EFAIL", error: (e && e.message) || String(e) });
210
+ }
211
+ });
212
+
213
+ function shutdown() {
214
+ for (const id of [...streams.keys()]) stopStream(id);
215
+ if (pool) pool.closeAll();
216
+ process.exit(0);
217
+ }
218
+ process.on("disconnect", shutdown); // Node-RED went away: never linger
219
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // A private copy of the user's NDI configuration with TCP receive switched on, for the NDI host process only.
3
+ //
4
+ // Why: by default the NDI Runtime receives video over reliable UDP. On Windows, the firewall drops that for a
5
+ // program it has no allow rule for, and Node-RED usually has none, so a receiver connects but gets no video. TCP
6
+ // receive avoids that. Everything else in the user's configuration (groups, discovery server, extra IPs) is kept.
7
+ //
8
+ // The copy is used only by the NDI host process (through its environment), never by Node-RED itself. If
9
+ // NDI_CONFIG_DIR is already set, the user (or a test) has chosen a configuration, and it is left alone.
10
+ const os = require("os");
11
+ const path = require("path");
12
+ const fs = require("fs");
13
+
14
+ function userConfigCandidates(env = process.env) {
15
+ const c = [];
16
+ if (process.platform === "win32") c.push(path.join(env.ProgramData || "C:\\ProgramData", "NDI", "ndi-config.v1.json"));
17
+ c.push(path.join(os.homedir(), ".ndi", "ndi-config.v1.json"));
18
+ return c;
19
+ }
20
+
21
+ function withTcpReceive(cfg) {
22
+ const out = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? { ...cfg } : {};
23
+ const ndi = out.ndi && typeof out.ndi === "object" ? { ...out.ndi } : {};
24
+ ndi.tcp = { ...(ndi.tcp || {}), recv: { ...((ndi.tcp || {}).recv || {}), enable: true } };
25
+ ndi.rudp = { ...(ndi.rudp || {}), recv: { ...((ndi.rudp || {}).recv || {}), enable: false } };
26
+ ndi.multicast = { ...(ndi.multicast || {}), recv: { ...((ndi.multicast || {}).recv || {}), enable: false } };
27
+ out.ndi = ndi;
28
+ return out;
29
+ }
30
+
31
+ // -> { env: { NDI_CONFIG_DIR } | {}, applied, dir?, reason? }. Never throws.
32
+ function prepareNdiConfig(dir, env = process.env) {
33
+ if (env.NDI_CONFIG_DIR) return { env: {}, applied: false, reason: "NDI_CONFIG_DIR is already set" };
34
+ try {
35
+ let cfg = {};
36
+ for (const c of userConfigCandidates(env)) {
37
+ try { cfg = JSON.parse(fs.readFileSync(c, "utf8").replace(/^\uFEFF/, "")); break; } catch (e) { /* try the next */ }
38
+ }
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(path.join(dir, "ndi-config.v1.json"), JSON.stringify(withTcpReceive(cfg), null, 2));
41
+ return { env: { NDI_CONFIG_DIR: dir }, applied: true, dir };
42
+ } catch (e) {
43
+ return { env: {}, applied: false, reason: String((e && e.message) || e) };
44
+ }
45
+ }
46
+
47
+ module.exports = { prepareNdiConfig, withTcpReceive };
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ // Shared NDI receivers, one per source and bandwidth, inside the NDI host process.
3
+ //
4
+ // Every node that shows the same source (stills and live previews alike) uses the same receiver, so each camera
5
+ // sees one connection from this computer, however many nodes and viewers there are.
6
+ //
7
+ // A receiver stays open while something needs it:
8
+ // - a live preview HOLDS it for as long as it runs;
9
+ // - a still grab KEEPS it for that node's idle time after the grab (0 = close straight after).
10
+ // When nothing holds or keeps it, it is closed (the sweep runs every second).
11
+
12
+ const keyOf = (source, bandwidth) =>
13
+ JSON.stringify([source.name || "", source.address || "", bandwidth === "highest" ? "highest" : "lowest"]);
14
+
15
+ class Receiver {
16
+ constructor(addon, key, source, bandwidth, label) {
17
+ this.addon = addon;
18
+ this.key = key;
19
+ this.opts = { name: source.name || undefined, address: source.address || undefined, bandwidth, label };
20
+ this.h = null;
21
+ this.openedAt = 0;
22
+ this.holders = new Set(); // live streams
23
+ this.keeps = new Map(); // owner -> keep open until (ms since epoch)
24
+ this.lastFrame = null; // the newest frame seen, shared by everyone using this receiver
25
+ this.lastNewAt = 0; // when a frame with a new timestamp last arrived
26
+ }
27
+
28
+ get open() { return !!this.h; }
29
+
30
+ ensureOpen() {
31
+ if (!this.h) { this.h = this.addon.open(this.opts); this.openedAt = Date.now(); this.lastFrame = null; this.lastNewAt = 0; }
32
+ return this.h;
33
+ }
34
+
35
+ // The newest frame, or null when none has arrived yet. Never waits. NDI's FrameSync repeats the last frame until
36
+ // a new one arrives (also after the source has gone), so an unchanged frame is not copied again, and lastNewAt
37
+ // says when the picture last really changed.
38
+ poll() {
39
+ const since = this.lastFrame ? this.lastFrame.timestamp : undefined;
40
+ const f = this.addon.grab(this.ensureOpen(), since);
41
+ if (f === false) return this.lastFrame; // nothing new since the frame we already have
42
+ if (f) { this.lastFrame = f; this.lastNewAt = Date.now(); }
43
+ return f;
44
+ }
45
+
46
+ stats() {
47
+ if (!this.h) return { connections: 0, framesTotal: 0, framesDropped: 0, connectedForMs: 0 };
48
+ return { ...this.addon.stats(this.h), connectedForMs: Date.now() - this.openedAt };
49
+ }
50
+
51
+ // A current frame: one that arrived within the last timeoutMs, waiting up to timeoutMs for it, so a source that
52
+ // has stopped is reported instead of its last picture being shown again. Rejects with a readable reason.
53
+ async waitFrame(timeoutMs) {
54
+ const t0 = Date.now();
55
+ for (;;) {
56
+ const f = this.poll();
57
+ // fresh, and still connected: a frame seen for the first time can still be the last one a dead source sent
58
+ if (f && Date.now() - this.lastNewAt <= timeoutMs && this.stats().connections > 0) return f;
59
+ if (Date.now() - t0 >= timeoutMs) {
60
+ const { connections } = this.stats();
61
+ if (f) throw new Error(connections ? `no new video for ${Math.round((Date.now() - this.lastNewAt) / 1000)} s` : "the source has gone");
62
+ throw new Error(connections ? `connected, but no video arrived within ${timeoutMs} ms` : "source not found, or it did not answer");
63
+ }
64
+ await new Promise((r) => setTimeout(r, 25));
65
+ }
66
+ }
67
+
68
+ needed(now) {
69
+ if (this.holders.size) return true;
70
+ for (const until of this.keeps.values()) if (until > now) return true;
71
+ return false;
72
+ }
73
+
74
+ close() {
75
+ if (this.h) { try { this.addon.close(this.h); } catch (e) { /* already closed */ } }
76
+ this.h = null;
77
+ this.lastFrame = null;
78
+ }
79
+ }
80
+
81
+ class ReceiverPool {
82
+ constructor(addon, { sweepMs = 1000 } = {}) {
83
+ this.addon = addon;
84
+ this.receivers = new Map();
85
+ this.timer = setInterval(() => this.sweep(), sweepMs);
86
+ this.timer.unref?.();
87
+ }
88
+
89
+ get(source, bandwidth, label) {
90
+ const bw = bandwidth === "highest" ? "highest" : "lowest";
91
+ const key = keyOf(source, bw);
92
+ let r = this.receivers.get(key);
93
+ if (!r) { r = new Receiver(this.addon, key, source, bw, label || "Node-RED NDI Preview"); this.receivers.set(key, r); }
94
+ return r;
95
+ }
96
+
97
+ keep(r, owner, idleMs) {
98
+ const until = Date.now() + Math.max(0, idleMs);
99
+ r.keeps.set(owner, Math.max(r.keeps.get(owner) || 0, until));
100
+ }
101
+
102
+ hold(r, holder) { r.holders.add(holder); }
103
+
104
+ // now = true closes an unneeded receiver at once; otherwise the next sweep (within a second) does, so a stream that
105
+ // is restarted straight away keeps the same connection to the camera
106
+ release(r, holder, now = true) { r.holders.delete(holder); if (now) this.sweep(); }
107
+
108
+ // forget everything an owner kept open (a node was paused, changed or removed)
109
+ drop(owner) {
110
+ for (const r of this.receivers.values()) r.keeps.delete(owner);
111
+ this.sweep();
112
+ }
113
+
114
+ sweep() {
115
+ const now = Date.now();
116
+ for (const [key, r] of this.receivers) {
117
+ for (const [owner, until] of r.keeps) if (until <= now) r.keeps.delete(owner);
118
+ if (!r.needed(now)) { r.close(); this.receivers.delete(key); }
119
+ }
120
+ }
121
+
122
+ openCount() { let n = 0; for (const r of this.receivers.values()) if (r.open) n++; return n; }
123
+
124
+ closeAll() {
125
+ clearInterval(this.timer);
126
+ for (const r of this.receivers.values()) r.close();
127
+ this.receivers.clear();
128
+ }
129
+ }
130
+
131
+ module.exports = { ReceiverPool, keyOf };
@@ -0,0 +1,12 @@
1
+ # Vendored code
2
+
3
+ | file | from | version | licence | changes |
4
+ |---|---|---|---|---|
5
+ | `jpeg-encoder.js` | jpeg-js `lib/encoder.js` (<https://github.com/jpeg-js/jpeg-js>), itself Andreas Ritter's JavaScript port of Adobe's ActionScript 3 JPEG encoder | 0.4.4 (npm `jpeg-js@0.4.4`, integrity `sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==`) | BSD-3-Clause (Eugene Ware, `jpeg-js.LICENSE`) and BSD (Adobe Systems, in the file's header) | none: byte-identical to the published file, sha256 `00bde8df2517eca556669ab6fc27b414e9ba3fc52a0a71e1e37ec7c5f19c3d34` |
6
+ | `jpeg-js.LICENSE` | jpeg-js `LICENSE` | 0.4.4 | | none: byte-identical, sha256 `c0a8512eabe960492fefd4f287523eb3b5ca1518ca37ef0ec83344cd7f089cb8` |
7
+
8
+ The encoder is vendored rather than depended on, so the package has no npm runtime dependencies. It has no
9
+ dependencies of its own and is only ever called with RGBA buffers this package builds itself.
10
+
11
+ To check it: `npm pack jpeg-js@0.4.4`, unpack, and compare the sha256 of `package/lib/encoder.js` and
12
+ `package/LICENSE` with the values above.