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

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,25 @@
1
+ Copyright (c) 2014, Eugene Ware
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright
8
+ notice, this list of conditions and the following disclaimer.
9
+ 2. Redistributions in binary form must reproduce the above copyright
10
+ notice, this list of conditions and the following disclaimer in the
11
+ documentation and/or other materials provided with the distribution.
12
+ 3. Neither the name of Eugene Ware nor the names of its contributors
13
+ may be used to endorse or promote products derived from this software
14
+ without specific prior written permission.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY EUGENE WARE ''AS IS'' AND ANY
17
+ EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL EUGENE WARE BE LIABLE FOR ANY
20
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
21
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
22
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
23
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
25
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,101 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("ndi-live", {
3
+ category: "network",
4
+ color: "#3BC3AB",
5
+ defaults: {
6
+ name: { value: "" },
7
+ source: { value: "" },
8
+ address: { value: "" },
9
+ fps: { value: 10, validate: RED.validators.number() },
10
+ width: { value: 640, validate: RED.validators.number() },
11
+ quality: { value: 60, validate: RED.validators.number() },
12
+ bandwidth: { value: "lowest" },
13
+ maxViewers: { value: 4, validate: RED.validators.number() },
14
+ timeout: { value: 5, validate: RED.validators.number() },
15
+ },
16
+ inputs: 1,
17
+ outputs: 1,
18
+ icon: "font-awesome/fa-video-camera",
19
+ paletteLabel: "ndi live",
20
+ label: function () { return this.name || this.source || this.address || "ndi live"; },
21
+ oneditprepare: function () {
22
+ // NdiPreviewEditor comes from the ndi-thumbnail editor file, which Node-RED loads with this one.
23
+ if (window.NdiPreviewEditor) {
24
+ NdiPreviewEditor.runtimeBanner($("#ndi-live-runtime"));
25
+ NdiPreviewEditor.sourcePicker($("#ndi-live-find"), $("#ndi-live-found"), $("#node-input-source"));
26
+ }
27
+ $("#ndi-live-url").text("ndi-live/" + this.id + ".mjpg");
28
+ },
29
+ });
30
+ </script>
31
+
32
+ <script type="text/html" data-template-name="ndi-live">
33
+ <div class="form-row" id="ndi-live-runtime">Checking for the NDI Runtime…</div>
34
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
35
+ <input type="text" id="node-input-name"></div>
36
+ <div class="form-row"><label for="node-input-source"><i class="fa fa-video-camera"></i> Source</label>
37
+ <input type="text" id="node-input-source" placeholder="COMPUTER (Source name)"></div>
38
+ <div class="form-row"><label></label>
39
+ <button type="button" class="red-ui-button" id="ndi-live-find">Find sources</button>
40
+ <select id="ndi-live-found" style="width:60%"></select></div>
41
+ <div class="form-row"><label for="node-input-address"><i class="fa fa-plug"></i> Address</label>
42
+ <input type="text" id="node-input-address" placeholder="optional ip:port, to connect without discovery"></div>
43
+ <div class="form-row"><label for="node-input-fps"><i class="fa fa-film"></i> Frame rate</label>
44
+ <input type="text" id="node-input-fps" style="width:50px"> per second <span style="opacity:.7">(1–15)</span></div>
45
+ <div class="form-row"><label for="node-input-width"><i class="fa fa-arrows-h"></i> Width</label>
46
+ <input type="text" id="node-input-width" style="width:60px"> px &nbsp; Quality
47
+ <input type="text" id="node-input-quality" style="width:50px"></div>
48
+ <div class="form-row"><label for="node-input-bandwidth"><i class="fa fa-tachometer"></i> Stream</label>
49
+ <select id="node-input-bandwidth">
50
+ <option value="lowest">Proxy (lowest bandwidth)</option>
51
+ <option value="highest">Full (highest bandwidth: much more network traffic)</option></select></div>
52
+ <div class="form-row"><label for="node-input-maxViewers"><i class="fa fa-users"></i> Viewers</label>
53
+ <input type="text" id="node-input-maxViewers" style="width:50px"> at most &nbsp; Timeout
54
+ <input type="text" id="node-input-timeout" style="width:50px"> s</div>
55
+ <div class="form-row"><label><i class="fa fa-link"></i> Preview at</label>
56
+ <code id="ndi-live-url"></code> <span style="opacity:.7">(under Node-RED's HTTP node path)</span></div>
57
+ <div class="form-tips">Anyone who can reach Node-RED's HTTP endpoints can watch this preview. Protect them with
58
+ <code>httpNodeAuth</code> if that matters.<br>
59
+ NDI® is a registered trademark of Vizrt NDI AB. More about NDI:
60
+ <a href="https://ndi.video/" target="_blank" rel="noopener">ndi.video</a>.</div>
61
+ </script>
62
+
63
+ <script type="text/html" data-help-name="ndi-live">
64
+ <p>A silent, low-latency live preview of one NDI® source, for checking a picture in a browser. It is a preview,
65
+ not a programme monitor: a few frames a second, no sound.</p>
66
+ <p><b>Needs the NDI Runtime on the Node-RED computer.</b> Install the NDI 6 Runtime (or NDI Tools) from
67
+ <a href="https://ndi.video/tools/">ndi.video</a>, then restart Node-RED. This node does not include NDI. It runs on
68
+ macOS and on Windows x64.</p>
69
+ <h3>Where to watch</h3>
70
+ <p>The node serves the picture on Node-RED's HTTP node server (the same place as <i>http in</i> nodes):</p>
71
+ <ul>
72
+ <li><code>ndi-live/&lt;node id&gt;.mjpg</code>: the live picture. Show it with <code>&lt;img src="…"&gt;</code>
73
+ in any browser or dashboard template.</li>
74
+ <li><code>ndi-live/&lt;node id&gt;.jpg</code>: one still of the same picture.</li>
75
+ </ul>
76
+ <p>The connection to the source opens when the first viewer starts watching and closes a few seconds after the
77
+ last one stops, so an unwatched preview uses no network and no processor time. Each frame is encoded once for
78
+ all viewers. A viewer on a slow connection skips frames instead of falling behind.</p>
79
+ <p>The endpoints are protected by <code>httpNodeAuth</code> if your Node-RED settings set it; without it, anyone
80
+ who can reach Node-RED can watch.</p>
81
+ <h3>Inputs</h3>
82
+ <dl class="message-properties">
83
+ <dt>payload <span class="property-type">any</span></dt>
84
+ <dd><code>"stop"</code> or <code>false</code> ends every viewer and refuses new ones; <code>"start"</code> or
85
+ <code>true</code> allows them again. Any message is answered with the output below.</dd>
86
+ </dl>
87
+ <h3>Outputs</h3>
88
+ <p>When the flow starts, whenever what the node knows about the NDI Runtime changes, and for each input:</p>
89
+ <dl class="message-properties">
90
+ <dt>payload <span class="property-type">string</span></dt><dd>the path of the live picture.</dd>
91
+ <dt>ndi <span class="property-type">object</span></dt>
92
+ <dd><code>{source, stream, still, viewers, live, paused, maxViewers, fps, width, bandwidth, runtime}</code>, and
93
+ <code>error</code> when the preview cannot work (no NDI Runtime, or the NDI helper has stopped).
94
+ <code>runtime</code> is the runtime's version, or <code>null</code> when there is none yet; <code>null</code>
95
+ with no <code>error</code> means it is still being checked.</dd>
96
+ </dl>
97
+ <h3>Details</h3>
98
+ <p><i>Proxy</i> asks the sender for its low-bandwidth stream; <i>Full</i> can put over 100 Mb/s per camera on the
99
+ network. The node never sets tally. Nodes that show the same source share one connection to it.</p>
100
+ <p>NDI® is a registered trademark of Vizrt NDI AB. <a href="https://ndi.video/">ndi.video</a></p>
101
+ </script>
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ // ndi-live: a silent, low-latency live preview of an NDI source, served as Motion-JPEG over HTTP so a plain
3
+ // <img src="..."> shows it in any browser. It is a preview for checking a picture, not a programme monitor.
4
+ //
5
+ // GET <httpNodeRoot>/ndi-live/<node id>.mjpg the live picture (multipart/x-mixed-replace)
6
+ // GET <httpNodeRoot>/ndi-live/<node id>.jpg one still of the same picture
7
+ //
8
+ // The NDI receiver runs only while someone is watching: the first viewer starts it, and it stops a few seconds
9
+ // after the last viewer leaves. Each frame is encoded once (in the NDI host process) and the same bytes go to every
10
+ // viewer; a viewer that falls behind skips frames instead of building a queue.
11
+ // These endpoints sit on Node-RED's normal HTTP node server, so httpNodeAuth (if set) protects them too.
12
+ const { getClient } = require("../lib/host-client");
13
+ const { registerAdmin, watchRuntime, statusForRuntime, CHECKING } = require("../lib/admin");
14
+
15
+ const BOUNDARY = "ndipreviewframe";
16
+ const STOP_GRACE_MS = 5000;
17
+ const FRESH_MS = 2000;
18
+ const STUCK_MS = 10000; // a viewer that has not taken a frame for this long is disconnected
19
+ const HOST_STOPPED = "the NDI helper has stopped; restart Node-RED";
20
+ const liveNodes = new Map();
21
+ let routed = false;
22
+
23
+ function joinUrl(root, rest) {
24
+ const r = typeof root === "string" ? root : "/";
25
+ return (r.endsWith("/") ? r : r + "/") + rest;
26
+ }
27
+
28
+ module.exports = function (RED) {
29
+ registerAdmin(RED);
30
+
31
+ if (!routed && RED.httpNode) {
32
+ routed = true;
33
+ RED.httpNode.get("/ndi-live/:file", (req, res) => {
34
+ const m = /^([A-Za-z0-9_.-]{1,64})\.(mjpg|jpg)$/.exec(req.params.file || ""); // older flows have dotted ids
35
+ const n = m && liveNodes.get(m[1]);
36
+ if (!n) { res.status(404).type("text/plain").send("No NDI live preview with that id is deployed."); return; }
37
+ if (m[2] === "mjpg") n.serveStream(req, res); else n.serveStill(req, res);
38
+ });
39
+ }
40
+
41
+ function NdiLiveNode(config) {
42
+ RED.nodes.createNode(this, config);
43
+ const node = this;
44
+ const client = getClient(RED);
45
+ const source = { name: config.source || "", address: config.address || "" };
46
+ const bandwidth = config.bandwidth === "highest" ? "highest" : "lowest";
47
+ const fps = Math.max(1, Math.min(15, Math.round(Number(config.fps) || 10)));
48
+ const width = Math.max(32, Math.min(1280, Number(config.width) || 640));
49
+ const quality = Math.max(10, Math.min(95, Number(config.quality) || 60));
50
+ const maxViewers = Math.max(1, Math.min(32, Math.round(Number(config.maxViewers) || 4)));
51
+ const timeoutMs = Math.max(1000, (Number(config.timeout) || 5) * 1000);
52
+ const label = "Node-RED " + (config.name || "NDI Preview") + " (live preview)";
53
+ const streamUrl = joinUrl(RED.settings.httpNodeRoot, `ndi-live/${node.id}.mjpg`);
54
+ const stillUrl = joinUrl(RED.settings.httpNodeRoot, `ndi-live/${node.id}.jpg`);
55
+
56
+ const viewers = new Set();
57
+ let rt = null, streaming = false, paused = false, closed = false, stopTimer = null, hostFailed = false;
58
+ let stillPending = null; // one still grab at a time, shared by every .jpg request
59
+ let last = null; // { jpeg, meta, at }
60
+ let state = "idle", stateError = null;
61
+ let frameTimes = [];
62
+ let statusTimer = null;
63
+
64
+ function measuredFps() {
65
+ const now = Date.now();
66
+ frameTimes = frameTimes.filter((t) => now - t < 2000);
67
+ return Math.round(frameTimes.length / 2);
68
+ }
69
+
70
+ function showStatus() {
71
+ if (closed) return;
72
+ if (hostFailed) { node.status(statusForRuntime({ ok: false, host: true, failed: true })); return; }
73
+ if (rt && !rt.ok) { node.status(statusForRuntime(rt)); return; }
74
+ if (!RED.httpNode) { node.status({ fill: "red", shape: "ring", text: "HTTP endpoints are off (httpNodeRoot)" }); return; }
75
+ if (!source.name && !source.address) { node.status({ fill: "grey", shape: "ring", text: "no source set" }); return; }
76
+ if (paused) { node.status({ fill: "grey", shape: "ring", text: "paused" }); return; }
77
+ const v = `${viewers.size} viewer${viewers.size === 1 ? "" : "s"}`;
78
+ if (!streaming) { node.status({ fill: "grey", shape: "ring", text: `idle · ${v}` }); return; }
79
+ if (state === "novideo" || state === "error") { node.status({ fill: "red", shape: "ring", text: `${(stateError || "no video").slice(0, 40)} · ${v}` }); return; }
80
+ if (state === "live") { node.status({ fill: "green", shape: "dot", text: `live · ${v} · ${measuredFps()} fps` }); return; }
81
+ node.status({ fill: "blue", shape: "ring", text: `connecting · ${v}` });
82
+ }
83
+
84
+ function info() {
85
+ const error = hostFailed ? HOST_STOPPED : rt && !rt.ok ? rt.error : null;
86
+ return {
87
+ payload: streamUrl,
88
+ ndi: {
89
+ source: source.name || source.address, stream: streamUrl, still: stillUrl, viewers: viewers.size,
90
+ live: streaming, paused, maxViewers, fps, width, bandwidth, runtime: rt ? rt.version || null : null,
91
+ ...(error ? { error } : {}),
92
+ },
93
+ };
94
+ }
95
+
96
+ // The node sends info() when the flow starts, and again whenever what it says about the NDI Runtime changes
97
+ // (for example the helper answered late, or stopped), so a flow showing it never keeps a stale answer.
98
+ let announced = null;
99
+ function announce() {
100
+ if (closed) return;
101
+ const { runtime, error } = info().ndi;
102
+ const key = JSON.stringify([runtime, error || null]);
103
+ if (key === announced) return;
104
+ announced = key;
105
+ node.send(info());
106
+ }
107
+
108
+ function startStream() {
109
+ clearTimeout(stopTimer); stopTimer = null;
110
+ if (streaming || paused || closed) return;
111
+ streaming = true; state = "starting"; stateError = null;
112
+ if (!statusTimer) { statusTimer = setInterval(showStatus, 1000); statusTimer.unref?.(); }
113
+ showStatus();
114
+ client.liveStart(node.id, { source, bandwidth, fps, width, quality, label, timeoutMs }).catch((e) => {
115
+ if (closed) return;
116
+ if (e.code === "ENORUNTIME") { rt = { ok: false, error: e.message }; announce(); }
117
+ node.warn(`NDI live "${source.name || source.address}": ${e.message}`);
118
+ stopStream();
119
+ endViewers();
120
+ });
121
+ }
122
+
123
+ function stopStream() {
124
+ clearTimeout(stopTimer); stopTimer = null;
125
+ clearInterval(statusTimer); statusTimer = null;
126
+ if (streaming) { streaming = false; client.liveStop(node.id); }
127
+ state = "idle";
128
+ showStatus();
129
+ }
130
+
131
+ function endViewers() {
132
+ for (const res of viewers) { try { res.end(); } catch (e) { /* already gone */ } }
133
+ viewers.clear();
134
+ showStatus();
135
+ }
136
+
137
+ function writeFrame(res, jpeg) {
138
+ if (res.writableNeedDrain) { // this viewer is behind: skip, never queue
139
+ if (!res.stuckSince) res.stuckSince = Date.now();
140
+ else if (Date.now() - res.stuckSince > STUCK_MS) res.destroy(); // gone quiet: free its place
141
+ return;
142
+ }
143
+ res.stuckSince = 0;
144
+ res.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`);
145
+ res.write(jpeg);
146
+ res.write("\r\n");
147
+ }
148
+
149
+ function onFrame(stream, jpeg, meta) {
150
+ if (stream !== node.id || closed) return;
151
+ last = { jpeg, meta, at: Date.now() };
152
+ frameTimes.push(last.at);
153
+ for (const res of viewers) writeFrame(res, jpeg);
154
+ }
155
+ function onLiveStatus(stream, s, error) {
156
+ if (stream !== node.id || closed) return;
157
+ state = s; stateError = error;
158
+ if (s === "novideo" || s === "error") node.warn(`NDI live "${source.name || source.address}": ${error}`);
159
+ showStatus();
160
+ }
161
+ function onHostState(s, why) {
162
+ if (closed) return;
163
+ if (s === "failed") { hostFailed = true; node.error(why); stopStream(); endViewers(); announce(); }
164
+ }
165
+ client.on("frame", onFrame);
166
+ client.on("liveStatus", onLiveStatus);
167
+ client.on("state", onHostState);
168
+
169
+ function refuse(res, code, text) {
170
+ res.status(code).set({ "Cache-Control": "no-store", "Retry-After": "5" }).type("text/plain").send(text);
171
+ }
172
+
173
+ node.serveStream = (req, res) => {
174
+ if (hostFailed) { refuse(res, 503, "The NDI helper has stopped; restart Node-RED."); return; }
175
+ if (paused) { refuse(res, 503, "This NDI live preview is paused."); return; }
176
+ if (rt && !rt.ok) { refuse(res, 503, "The NDI Runtime is not installed on the Node-RED computer (see ndi.video)."); return; }
177
+ if (!source.name && !source.address) { refuse(res, 503, "No NDI source is set."); return; }
178
+ if (viewers.size >= maxViewers) { refuse(res, 503, `This NDI live preview already has ${maxViewers} viewers.`); return; }
179
+ res.writeHead(200, {
180
+ "Content-Type": `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
181
+ "Cache-Control": "no-store, no-cache, must-revalidate",
182
+ Pragma: "no-cache",
183
+ Connection: "close",
184
+ "X-Content-Type-Options": "nosniff",
185
+ });
186
+ viewers.add(res);
187
+ const gone = () => {
188
+ if (!viewers.delete(res)) return;
189
+ showStatus();
190
+ if (!viewers.size && streaming && !stopTimer) { stopTimer = setTimeout(stopStream, STOP_GRACE_MS); stopTimer.unref?.(); }
191
+ };
192
+ res.on("close", gone);
193
+ res.on("error", gone);
194
+ startStream();
195
+ if (last && Date.now() - last.at < FRESH_MS) writeFrame(res, last.jpeg);
196
+ showStatus();
197
+ };
198
+
199
+ node.serveStill = async (req, res) => {
200
+ if (hostFailed) { refuse(res, 503, "The NDI helper has stopped; restart Node-RED."); return; }
201
+ if (paused) { refuse(res, 503, "This NDI live preview is paused."); return; }
202
+ const send = (jpeg) => res.status(200).set({ "Content-Type": "image/jpeg", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).send(jpeg);
203
+ if (last && Date.now() - last.at < FRESH_MS) { send(last.jpeg); return; }
204
+ try {
205
+ // many requests at once share one grab, so a burst of stills cannot queue up work in the NDI helper
206
+ if (!stillPending) {
207
+ stillPending = client.grab({ owner: node.id + ":still", source, bandwidth, width, quality, timeoutMs, idleMs: STOP_GRACE_MS, label })
208
+ .finally(() => { stillPending = null; });
209
+ }
210
+ const r = await stillPending;
211
+ send(r.jpeg);
212
+ } catch (e) {
213
+ refuse(res, 503, `No picture: ${e.message}`);
214
+ }
215
+ };
216
+
217
+ node.on("input", (msg, send, done) => {
218
+ const cmd = typeof msg.payload === "string" ? msg.payload.trim().toLowerCase() : "";
219
+ if (cmd === "stop" || msg.payload === false) { paused = true; endViewers(); stopStream(); client.drop(node.id + ":still"); }
220
+ else if (cmd === "start" || msg.payload === true) { paused = false; showStatus(); }
221
+ send({ ...RED.util.cloneMessage(msg), ...info() });
222
+ done();
223
+ });
224
+
225
+ node.on("close", (removed, done) => {
226
+ closed = true;
227
+ liveNodes.delete(node.id);
228
+ client.off("frame", onFrame);
229
+ client.off("liveStatus", onLiveStatus);
230
+ client.off("state", onHostState);
231
+ clearTimeout(stopTimer); clearInterval(statusTimer);
232
+ for (const res of viewers) { try { res.end(); } catch (e) { /* already gone */ } }
233
+ viewers.clear();
234
+ const stops = [streaming ? client.liveStop(node.id) : null, client.drop(node.id + ":still")];
235
+ Promise.all(stops).then(() => done(), () => done());
236
+ });
237
+
238
+ liveNodes.set(node.id, node);
239
+ node.status(CHECKING);
240
+ watchRuntime(RED, () => closed, (r) => {
241
+ if (r.ok || !r.host || r.failed) rt = r; // a host hiccup is not final: it is checked again
242
+ if (r.failed) hostFailed = true;
243
+ if (!r.ok && r.host && !r.failed) node.status(statusForRuntime(r)); else showStatus();
244
+ announce(); // tells the flow where the preview is
245
+ });
246
+ }
247
+
248
+ RED.nodes.registerType("ndi-live", NdiLiveNode);
249
+ };
@@ -0,0 +1,126 @@
1
+ <script type="text/javascript">
2
+ (function () {
3
+ // Shared by both NDI preview nodes: the runtime banner and the "Find sources" picker.
4
+ window.NdiPreviewEditor = window.NdiPreviewEditor || {
5
+ runtimeBanner: function (el) {
6
+ function show(rt) {
7
+ if (rt.ok) { el.text("NDI Runtime " + rt.version).css({ opacity: 0.7, color: "" }); return; }
8
+ var text = rt.unsupported ? $("<span>").text(rt.error + ".")
9
+ : $("<span>").append("<b>The NDI Runtime is not installed on the Node-RED computer.</b> Install the NDI 6 Runtime " +
10
+ '(or NDI Tools) from <a href="https://ndi.video/tools/" target="_blank" rel="noopener">ndi.video</a>, then restart ' +
11
+ "Node-RED. These nodes do not include NDI. ")
12
+ .append($('<a href="#">').text("Check again").on("click", function (e) { e.preventDefault(); load("?refresh=1"); }));
13
+ el.empty().append(text).css({ opacity: 1, color: "var(--red-ui-text-color-error, #c00)" });
14
+ }
15
+ function load(q) {
16
+ el.text("Checking for the NDI Runtime…");
17
+ $.getJSON("ndi-preview/runtime" + (q || "")).done(show).fail(function () { el.text("Could not check for the NDI Runtime."); });
18
+ }
19
+ load("");
20
+ },
21
+ sourcePicker: function (button, select, input) {
22
+ button.on("click", function () {
23
+ select.empty().append($("<option>").text("Searching…"));
24
+ $.getJSON("ndi-preview/sources").done(function (list) {
25
+ select.empty().append($("<option>").val("").text(list.length ? "Pick a source…" : "No NDI sources found"));
26
+ list.forEach(function (s) {
27
+ select.append($("<option>").val(s.name).text(s.name + (s.address ? " (" + s.address + ")" : "")));
28
+ });
29
+ }).fail(function (x) {
30
+ var msg = (x.responseJSON && x.responseJSON.error) || "search failed";
31
+ select.empty().append($("<option>").val("").text("Search failed: " + msg));
32
+ });
33
+ });
34
+ select.on("change", function () { if (this.value) input.val(this.value); });
35
+ },
36
+ };
37
+ })();
38
+
39
+ RED.nodes.registerType("ndi-thumbnail", {
40
+ category: "network",
41
+ color: "#3BC3AB",
42
+ defaults: {
43
+ name: { value: "" },
44
+ source: { value: "" },
45
+ address: { value: "" },
46
+ interval: { value: 5, validate: RED.validators.number(true) },
47
+ width: { value: 320, validate: RED.validators.number() },
48
+ quality: { value: 70, validate: RED.validators.number() },
49
+ bandwidth: { value: "lowest" },
50
+ idle: { value: 30, validate: RED.validators.number(true) },
51
+ timeout: { value: 3, validate: RED.validators.number() },
52
+ },
53
+ inputs: 1,
54
+ outputs: 1,
55
+ icon: "font-awesome/fa-camera",
56
+ paletteLabel: "ndi thumbnail",
57
+ label: function () { return this.name || this.source || this.address || "ndi thumbnail"; },
58
+ oneditprepare: function () {
59
+ NdiPreviewEditor.runtimeBanner($("#ndi-thumb-runtime"));
60
+ NdiPreviewEditor.sourcePicker($("#ndi-thumb-find"), $("#ndi-thumb-found"), $("#node-input-source"));
61
+ },
62
+ });
63
+ </script>
64
+
65
+ <script type="text/html" data-template-name="ndi-thumbnail">
66
+ <div class="form-row" id="ndi-thumb-runtime">Checking for the NDI Runtime…</div>
67
+ <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
68
+ <input type="text" id="node-input-name"></div>
69
+ <div class="form-row"><label for="node-input-source"><i class="fa fa-video-camera"></i> Source</label>
70
+ <input type="text" id="node-input-source" placeholder="COMPUTER (Source name)"></div>
71
+ <div class="form-row"><label></label>
72
+ <button type="button" class="red-ui-button" id="ndi-thumb-find">Find sources</button>
73
+ <select id="ndi-thumb-found" style="width:60%"></select></div>
74
+ <div class="form-row"><label for="node-input-address"><i class="fa fa-plug"></i> Address</label>
75
+ <input type="text" id="node-input-address" placeholder="optional ip:port, to connect without discovery"></div>
76
+ <div class="form-row"><label for="node-input-interval"><i class="fa fa-clock-o"></i> Every</label>
77
+ <input type="text" id="node-input-interval" style="width:60px"> s <span style="opacity:.7">(0 = only when a message arrives)</span></div>
78
+ <div class="form-row"><label for="node-input-width"><i class="fa fa-arrows-h"></i> Width</label>
79
+ <input type="text" id="node-input-width" style="width:60px"> px &nbsp; Quality
80
+ <input type="text" id="node-input-quality" style="width:50px"></div>
81
+ <div class="form-row"><label for="node-input-bandwidth"><i class="fa fa-tachometer"></i> Stream</label>
82
+ <select id="node-input-bandwidth">
83
+ <option value="lowest">Proxy (lowest bandwidth)</option>
84
+ <option value="highest">Full (highest bandwidth: much more network traffic)</option></select></div>
85
+ <div class="form-row"><label for="node-input-idle"><i class="fa fa-power-off"></i> Idle drop</label>
86
+ <input type="text" id="node-input-idle" style="width:60px"> s &nbsp; Timeout
87
+ <input type="text" id="node-input-timeout" style="width:50px"> s</div>
88
+ <div class="form-tips">NDI® is a registered trademark of Vizrt NDI AB. More about NDI:
89
+ <a href="https://ndi.video/" target="_blank" rel="noopener">ndi.video</a>.</div>
90
+ </script>
91
+
92
+ <script type="text/html" data-help-name="ndi-thumbnail">
93
+ <p>Receives one NDI® source and outputs a JPEG still every few seconds, or on each input.</p>
94
+ <p><b>Needs the NDI Runtime on the Node-RED computer.</b> Install the NDI 6 Runtime (or NDI Tools) from
95
+ <a href="https://ndi.video/tools/">ndi.video</a>, then restart Node-RED. This node does not include NDI; without
96
+ the runtime it shows "NDI Runtime not installed" and does nothing else. It runs on macOS and on Windows x64.</p>
97
+ <h3>Inputs</h3>
98
+ <dl class="message-properties">
99
+ <dt>payload <span class="property-type">any</span></dt>
100
+ <dd>any message grabs a still now. <code>"stop"</code> or <code>false</code> pauses the timer and disconnects;
101
+ <code>"start"</code> or <code>true</code> resumes.</dd>
102
+ <dt class="optional">source <span class="property-type">string</span></dt><dd>overrides the configured source name.</dd>
103
+ <dt class="optional">address <span class="property-type">string</span></dt><dd>overrides the configured ip:port.</dd>
104
+ </dl>
105
+ <h3>Outputs</h3>
106
+ <dl class="message-properties">
107
+ <dt>payload <span class="property-type">Buffer | null</span></dt>
108
+ <dd>the JPEG, or <code>null</code> when no picture arrived.</dd>
109
+ <dt>statusCode, headers</dt>
110
+ <dd><code>200</code> and <code>content-type: image/jpeg</code> on success, like an http request node's reply,
111
+ so the message can feed flows built for a camera's still-image URL.</dd>
112
+ <dt>ndi <span class="property-type">object</span></dt>
113
+ <dd><code>{source, width, height, fps, fourCC, bandwidth, lastFrame, runtime, thumbWidth, thumbHeight, bytes,
114
+ grabMs, connections, framesTotal, framesDropped, connectedForMs}</code>, or <code>{source, error, misses}</code>
115
+ when no picture arrived. <code>width</code> and <code>height</code> are those of the stream received: with
116
+ <i>Proxy</i> that is the sender's low-bandwidth stream (often 640×360), not the camera's own format.</dd>
117
+ </dl>
118
+ <h3>Details</h3>
119
+ <p>The connection to the source stays open while stills are taken within <i>Idle drop</i> seconds of each other
120
+ (a timer faster than that keeps it open); otherwise it connects for each still, which takes about 0.1–1 s.
121
+ <i>Proxy</i> asks the sender for its low-bandwidth stream. The node never sets tally, so it never lights a
122
+ camera's on-air lamp.</p>
123
+ <p>All NDI work runs in a separate helper process, so a problem in the NDI Runtime cannot stop Node-RED. Nodes
124
+ that show the same source share one connection to it.</p>
125
+ <p>NDI® is a registered trademark of Vizrt NDI AB. <a href="https://ndi.video/">ndi.video</a></p>
126
+ </script>
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ // ndi-thumbnail: an NDI source -> a JPEG still (msg.payload, a Buffer) plus frame statistics (msg.ndi).
3
+ // The message looks like an http request node's reply (statusCode 200, content-type image/jpeg), so it can feed
4
+ // flows that were built for a camera's still-image URL without changes.
5
+ // All NDI work happens in the NDI host process (lib/host.js); this file only schedules grabs and sends messages.
6
+ const { getClient } = require("../lib/host-client");
7
+ const { registerAdmin, watchRuntime, statusForRuntime, CHECKING } = require("../lib/admin");
8
+
9
+ module.exports = function (RED) {
10
+ registerAdmin(RED);
11
+
12
+ function NdiThumbnailNode(config) {
13
+ RED.nodes.createNode(this, config);
14
+ const node = this;
15
+ const client = getClient(RED);
16
+ const everyS = Math.max(0, Number(config.interval) || 0); // 0 = only when a message arrives
17
+ const width = Math.max(32, Math.min(1920, Number(config.width) || 320));
18
+ const quality = Math.max(10, Math.min(95, Number(config.quality) || 70));
19
+ const timeoutMs = Math.max(500, (Number(config.timeout) || 3) * 1000);
20
+ const idleS = config.idle === "" || config.idle == null ? 30 : Math.max(0, Number(config.idle) || 0);
21
+ const bandwidth = config.bandwidth === "highest" ? "highest" : "lowest";
22
+ // A timer that fires within the idle time keeps the receiver open between grabs; a slower timer connects for
23
+ // each grab. On input only, the receiver stays open for the idle time after each grab.
24
+ const idleMs = everyS > 0 ? (everyS < idleS ? everyS * 1000 + 5000 : 0) : idleS * 1000;
25
+ const label = "Node-RED " + (config.name || "NDI Preview");
26
+
27
+ let rt = null, timer = null, busy = false, paused = false, misses = 0, closed = false;
28
+
29
+ async function tick(msg, send, done) {
30
+ send = send || node.send.bind(node);
31
+ const name = (msg && typeof msg.source === "string" && msg.source) || config.source || "";
32
+ const address = (msg && typeof msg.address === "string" && msg.address) || config.address || "";
33
+ if (!name && !address) { node.status({ fill: "grey", shape: "ring", text: "no source set" }); if (done) done(); return; }
34
+ if (busy) { if (done) done(); return; } // never stack grabs
35
+ busy = true;
36
+ const out = msg ? RED.util.cloneMessage(msg) : {};
37
+ try {
38
+ const r = await client.grab({ owner: node.id, source: { name, address }, bandwidth, width, quality, timeoutMs, idleMs, label });
39
+ if (closed) return;
40
+ misses = 0;
41
+ out.payload = r.jpeg;
42
+ out.statusCode = 200;
43
+ out.headers = { "content-type": "image/jpeg" };
44
+ out.ndi = { source: name || address, ...r.meta };
45
+ node.status({ fill: "green", shape: "dot", text: `${r.meta.width}×${r.meta.height}${r.meta.fps ? " @ " + r.meta.fps : ""} · ${new Date().toLocaleTimeString()}` });
46
+ send(out);
47
+ } catch (e) {
48
+ if (closed) return;
49
+ if (e.code === "ENORUNTIME") { stopTimer(); rt = { ok: false }; node.status(statusForRuntime({ ok: false })); return; }
50
+ if (e.code === "EHOSTFAILED") { stopTimer(); rt = { ok: false, host: true, failed: true }; node.status(statusForRuntime(rt)); node.error(e.message); return; }
51
+ misses++;
52
+ out.payload = null;
53
+ out.ndi = { source: name || address, error: e.message, misses };
54
+ node.status({ fill: "red", shape: "ring", text: e.message.slice(0, 48) });
55
+ if (misses === 1) node.warn(`NDI "${name || address}": ${e.message}`); // once per outage, not every tick
56
+ send(out);
57
+ } finally {
58
+ busy = false;
59
+ if (done) done();
60
+ }
61
+ }
62
+
63
+ function startTimer() {
64
+ stopTimer();
65
+ if (everyS > 0 && !paused && rt && rt.ok) timer = setInterval(() => tick(null), everyS * 1000);
66
+ }
67
+ function stopTimer() { clearInterval(timer); timer = null; }
68
+
69
+ node.on("input", (msg, send, done) => {
70
+ const cmd = typeof msg.payload === "string" ? msg.payload.trim().toLowerCase() : "";
71
+ if (cmd === "stop" || msg.payload === false) { // e.g. the page showing it was hidden
72
+ paused = true; stopTimer(); client.drop(node.id);
73
+ node.status({ fill: "grey", shape: "ring", text: "paused" }); done(); return;
74
+ }
75
+ if (rt && !rt.ok) { node.status(statusForRuntime(rt)); done(); return; } // nothing can run without NDI
76
+ if (cmd === "start" || msg.payload === true) { paused = false; startTimer(); }
77
+ tick(msg, send, done);
78
+ });
79
+
80
+ node.on("close", (done) => {
81
+ closed = true;
82
+ stopTimer();
83
+ client.drop(node.id).then(() => done(), () => done());
84
+ });
85
+
86
+ node.status(CHECKING);
87
+ watchRuntime(RED, () => closed, (r) => {
88
+ if (!r.ok) { node.status(statusForRuntime(r)); if (!r.host || r.failed) rt = r; return; } // a host hiccup is retried
89
+ rt = r;
90
+ node.status({ fill: "grey", shape: "ring", text: config.source || config.address || "no source set" });
91
+ startTimer();
92
+ if (everyS > 0) setTimeout(() => { if (!paused && !closed) tick(null); }, 500);
93
+ });
94
+ }
95
+
96
+ RED.nodes.registerType("ndi-thumbnail", NdiThumbnailNode);
97
+ };