@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
10
|
+
## 0.1.0 (13 November 2026)
|
|
11
|
+
|
|
12
|
+
First version.
|
|
13
|
+
|
|
14
|
+
- `ndi-thumbnail`: a JPEG still of an NDI® source every few seconds or on each message, with frame statistics.
|
|
15
|
+
The message has the same shape as an http request node's reply.
|
|
16
|
+
- `ndi-live`: a live Motion-JPEG preview at `<httpNodeRoot>/ndi-live/<node id>.mjpg` (and one still at `.jpg`).
|
|
17
|
+
It receives only while someone is watching, encodes each frame once for all viewers, and limits the number of
|
|
18
|
+
viewers.
|
|
19
|
+
- All NDI work runs in a separate helper process, which is restarted if it crashes or hangs, so Node-RED keeps
|
|
20
|
+
running. Nodes that show the same source share one connection to it. Tally is never set.
|
|
21
|
+
- Uses the NDI Runtime the user installs (NDI 5 or 6); contains no NDI code. Without the runtime the nodes say
|
|
22
|
+
"NDI Runtime not installed (see ndi.video)" and do nothing else.
|
|
23
|
+
- macOS (universal) and Windows x64. No npm dependencies.
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Copyright (c) 2026 James Breedon
|
|
2
|
+
|
|
3
|
+
Licensed under the PolyForm Noncommercial License 1.0.0.
|
|
4
|
+
For commercial licensing, contact james@jamesbreedon.com.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# PolyForm Noncommercial License 1.0.0
|
|
9
|
+
|
|
10
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
11
|
+
|
|
12
|
+
## Acceptance
|
|
13
|
+
|
|
14
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
15
|
+
|
|
16
|
+
## Copyright License
|
|
17
|
+
|
|
18
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
19
|
+
|
|
20
|
+
## Distribution License
|
|
21
|
+
|
|
22
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
23
|
+
|
|
24
|
+
## Notices
|
|
25
|
+
|
|
26
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
27
|
+
|
|
28
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
29
|
+
|
|
30
|
+
## Changes and New Works License
|
|
31
|
+
|
|
32
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
33
|
+
|
|
34
|
+
## Patent License
|
|
35
|
+
|
|
36
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
37
|
+
|
|
38
|
+
## Noncommercial Purposes
|
|
39
|
+
|
|
40
|
+
Any noncommercial purpose is a permitted purpose.
|
|
41
|
+
|
|
42
|
+
## Personal Uses
|
|
43
|
+
|
|
44
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
45
|
+
|
|
46
|
+
## Noncommercial Organizations
|
|
47
|
+
|
|
48
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
49
|
+
|
|
50
|
+
## Fair Use
|
|
51
|
+
|
|
52
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
53
|
+
|
|
54
|
+
## No Other Rights
|
|
55
|
+
|
|
56
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
57
|
+
|
|
58
|
+
## Patent Defense
|
|
59
|
+
|
|
60
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
61
|
+
|
|
62
|
+
## Violations
|
|
63
|
+
|
|
64
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
69
|
+
|
|
70
|
+
## Definitions
|
|
71
|
+
|
|
72
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
73
|
+
|
|
74
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
75
|
+
|
|
76
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
77
|
+
|
|
78
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
79
|
+
|
|
80
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Node-RED NDI Preview
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node-RED nodes that show NDI® sources:
|
|
4
|
+
|
|
5
|
+
- **`ndi-thumbnail`**: a JPEG still of a source every few seconds (or on each message), with frame statistics.
|
|
6
|
+
- **`ndi-live`**: a silent, low-latency live preview of a source that any browser can show, for checking a picture.
|
|
7
|
+
|
|
8
|
+
NDI® is a registered trademark of Vizrt NDI AB. This project is not affiliated with or endorsed by Vizrt NDI AB;
|
|
9
|
+
"NDI" in its name says only what the nodes work with. More about NDI: <https://ndi.video/>.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
In Node-RED: **Manage palette**, **Install**, search for `@jamezjb/node-red-ndi-preview`. Or, in your Node-RED user
|
|
14
|
+
folder (usually `~/.node-red`):
|
|
15
|
+
|
|
16
|
+
npm install @jamezjb/node-red-ndi-preview
|
|
17
|
+
|
|
18
|
+
Then restart Node-RED.
|
|
19
|
+
|
|
20
|
+
## What you need
|
|
21
|
+
|
|
22
|
+
- **The NDI Runtime, installed by you, on the computer that runs Node-RED.** This package contains no NDI code.
|
|
23
|
+
Download the free NDI 6 Runtime, or NDI Tools (which includes it), from <https://ndi.video/tools/>, install it,
|
|
24
|
+
then restart Node-RED. Without it, the nodes say "NDI Runtime not installed (see ndi.video)" and do nothing
|
|
25
|
+
else. NDI 5 and 6 runtimes work; NDI 6 is recommended.
|
|
26
|
+
- **macOS** (11 or later, Apple silicon or Intel) or **Windows x64** (10 or 11). Other systems are not supported:
|
|
27
|
+
the nodes load, and say so.
|
|
28
|
+
- Node-RED 3.1 or later, on Node.js 18 or later.
|
|
29
|
+
|
|
30
|
+
The package has no npm dependencies.
|
|
31
|
+
|
|
32
|
+
## The nodes
|
|
33
|
+
|
|
34
|
+
### ndi-thumbnail
|
|
35
|
+
|
|
36
|
+
Pick a source by name ("Find sources" lists what the Node-RED computer can see) or give its `ip:port`. Every *N*
|
|
37
|
+
seconds, or whenever a message arrives, it sends:
|
|
38
|
+
|
|
39
|
+
- `msg.payload`: the JPEG (a Buffer), `msg.statusCode` 200 and `msg.headers['content-type']` `image/jpeg`, the same
|
|
40
|
+
shape as an http request node's reply, so it can feed flows built for a camera's still-image URL;
|
|
41
|
+
- `msg.ndi`: `{source, width, height, fps, fourCC, bandwidth, lastFrame, runtime, thumbWidth, thumbHeight, bytes,
|
|
42
|
+
grabMs, connections, framesTotal, framesDropped, connectedForMs}`.
|
|
43
|
+
|
|
44
|
+
When no picture arrives it sends `payload: null` with `msg.ndi.error`, and warns once per outage. Send `"stop"` (or
|
|
45
|
+
`false`) to pause and disconnect, and `"start"` (or `true`) to resume. `msg.source` / `msg.address` override the
|
|
46
|
+
configured source for one message.
|
|
47
|
+
|
|
48
|
+
### ndi-live
|
|
49
|
+
|
|
50
|
+
Serves the source as Motion-JPEG on Node-RED's HTTP node server (the same place as *http in* nodes):
|
|
51
|
+
|
|
52
|
+
- `<httpNodeRoot>/ndi-live/<node id>.mjpg`: the live picture; show it with `<img src="…">` in any browser or
|
|
53
|
+
dashboard template;
|
|
54
|
+
- `<httpNodeRoot>/ndi-live/<node id>.jpg`: one still.
|
|
55
|
+
|
|
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
|
|
60
|
+
behind live.
|
|
61
|
+
|
|
62
|
+
The connection to the source opens when the first viewer starts watching and closes a few seconds after the last
|
|
63
|
+
one stops, so an unwatched preview uses no network and no processor time. Each frame is encoded once for all
|
|
64
|
+
viewers, and a viewer on a slow connection skips frames instead of falling behind. Send `"stop"` to end every
|
|
65
|
+
viewer and refuse new ones, `"start"` to allow them again.
|
|
66
|
+
|
|
67
|
+
**Who can watch:** the endpoints sit behind Node-RED's `httpNodeAuth` if your settings set it. Without it, anyone
|
|
68
|
+
who can reach Node-RED's HTTP endpoints can watch.
|
|
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
|
+
|
|
78
|
+
### Both nodes
|
|
79
|
+
|
|
80
|
+
- **Network use.** *Proxy* (the default) asks the sender for its low-bandwidth stream. *Full* asks for the full
|
|
81
|
+
stream, which can be over 100 Mb/s per camera.
|
|
82
|
+
- **One connection per source.** Nodes that show the same source share one NDI connection to it.
|
|
83
|
+
- **Tally is never set**, so a preview never lights a camera's on-air lamp.
|
|
84
|
+
- **Safe for Node-RED.** All NDI work runs in a separate helper process. If the NDI Runtime crashes or hangs, the
|
|
85
|
+
helper is restarted (with a pause that grows each time), and Node-RED keeps running. After five crashes in a
|
|
86
|
+
minute it stops trying and the nodes say so, until Node-RED is restarted.
|
|
87
|
+
- **Where the runtime is found.** On Windows, the folder the NDI 6 Runtime installer records in
|
|
88
|
+
`NDI_RUNTIME_DIR_V6`; on macOS, `/usr/local/lib/libndi.dylib`. To use a runtime somewhere else, set
|
|
89
|
+
`NDI_PREVIEW_RUNTIME` to the full path of the library before starting Node-RED.
|
|
90
|
+
- **Windows firewall.** The helper asks the NDI Runtime to receive over TCP, which a firewall without an allow rule
|
|
91
|
+
for Node-RED does not block. Your own NDI settings (groups, discovery server, extra IPs) still apply. If you set
|
|
92
|
+
`NDI_CONFIG_DIR` yourself, your configuration is used unchanged.
|
|
93
|
+
|
|
94
|
+
## Building from source
|
|
95
|
+
|
|
96
|
+
The add-on is a small C file (`native/ndi_preview.c`) using Node-API. It loads the NDI Runtime the user installed
|
|
97
|
+
at run time and links no NDI library. Building it needs the NDI SDK's header files: install the NDI SDK from
|
|
98
|
+
<https://ndi.video/> (it is not part of this repository).
|
|
99
|
+
|
|
100
|
+
- macOS: `npm run build` (needs the Xcode Command Line Tools; set `NDI_SDK_DIR` if the SDK is not in
|
|
101
|
+
`/Library/NDI SDK for Apple`).
|
|
102
|
+
- Windows x64: `native/build-win.ps1` (needs the Visual Studio C++ build tools with the Windows SDK, and the NDI SDK
|
|
103
|
+
for Windows).
|
|
104
|
+
|
|
105
|
+
Tests: `npm test` runs the unit tests with a synthetic stand-in for NDI, so it needs no NDI and no network.
|
|
106
|
+
`npm run check` also checks the add-on and the package contents and, on macOS, runs an end-to-end test against a
|
|
107
|
+
local test source. That test runs every NDI process in a macOS sandbox that only allows loopback traffic.
|
|
108
|
+
|
|
109
|
+
## Licence
|
|
110
|
+
|
|
111
|
+
Copyright (c) 2026 James Breedon. Licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE.md): free for
|
|
112
|
+
noncommercial use.
|
|
113
|
+
|
|
114
|
+
Third-party code and terms are listed in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), including the terms that
|
|
115
|
+
apply to the NDI software this package works with.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## NDI®
|
|
4
|
+
|
|
5
|
+
NDI® is a registered trademark of Vizrt NDI AB. More about NDI: <https://ndi.video/>.
|
|
6
|
+
|
|
7
|
+
This package contains no NDI software. It works with the NDI Runtime that you download from <https://ndi.video/>
|
|
8
|
+
and install yourself, and its add-on was built using the NDI SDK's header files. The NDI Runtime and the NDI SDK are
|
|
9
|
+
licensed by Vizrt NDI AB under their own terms, which you accept when you install them. When you use this package
|
|
10
|
+
with NDI software, these terms also apply to you:
|
|
11
|
+
|
|
12
|
+
- You may not modify the NDI SDK, the NDI Runtime or any other NDI product, or any part of them.
|
|
13
|
+
- You may not reverse engineer, disassemble or recompile the NDI SDK, the NDI Runtime or any other NDI product, or
|
|
14
|
+
any protocol they use (whether sent or received over a network, or used inside a computer), and you may not try to.
|
|
15
|
+
- You may not circumvent any technical limitation in the NDI SDK, the NDI Runtime or any other NDI product.
|
|
16
|
+
- You may not remove, obscure or alter any proprietary notice or label in the NDI SDK, the NDI Runtime or any other
|
|
17
|
+
NDI product.
|
|
18
|
+
- Vizrt NDI AB and its licensors give no warranty of any kind for their software, and, as far as the law allows,
|
|
19
|
+
they are not liable for any damages (direct, indirect, incidental or consequential) arising from the use of this
|
|
20
|
+
package with their software.
|
|
21
|
+
- You must comply with all relevant export laws and regulations of the United States, so that NDI software is not
|
|
22
|
+
exported, directly or indirectly, in breach of United States law.
|
|
23
|
+
- The NDI SDK and NDI Runtime are copyright Vizrt NDI AB. All rights reserved.
|
|
24
|
+
- If you use this package to develop products of your own that use NDI, those products must comply with the NDI SDK
|
|
25
|
+
License Agreement, including its requirement of current and complete NDI compatibility, and their end-user licence
|
|
26
|
+
must include these terms.
|
|
27
|
+
|
|
28
|
+
The NDI SDK License Agreement: <https://ndi.link/ndisdk_license>.
|
|
29
|
+
|
|
30
|
+
Formats such as H.264, H.265 and AAC (used by some NDI|HX sources) may need licences of their own. The NDI Runtime
|
|
31
|
+
decodes them; making sure that use is licensed is your responsibility.
|
|
32
|
+
|
|
33
|
+
## jpeg-js encoder
|
|
34
|
+
|
|
35
|
+
`lib/vendor/jpeg-encoder.js` is the JPEG encoder from jpeg-js 0.4.4 (<https://github.com/jpeg-js/jpeg-js>),
|
|
36
|
+
unchanged. Provenance: `lib/vendor/PROVENANCE.md`.
|
|
37
|
+
|
|
38
|
+
jpeg-js licence (`lib/vendor/jpeg-js.LICENSE`):
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Copyright (c) 2014, Eugene Ware
|
|
42
|
+
All rights reserved.
|
|
43
|
+
|
|
44
|
+
Redistribution and use in source and binary forms, with or without
|
|
45
|
+
modification, are permitted provided that the following conditions are met:
|
|
46
|
+
|
|
47
|
+
1. Redistributions of source code must retain the above copyright
|
|
48
|
+
notice, this list of conditions and the following disclaimer.
|
|
49
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
50
|
+
notice, this list of conditions and the following disclaimer in the
|
|
51
|
+
documentation and/or other materials provided with the distribution.
|
|
52
|
+
3. Neither the name of Eugene Ware nor the names of its contributors
|
|
53
|
+
may be used to endorse or promote products derived from this software
|
|
54
|
+
without specific prior written permission.
|
|
55
|
+
|
|
56
|
+
THIS SOFTWARE IS PROVIDED BY EUGENE WARE ''AS IS'' AND ANY
|
|
57
|
+
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
58
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
59
|
+
DISCLAIMED. IN NO EVENT SHALL EUGENE WARE BE LIABLE FOR ANY
|
|
60
|
+
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
61
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
62
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
63
|
+
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
64
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
65
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The encoder is itself a port of Adobe's ActionScript 3 JPEG encoder. Its licence, from the header of
|
|
69
|
+
`lib/vendor/jpeg-encoder.js`:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Copyright (c) 2008, Adobe Systems Incorporated
|
|
73
|
+
All rights reserved.
|
|
74
|
+
|
|
75
|
+
Redistribution and use in source and binary forms, with or without
|
|
76
|
+
modification, are permitted provided that the following conditions are
|
|
77
|
+
met:
|
|
78
|
+
|
|
79
|
+
* Redistributions of source code must retain the above copyright notice,
|
|
80
|
+
this list of conditions and the following disclaimer.
|
|
81
|
+
|
|
82
|
+
* Redistributions in binary form must reproduce the above copyright
|
|
83
|
+
notice, this list of conditions and the following disclaimer in the
|
|
84
|
+
documentation and/or other materials provided with the distribution.
|
|
85
|
+
|
|
86
|
+
* Neither the name of Adobe Systems Incorporated nor the names of its
|
|
87
|
+
contributors may be used to endorse or promote products derived from
|
|
88
|
+
this software without specific prior written permission.
|
|
89
|
+
|
|
90
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
|
91
|
+
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
|
|
92
|
+
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
93
|
+
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
94
|
+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
95
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
96
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
97
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
98
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
99
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
100
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Node.js
|
|
104
|
+
|
|
105
|
+
The add-on is compiled against Node.js's Node-API header files (MIT licence, Copyright Node.js contributors). No
|
|
106
|
+
Node.js code is included in this package.
|
|
@@ -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/lib/addon.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Where the prebuilt add-on for this computer lives, and loading it. The add-on is only ever loaded inside the NDI
|
|
3
|
+
// host process (lib/host.js), never inside Node-RED itself.
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
|
|
7
|
+
const PREBUILDS = path.join(__dirname, "..", "prebuilds");
|
|
8
|
+
|
|
9
|
+
function platformDir(platform = process.platform, arch = process.arch) {
|
|
10
|
+
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) return "darwin-universal";
|
|
11
|
+
if (platform === "win32" && arch === "x64") return "win32-x64";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// -> absolute path of the add-on for this computer, or null when there is none
|
|
16
|
+
function addonPath() {
|
|
17
|
+
const dir = platformDir();
|
|
18
|
+
if (!dir) return null;
|
|
19
|
+
const p = path.join(PREBUILDS, dir, "ndi_preview.node");
|
|
20
|
+
return fs.existsSync(p) ? p : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -> null when this computer can run the nodes, else a sentence saying why not
|
|
24
|
+
function unsupportedReason() {
|
|
25
|
+
if (process.env.NDI_PREVIEW_TEST_ADDON) return null;
|
|
26
|
+
if (!platformDir()) {
|
|
27
|
+
return `NDI preview is not available on ${process.platform}-${process.arch}: it runs on macOS and on Windows x64`;
|
|
28
|
+
}
|
|
29
|
+
if (!addonPath()) return `the NDI preview add-on for ${platformDir()} is missing from this installation`;
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// NDI_PREVIEW_TEST_ADDON is for this package's own tests: a JavaScript stand-in with the add-on's API that makes
|
|
34
|
+
// synthetic frames and uses no network.
|
|
35
|
+
function loadAddon() {
|
|
36
|
+
const fake = process.env.NDI_PREVIEW_TEST_ADDON;
|
|
37
|
+
if (fake) return require(path.resolve(fake));
|
|
38
|
+
const p = addonPath();
|
|
39
|
+
if (!p) throw new Error(unsupportedReason() || "the NDI preview add-on is missing");
|
|
40
|
+
return require(p);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { addonPath, platformDir, unsupportedReason, loadAddon };
|
package/lib/admin.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What both node types share in Node-RED: the editor's admin endpoints and the status texts.
|
|
3
|
+
const { getClient } = require("./host-client");
|
|
4
|
+
|
|
5
|
+
const NOT_INSTALLED = { fill: "grey", shape: "ring", text: "NDI Runtime not installed (see ndi.video)" };
|
|
6
|
+
const CHECKING = { fill: "grey", shape: "ring", text: "checking for the NDI Runtime" };
|
|
7
|
+
|
|
8
|
+
function statusForRuntime(rt) {
|
|
9
|
+
if (rt.ok) return null;
|
|
10
|
+
if (rt.unsupported) return { fill: "grey", shape: "ring", text: "not available on this computer" };
|
|
11
|
+
if (rt.failed) return { fill: "red", shape: "ring", text: "NDI helper stopped; restart Node-RED" };
|
|
12
|
+
if (rt.host) return { fill: "red", shape: "ring", text: "NDI helper not answering; retrying" };
|
|
13
|
+
return NOT_INSTALLED;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let registered = false;
|
|
17
|
+
let logged = false;
|
|
18
|
+
|
|
19
|
+
// The runtime check a node does when it starts. The NDI host is started by the first node (or the editor), not
|
|
20
|
+
// when Node-RED loads the palette, so a Node-RED with no NDI nodes deployed runs no extra process.
|
|
21
|
+
// The result is logged once.
|
|
22
|
+
function checkRuntime(RED) {
|
|
23
|
+
return getClient(RED).runtime().then((rt) => {
|
|
24
|
+
if (!logged && !rt.host) {
|
|
25
|
+
logged = true;
|
|
26
|
+
if (rt.ok) RED.log.info(`[ndi-preview] NDI Runtime ${rt.version} (${rt.path})`);
|
|
27
|
+
else RED.log.warn(`[ndi-preview] ${rt.error}.${rt.help ? " " + rt.help + "." : ""} The NDI preview nodes stay idle.`);
|
|
28
|
+
}
|
|
29
|
+
return rt;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The runtime check for one node: calls onResult with each answer. A problem with the NDI host itself (it did not
|
|
34
|
+
// answer, or crashed once) is not final: the check is repeated every 10 s until there is a real answer or the node
|
|
35
|
+
// closes. A missing runtime or a host that has given up is final.
|
|
36
|
+
function watchRuntime(RED, isClosed, onResult) {
|
|
37
|
+
const attempt = () => {
|
|
38
|
+
if (isClosed()) return;
|
|
39
|
+
checkRuntime(RED).then((rt) => {
|
|
40
|
+
if (isClosed()) return;
|
|
41
|
+
onResult(rt);
|
|
42
|
+
if (!rt.ok && rt.host && !rt.failed) { const t = setTimeout(attempt, 10000); t.unref?.(); }
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
attempt();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Registered once, whichever node type loads first.
|
|
49
|
+
function registerAdmin(RED) {
|
|
50
|
+
if (registered) return;
|
|
51
|
+
registered = true;
|
|
52
|
+
|
|
53
|
+
// Is the NDI Runtime installed on the Node-RED computer? (the editor's banner)
|
|
54
|
+
RED.httpAdmin.get("/ndi-preview/runtime", RED.auth.needsPermission("ndi-preview.read"), async (req, res) => {
|
|
55
|
+
const rt = await getClient(RED).runtime({ refresh: req.query.refresh === "1" });
|
|
56
|
+
res.json({ ok: rt.ok, version: rt.version, error: rt.error, help: rt.help, unsupported: !!rt.unsupported });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// One short NDI discovery from the Node-RED computer (the editor's "Find sources" button).
|
|
60
|
+
RED.httpAdmin.get("/ndi-preview/sources", RED.auth.needsPermission("ndi-preview.read"), async (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
const client = getClient(RED);
|
|
63
|
+
const rt = await client.runtime();
|
|
64
|
+
if (!rt.ok) { res.status(503).json({ error: rt.error, help: rt.help }); return; }
|
|
65
|
+
res.json(await client.sources(2000));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
res.status(500).json({ error: e.message });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { registerAdmin, checkRuntime, watchRuntime, statusForRuntime, NOT_INSTALLED, CHECKING };
|
package/lib/frame.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure-JS frame helpers: any NDI video frame -> RGBA at thumbnail size -> JPEG Buffer.
|
|
3
|
+
// No native code here, so this file is unit-testable without the NDI runtime.
|
|
4
|
+
const encodeJpeg = require("./vendor/jpeg-encoder"); // vendored jpeg-js 0.4.4 encoder (lib/vendor/PROVENANCE.md)
|
|
5
|
+
|
|
6
|
+
const FOURCC = { UYVY: 1498831189, BGRA: 1095911234, BGRX: 1481787202, RGBA: 1094862674, RGBX: 1480738642 };
|
|
7
|
+
const fourccName = (v) => Object.keys(FOURCC).find((k) => FOURCC[k] === v) || String(v);
|
|
8
|
+
|
|
9
|
+
// Box-filter (area average) downscale of a packed 4-byte-per-pixel frame straight into RGBA.
|
|
10
|
+
// Handles RGBX/RGBA and BGRX/BGRA (channel swap) and UYVY (4:2:2 -> RGB, BT.709 limited range).
|
|
11
|
+
function toThumbRGBA(frame, outW) {
|
|
12
|
+
const { xres: W, yres: H, lineStrideBytes: stride, data, fourCC } = frame;
|
|
13
|
+
const w = Math.max(16, Math.min(outW | 0 || 320, W));
|
|
14
|
+
const h = Math.max(9, Math.round((H * w) / W));
|
|
15
|
+
const out = Buffer.alloc(w * h * 4);
|
|
16
|
+
const bgr = fourCC === FOURCC.BGRX || fourCC === FOURCC.BGRA;
|
|
17
|
+
const uyvy = fourCC === FOURCC.UYVY;
|
|
18
|
+
if (!uyvy && !bgr && fourCC !== FOURCC.RGBX && fourCC !== FOURCC.RGBA) {
|
|
19
|
+
throw new Error(`unsupported pixel format ${fourccName(fourCC)} (ask for colorFormat RGBX_RGBA)`);
|
|
20
|
+
}
|
|
21
|
+
const fx = W / w, fy = H / h;
|
|
22
|
+
// sample a capped grid inside each output cell (≤4×4) — cheap and still smooth for 1080p -> 320
|
|
23
|
+
const sx = Math.max(1, Math.min(4, Math.floor(fx))), sy = Math.max(1, Math.min(4, Math.floor(fy)));
|
|
24
|
+
for (let oy = 0; oy < h; oy++) {
|
|
25
|
+
for (let ox = 0; ox < w; ox++) {
|
|
26
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
27
|
+
for (let j = 0; j < sy; j++) {
|
|
28
|
+
const y = Math.min(H - 1, Math.floor(oy * fy + ((j + 0.5) * fy) / sy));
|
|
29
|
+
const row = y * stride;
|
|
30
|
+
for (let i = 0; i < sx; i++) {
|
|
31
|
+
const x = Math.min(W - 1, Math.floor(ox * fx + ((i + 0.5) * fx) / sx));
|
|
32
|
+
if (uyvy) {
|
|
33
|
+
const p = row + (x >> 1) * 4; // U Y0 V Y1
|
|
34
|
+
const Y = (x & 1 ? data[p + 3] : data[p + 1]) - 16, U = data[p] - 128, V = data[p + 2] - 128;
|
|
35
|
+
r += 1.164 * Y + 1.793 * V; g += 1.164 * Y - 0.213 * U - 0.533 * V; b += 1.164 * Y + 2.112 * U;
|
|
36
|
+
} else {
|
|
37
|
+
const p = row + x * 4;
|
|
38
|
+
if (bgr) { b += data[p]; g += data[p + 1]; r += data[p + 2]; }
|
|
39
|
+
else { r += data[p]; g += data[p + 1]; b += data[p + 2]; }
|
|
40
|
+
}
|
|
41
|
+
n++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const o = (oy * w + ox) * 4;
|
|
45
|
+
out[o] = clamp(r / n); out[o + 1] = clamp(g / n); out[o + 2] = clamp(b / n); out[o + 3] = 255;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { data: out, width: w, height: h };
|
|
49
|
+
}
|
|
50
|
+
const clamp = (v) => (v < 0 ? 0 : v > 255 ? 255 : v | 0);
|
|
51
|
+
|
|
52
|
+
function frameToJpeg(frame, outW, quality) {
|
|
53
|
+
const rgba = toThumbRGBA(frame, outW);
|
|
54
|
+
const q = Math.max(10, Math.min(95, quality | 0 || 70));
|
|
55
|
+
return { jpeg: encodeJpeg(rgba, q).data, width: rgba.width, height: rgba.height };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { toThumbRGBA, frameToJpeg, fourccName, FOURCC };
|