@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE.md +80 -0
- package/README.md +114 -2
- package/THIRD-PARTY-NOTICES.md +106 -0
- package/examples/live-preview-on-a-web-page.json +49 -0
- package/examples/thumbnail-still-on-a-web-page.json +48 -0
- package/lib/addon.js +43 -0
- package/lib/admin.js +72 -0
- package/lib/frame.js +58 -0
- package/lib/host-client.js +272 -0
- package/lib/host.js +219 -0
- package/lib/ndi-config.js +47 -0
- package/lib/receivers.js +131 -0
- package/lib/vendor/PROVENANCE.md +12 -0
- package/lib/vendor/jpeg-encoder.js +814 -0
- package/lib/vendor/jpeg-js.LICENSE +25 -0
- package/nodes/ndi-live.html +101 -0
- package/nodes/ndi-live.js +249 -0
- package/nodes/ndi-thumbnail.html +126 -0
- package/nodes/ndi-thumbnail.js +97 -0
- package/package.json +51 -4
- package/prebuilds/darwin-universal/ndi_preview.node +0 -0
- package/prebuilds/win32-x64/ndi_preview.node +0 -0
|
@@ -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 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 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/<node id>.mjpg</code>: the live picture. Show it with <code><img src="…"></code>
|
|
73
|
+
in any browser or dashboard template.</li>
|
|
74
|
+
<li><code>ndi-live/<node id>.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 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 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
|
+
};
|