@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 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 once when the flow starts and in reply to every message. Defaults: 10 frames a second (at most
57
- 15), 640 pixels wide, at most 4 viewers. It is a preview, not a programme monitor: no sound, a fraction of a second
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
+ ]
@@ -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>Once when the flow starts, and for each input:</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>.</dd>
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
- ...(rt && !rt.ok ? { error: rt.error } : {}),
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
- if (!announced) { announced = true; node.send(info()); } // tells the flow where the preview is
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.0",
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",