@jamezjb/node-red-ndi-preview 0.1.0 → 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 +7 -0
- package/README.md +12 -2
- package/examples/live-preview-on-a-web-page.json +49 -0
- package/examples/thumbnail-still-on-a-web-page.json +48 -0
- package/nodes/ndi-live.html +5 -2
- package/nodes/ndi-live.js +18 -5
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 (13 November 2026)
|
|
4
|
+
|
|
5
|
+
- Example flows for both nodes, under **Import**, **Examples** in Node-RED.
|
|
6
|
+
- `ndi-live` sends its message again whenever what it knows about the NDI Runtime changes, so a flow is not left
|
|
7
|
+
showing an old answer: for example when the NDI helper answers late after Node-RED starts, or stops.
|
|
8
|
+
`msg.ndi.error` is now also set when the NDI helper has stopped.
|
|
9
|
+
|
|
3
10
|
## 0.1.0 (13 November 2026)
|
|
4
11
|
|
|
5
12
|
First version.
|
package/README.md
CHANGED
|
@@ -53,8 +53,10 @@ Serves the source as Motion-JPEG on Node-RED's HTTP node server (the same place
|
|
|
53
53
|
dashboard template;
|
|
54
54
|
- `<httpNodeRoot>/ndi-live/<node id>.jpg`: one still.
|
|
55
55
|
|
|
56
|
-
It sends the path
|
|
57
|
-
|
|
56
|
+
It sends the path when the flow starts, again whenever what it knows about the NDI Runtime changes, and in reply to
|
|
57
|
+
every message. `msg.ndi.runtime` is the runtime's version, or `null` when there is none yet; `msg.ndi.error` says why
|
|
58
|
+
the preview cannot work (no NDI Runtime, or the NDI helper has stopped). `null` with no error means it is still being
|
|
59
|
+
checked. Defaults: 10 frames a second (at most 15), 640 pixels wide, at most 4 viewers. It is a preview, not a programme monitor: no sound, a fraction of a second
|
|
58
60
|
behind live.
|
|
59
61
|
|
|
60
62
|
The connection to the source opens when the first viewer starts watching and closes a few seconds after the last
|
|
@@ -65,6 +67,14 @@ viewer and refuse new ones, `"start"` to allow them again.
|
|
|
65
67
|
**Who can watch:** the endpoints sit behind Node-RED's `httpNodeAuth` if your settings set it. Without it, anyone
|
|
66
68
|
who can reach Node-RED's HTTP endpoints can watch.
|
|
67
69
|
|
|
70
|
+
### Examples
|
|
71
|
+
|
|
72
|
+
In Node-RED: **Import**, **Examples**, `@jamezjb/node-red-ndi-preview`. Each example shows one node on a small web page
|
|
73
|
+
made with core nodes only: *thumbnail-still-on-a-web-page* serves the newest still at
|
|
74
|
+
`<httpNodeRoot>/ndi-example/still.jpg`, and *live-preview-on-a-web-page* shows the live preview at
|
|
75
|
+
`<httpNodeRoot>/ndi-example/live`. Pick your source in the NDI node after importing; the source name in the examples
|
|
76
|
+
is made up.
|
|
77
|
+
|
|
68
78
|
### Both nodes
|
|
69
79
|
|
|
70
80
|
- **Network use.** *Proxy* (the default) asks the sender for its low-bandwidth stream. *Full* asks for the full
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "b2c0de0000000001", "type": "comment", "name": "NDI live preview on a web page: read me",
|
|
4
|
+
"info": "Shows a live, silent preview of an NDI source on a small web page at\n`<httpNodeRoot>/ndi-example/live` (with default settings: http://<node-red>:1880/ndi-example/live).\n\n1. Open the **ndi live** node, press **Find sources** and pick your source (the example name is made up).\n2. Deploy, then open the page. The node connects to the source only while someone is watching.\n3. **stop** ends every viewer and refuses new ones; **start** allows them again.\n\nThe node sends the path of its stream when the flow starts; the page uses that path in an `<img>` tag.\nThe same `<img>` works in any dashboard template.\nAnyone who can reach Node-RED's HTTP endpoints can watch, unless your settings set `httpNodeAuth`.\n\nNeeds the NDI Runtime on the Node-RED computer (https://ndi.video/tools/).",
|
|
5
|
+
"x": 220, "y": 60, "wires": []
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"id": "b2c0de0000000002", "type": "inject", "name": "stop", "props": [{ "p": "payload" }],
|
|
9
|
+
"repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "stop", "payloadType": "str",
|
|
10
|
+
"x": 130, "y": 140, "wires": [["b2c0de0000000004"]]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "b2c0de0000000003", "type": "inject", "name": "start", "props": [{ "p": "payload" }],
|
|
14
|
+
"repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "start", "payloadType": "str",
|
|
15
|
+
"x": 130, "y": 180, "wires": [["b2c0de0000000004"]]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "b2c0de0000000004", "type": "ndi-live", "name": "", "source": "EXAMPLE-PC (Camera 1)", "address": "",
|
|
19
|
+
"fps": 10, "width": 640, "quality": 60, "bandwidth": "lowest", "maxViewers": 4, "timeout": 5,
|
|
20
|
+
"x": 320, "y": 160, "wires": [["b2c0de0000000005", "b2c0de0000000006"]]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "b2c0de0000000005", "type": "change", "name": "remember the stream path",
|
|
24
|
+
"rules": [{ "t": "set", "p": "ndiExampleLive", "pt": "flow", "to": "payload", "tot": "msg" }],
|
|
25
|
+
"action": "", "property": "", "from": "", "to": "", "reg": false,
|
|
26
|
+
"x": 560, "y": 140, "wires": [[]]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "b2c0de0000000006", "type": "debug", "name": "preview details", "active": true, "tosidebar": true,
|
|
30
|
+
"console": false, "tostatus": false, "complete": "ndi", "targetType": "msg", "statusVal": "", "statusType": "auto",
|
|
31
|
+
"x": 540, "y": 180, "wires": []
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "b2c0de0000000007", "type": "http in", "name": "", "url": "/ndi-example/live", "method": "get",
|
|
35
|
+
"upload": false, "swaggerDoc": "",
|
|
36
|
+
"x": 160, "y": 260, "wires": [["b2c0de0000000008"]]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "b2c0de0000000008", "type": "template", "name": "preview page", "field": "payload", "fieldType": "msg",
|
|
40
|
+
"format": "html", "syntax": "mustache", "output": "str",
|
|
41
|
+
"template": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>NDI preview</title>\n<style>\n body { margin: 0; background: #111; color: #ddd; font: 14px system-ui, sans-serif; }\n img { display: block; width: 100%; max-width: 960px; margin: 0 auto; background: #000; }\n p { text-align: center; }\n</style>\n</head>\n<body>\n{{#flow.ndiExampleLive}}<img src=\"{{{flow.ndiExampleLive}}}\" alt=\"Live NDI preview\">{{/flow.ndiExampleLive}}\n{{^flow.ndiExampleLive}}<p>The preview is not ready yet: deploy the flow, then reload this page.</p>{{/flow.ndiExampleLive}}\n</body>\n</html>\n",
|
|
42
|
+
"x": 370, "y": 260, "wires": [["b2c0de0000000009"]]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "b2c0de0000000009", "type": "http response", "name": "",
|
|
46
|
+
"statusCode": "", "headers": { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
|
|
47
|
+
"x": 570, "y": 260, "wires": []
|
|
48
|
+
}
|
|
49
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "a1c0de0000000001", "type": "comment", "name": "NDI still on a web page: read me",
|
|
4
|
+
"info": "Takes a JPEG still of an NDI source every 5 seconds and serves the newest one at\n`<httpNodeRoot>/ndi-example/still.jpg` (with default settings: http://<node-red>:1880/ndi-example/still.jpg).\n\n1. Open the **ndi thumbnail** node, press **Find sources** and pick your source (the example name is made up).\n2. Deploy. The debug sidebar shows each still's statistics (`msg.ndi`).\n3. Use **stop** and **start** to pause and resume (stop also disconnects from the source).\n\nWhile no picture arrives, the endpoint answers 503 instead of showing an old picture.\nAnyone who can reach Node-RED's HTTP endpoints can open the still, unless your settings set `httpNodeAuth`.\n\nNeeds the NDI Runtime on the Node-RED computer (https://ndi.video/tools/).",
|
|
5
|
+
"x": 210, "y": 60, "wires": []
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"id": "a1c0de0000000002", "type": "inject", "name": "stop", "props": [{ "p": "payload" }],
|
|
9
|
+
"repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "stop", "payloadType": "str",
|
|
10
|
+
"x": 130, "y": 140, "wires": [["a1c0de0000000004"]]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "a1c0de0000000003", "type": "inject", "name": "start", "props": [{ "p": "payload" }],
|
|
14
|
+
"repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "start", "payloadType": "str",
|
|
15
|
+
"x": 130, "y": 180, "wires": [["a1c0de0000000004"]]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "a1c0de0000000004", "type": "ndi-thumbnail", "name": "", "source": "EXAMPLE-PC (Camera 1)", "address": "",
|
|
19
|
+
"interval": 5, "width": 320, "quality": 70, "bandwidth": "lowest", "idle": 30, "timeout": 3,
|
|
20
|
+
"x": 330, "y": 160, "wires": [["a1c0de0000000005", "a1c0de0000000006"]]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "a1c0de0000000005", "type": "change", "name": "keep the newest still",
|
|
24
|
+
"rules": [{ "t": "set", "p": "ndiExampleStill", "pt": "flow", "to": "payload", "tot": "msg" }],
|
|
25
|
+
"action": "", "property": "", "from": "", "to": "", "reg": false,
|
|
26
|
+
"x": 560, "y": 140, "wires": [[]]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "a1c0de0000000006", "type": "debug", "name": "still statistics", "active": true, "tosidebar": true,
|
|
30
|
+
"console": false, "tostatus": false, "complete": "ndi", "targetType": "msg", "statusVal": "", "statusType": "auto",
|
|
31
|
+
"x": 550, "y": 180, "wires": []
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "a1c0de0000000007", "type": "http in", "name": "", "url": "/ndi-example/still.jpg", "method": "get",
|
|
35
|
+
"upload": false, "swaggerDoc": "",
|
|
36
|
+
"x": 170, "y": 260, "wires": [["a1c0de0000000008"]]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "a1c0de0000000008", "type": "function", "name": "newest still, or 503",
|
|
40
|
+
"func": "const still = flow.get(\"ndiExampleStill\");\nif (!Buffer.isBuffer(still)) {\n msg.statusCode = 503;\n msg.headers = { \"Content-Type\": \"text/plain\", \"Retry-After\": \"5\", \"Cache-Control\": \"no-store\" };\n msg.payload = \"No picture from the NDI source yet.\";\n return msg;\n}\nmsg.statusCode = 200;\nmsg.headers = { \"Content-Type\": \"image/jpeg\", \"Cache-Control\": \"no-store\" };\nmsg.payload = still;\nreturn msg;",
|
|
41
|
+
"outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [],
|
|
42
|
+
"x": 400, "y": 260, "wires": [["a1c0de0000000009"]]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "a1c0de0000000009", "type": "http response", "name": "", "statusCode": "", "headers": {},
|
|
46
|
+
"x": 590, "y": 260, "wires": []
|
|
47
|
+
}
|
|
48
|
+
]
|
package/nodes/ndi-live.html
CHANGED
|
@@ -85,11 +85,14 @@
|
|
|
85
85
|
<code>true</code> allows them again. Any message is answered with the output below.</dd>
|
|
86
86
|
</dl>
|
|
87
87
|
<h3>Outputs</h3>
|
|
88
|
-
<p>
|
|
88
|
+
<p>When the flow starts, whenever what the node knows about the NDI Runtime changes, and for each input:</p>
|
|
89
89
|
<dl class="message-properties">
|
|
90
90
|
<dt>payload <span class="property-type">string</span></dt><dd>the path of the live picture.</dd>
|
|
91
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
|
|
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>
|
|
93
96
|
</dl>
|
|
94
97
|
<h3>Details</h3>
|
|
95
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
|
package/nodes/ndi-live.js
CHANGED
|
@@ -16,6 +16,7 @@ const BOUNDARY = "ndipreviewframe";
|
|
|
16
16
|
const STOP_GRACE_MS = 5000;
|
|
17
17
|
const FRESH_MS = 2000;
|
|
18
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";
|
|
19
20
|
const liveNodes = new Map();
|
|
20
21
|
let routed = false;
|
|
21
22
|
|
|
@@ -81,16 +82,29 @@ module.exports = function (RED) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
function info() {
|
|
85
|
+
const error = hostFailed ? HOST_STOPPED : rt && !rt.ok ? rt.error : null;
|
|
84
86
|
return {
|
|
85
87
|
payload: streamUrl,
|
|
86
88
|
ndi: {
|
|
87
89
|
source: source.name || source.address, stream: streamUrl, still: stillUrl, viewers: viewers.size,
|
|
88
90
|
live: streaming, paused, maxViewers, fps, width, bandwidth, runtime: rt ? rt.version || null : null,
|
|
89
|
-
...(
|
|
91
|
+
...(error ? { error } : {}),
|
|
90
92
|
},
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
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
|
+
|
|
94
108
|
function startStream() {
|
|
95
109
|
clearTimeout(stopTimer); stopTimer = null;
|
|
96
110
|
if (streaming || paused || closed) return;
|
|
@@ -99,7 +113,7 @@ module.exports = function (RED) {
|
|
|
99
113
|
showStatus();
|
|
100
114
|
client.liveStart(node.id, { source, bandwidth, fps, width, quality, label, timeoutMs }).catch((e) => {
|
|
101
115
|
if (closed) return;
|
|
102
|
-
if (e.code === "ENORUNTIME") rt = { ok: false, error: e.message };
|
|
116
|
+
if (e.code === "ENORUNTIME") { rt = { ok: false, error: e.message }; announce(); }
|
|
103
117
|
node.warn(`NDI live "${source.name || source.address}": ${e.message}`);
|
|
104
118
|
stopStream();
|
|
105
119
|
endViewers();
|
|
@@ -146,7 +160,7 @@ module.exports = function (RED) {
|
|
|
146
160
|
}
|
|
147
161
|
function onHostState(s, why) {
|
|
148
162
|
if (closed) return;
|
|
149
|
-
if (s === "failed") { hostFailed = true; node.error(why); stopStream(); endViewers(); }
|
|
163
|
+
if (s === "failed") { hostFailed = true; node.error(why); stopStream(); endViewers(); announce(); }
|
|
150
164
|
}
|
|
151
165
|
client.on("frame", onFrame);
|
|
152
166
|
client.on("liveStatus", onLiveStatus);
|
|
@@ -223,12 +237,11 @@ module.exports = function (RED) {
|
|
|
223
237
|
|
|
224
238
|
liveNodes.set(node.id, node);
|
|
225
239
|
node.status(CHECKING);
|
|
226
|
-
let announced = false;
|
|
227
240
|
watchRuntime(RED, () => closed, (r) => {
|
|
228
241
|
if (r.ok || !r.host || r.failed) rt = r; // a host hiccup is not final: it is checked again
|
|
229
242
|
if (r.failed) hostFailed = true;
|
|
230
243
|
if (!r.ok && r.host && !r.failed) node.status(statusForRuntime(r)); else showStatus();
|
|
231
|
-
|
|
244
|
+
announce(); // tells the flow where the preview is
|
|
232
245
|
});
|
|
233
246
|
}
|
|
234
247
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jamezjb/node-red-ndi-preview",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Node-RED nodes that show NDI® sources: JPEG stills with frame statistics, and a live preview. Needs the NDI Runtime installed separately (ndi.video); contains no NDI code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"nodes/",
|
|
36
36
|
"lib/",
|
|
37
|
+
"examples/",
|
|
37
38
|
"prebuilds/darwin-universal/ndi_preview.node",
|
|
38
39
|
"prebuilds/win32-x64/ndi_preview.node",
|
|
39
40
|
"README.md",
|