Plugin Demo: NDI (Network Device Interface)
48 |
49 |
50 |
51 |
52 |
53 |
54 |
Demo details
55 |
This is a simple demo to showcase how you can locally turn a WebRTC stream to an NDI feed o
56 | the same network where Janus is running.
57 |
To test the demo, just choose a name for the NDI feed, and that will originate a WebRTC
58 | PeerConnection to the NDI plugin in Janus, where the audio and video feeds will be decoded and
59 | sent in an NDI feed with the provided name. Tally information will be displayed as well, if
60 | notified via NDI, as they'll be relayed as events from the plugin. Closing or refreshing the
61 | page will result in the session with Janus being closed, which will get rid of the NDI resources
62 | associated with this session too.
63 |
Check the JavaScript code of the demo for more options you can add to the translate
64 | request, besides the NDI name, and check the documentation in the repo for how to use additional
65 | features (e.g., pre-creating NDI names with placeholder images, or sending test NDI patterns).
66 |
Press the Start button above to launch the demo.
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 | Settings
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | Local Stream
89 | Program
90 | Preview
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
105 |
106 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Janus NDI Plugin
2 | ================
3 |
4 | This is an implementation of a Janus NDI plugin, developed by [Meetecho](http://www.meetecho.com). Its main purpose is receiving streams via WebRTC, and translating them to NDI senders locally. It's the open source version of a plugin that, at the time, was originally used by [Broadcast Bridge](https://broadcastbridge.app/) to help with the recording of [CommCon Virtual 2021](https://2021.commcon.xyz/) remote presentations.
5 |
6 | A [Janode](https://github.com/meetecho/janode/) module is available as well, to control the plugin programmatically via Node.js/JanaScript.
7 |
8 | The plugin supports:
9 |
10 | * NDI sender with a test pattern and a static name
11 | * Creating one-shot or reusable NDI senders for WebRTC users
12 | * Placeholder images for reusable NDI senders (e.g., when no PeerConnection is feeding them)
13 | * Closing images for one-shot NDI senders (e.g., when the PeerConnection goes away)
14 | * Decode Opus and VP8/VP9/H.264/AV1 (depending on FFmpeg installation) to raw NDI
15 | * Resizing video after decode (with or without keeping the aspect ratio)
16 | * Stereo audio
17 | * Tally events
18 |
19 | At the time of writing, the plugin does _NOT_ support:
20 |
21 | * Watermarking (partially supported in a private development branch)
22 | * NDI-HX
23 | * Advanced NDI 5 and 6 features (this plugin was implemented when only NDI 4 was available)
24 |
25 | To learn more about the plugin, you can refer to [this blog post](https://www.meetecho.com/blog/webrtc-ndi/) and [this other blog post](https://www.meetecho.com/blog/webrtc-ndi-part-2/), which explain more in detail how it should be used within the context of Janus-based WebRTC conversations.
26 |
27 | ## Dependencies
28 |
29 | The main dependencies are Janus, of course, and the NDI SDK, which needs to be installed as follows:
30 |
31 | * headers in /usr/include/NDI
32 | * shared objects to /usr/lib (or /usr/lib64, if it's a 64-bit installation)
33 |
34 | To install the plugin itself, you'll also need to satisfy the following dev dependencies:
35 |
36 | * [GLib](http://library.gnome.org/devel/glib/)
37 | * [Jansson](http://www.digip.org/jansson/)
38 | * [libcurl](https://curl.haxx.se/libcurl/)
39 | * [ffmpeg-dev](http://ffmpeg.org/)
40 | * [libopus](http://opus-codec.org/)
41 |
42 | Support for decoding Opus, VP8, VP9, H.264 and AV1 should be available in the FFmpeg installation, or attempting to decode those codecs will fail.
43 |
44 | ## Compiling
45 |
46 | Set the `JANUSP` env variable to configure where Janus is installed, and then issue `make` to compile the plugin, e.g.:
47 |
48 | JANUSP=/opt/janus make
49 |
50 | The plugin will automatically detect whether it's building against Janus `1.x` or `0.x`. Notice that, even when building for `1.x`, this plugin doesn't support multistream: this means that, at the time of writing, each PeerConnection can only be associated with one NDI sender, and each NDI sender can only contain one audio and/or video feed.
51 |
52 | ## Installing
53 |
54 | Set the `JANUSP` env variable to configure where Janus is installed, and then issue `make install` to install the plugin, e.g.:
55 |
56 | JANUSP=/opt/janus make install
57 |
58 | This will also install a template configuration file for the plugin (currently limited to a couple of settings).
59 |
60 | ## Testing
61 |
62 | To test the plugin in a local setup, you can use the `ndi.html` web page in the `demo` folder. It will work as any other Janus demo, so please refer to the [related instructions](https://janus.conf.meetecho.com/docs/deploy) in the Janus documentation for info on how to deploy this. You can also use the `start_test_pattern` request to test the plugin without the need to establish a PeerConnection: check the `API` section below for more information.
63 |
64 | When using the demo, opening the web page will prompt you for a display name. Once you do that, a `sendonly` PeerConnection will be established with the plugin, and the plugin will create an NDI sender with that display name for you locally. This NDI feed should then become visible to NDI compatible applications (e.g., OBS if you have the NDI plugin installed). Tally events will be displayed in the web page when the related NDI feed is consumed.
65 |
66 | Notice that this is just a local demo to showcase the plugin from a functional perspective. In regular scenarios, the Janus instance serving users will not be in the same network as the applications dealing with NDI feeds. Please refer to the blog posts mentioned at the beginning of this page for more information on the type of orchestration you'll need to perform.
67 |
68 | A [Janode](https://github.com/meetecho/janode/) module is also available as well, to control the plugin programmatically via Node.js/JanaScript. No example is available as of yet, but if you're familiar with Janode it should be trivial to use. You can learn more [here](janode/README.md).
69 |
70 | # API
71 |
72 | The `translate` request must be used to setup the PeerConnection and associate it with an NDI source: it expects a `name` property to be used by the NDI sender; optional arguments are `bitrate` (to send a bitrate cap via REMB) and `width`/`height` (to force scaling to a static resolution; if missing, the original resolution in the WebRTC stream is used). The following code comes from the sample demo page:
73 |
74 | ndi.createOffer(
75 | {
76 | media: { audio: true, video: true },
77 | success: function(jsep) {
78 | Janus.debug("Got SDP!", jsep);
79 | // Send a request to the plugin
80 | var translate = {
81 | request: "translate",
82 | name: "my-test"
83 | }
84 | ndi.send({ message: translate, jsep: jsep });
85 | },
86 | error: function(error) {
87 | Janus.error("WebRTC error:", error);
88 | bootbox.alert("WebRTC error... " + error.message);
89 | }
90 | });
91 |
92 | This will create a new NDI source named `my-test` available with the provided audio/video streams. The `hangup` request can be used to tear down the PeerConnection instead: for one-shot NDI senders, this will release the NDI sender as well. A `configure` request can be used to try and tweak a WebRTC stream: `bitrate` will send a bitrate cap via REMB, `keyframe: true` will trigger a PLI. Notice that REMB will be ignored if the NDI plugin is receiving a WebRTC stream from another Janus instance, rather than a browser.
93 |
94 | A test pattern can be sent via NDI by using a `start_test_pattern` request, and stopped via `stop_test_pattern`. The test pattern is a static image sent at 30fps via NDI, and so can be used to verify whether or not recipients can obtain NDI streams originated by the plugin. Only a single test pattern can be started at a time, since it has a hardcoded `janus-ndi-test` name. Both `start_test_pattern` and `stop_test_pattern` are synchronous requests, and can be invoked via Admin API as well, which means they can be triggered by, e.g., curl one-liners:
95 |
96 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "start_test_pattern" } }' http://localhost:7088/admin
97 |
98 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "stop_test_pattern" } }' http://localhost:7088/admin
99 |
100 | For a more comprehensive documentation, including info on how to pre-create senders and have placeholder images be displayed when a WebRTC connection is not feeding them, please refer to the [API](docs/API.md).
101 |
--------------------------------------------------------------------------------
/janode/src/ndi.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import Handle from 'janode/handle';
4 |
5 | /* The plugin ID exported in the plugin descriptor */
6 | const PLUGIN_ID = 'janus.plugin.ndi';
7 |
8 | /* These are the requests defined for the Janus NDI plugin API */
9 | const REQUEST_CREATE = 'create';
10 | const REQUEST_UPDATE_IMG = 'update_img';
11 | const REQUEST_LIST = 'list';
12 | const REQUEST_DESTROY = 'destroy';
13 | const REQUEST_TRANSLATE = 'translate';
14 | const REQUEST_CONFIGURE = 'configure';
15 | const REQUEST_HANGUP = 'hangup';
16 | const REQUEST_START_TEST_PATTERN = 'start_test_pattern';
17 | const REQUEST_STOP_TEST_PATTERN = 'stop_test_pattern';
18 |
19 | /* These are the events/responses that the Janode plugin will manage */
20 | /* Some of them will be exported in the plugin descriptor */
21 | const PLUGIN_EVENT = {
22 | LIST: 'ndi_list',
23 | TRANSLATING: 'ndi_translating',
24 | CONFIGURED: 'ndi_configured',
25 | TALLY: 'ndi_tally',
26 | HANGINGUP: 'ndi_hangingup',
27 | SUCCESS: 'ndi_success',
28 | ERROR: 'ndi_error',
29 | };
30 |
31 | /* The class implementing the Janus NDI plugin (https://github.com/meetecho/janus-ndi/blob/main/docs/API.md) */
32 | class JanusNdiHandle extends Handle {
33 | /* Constructor */
34 | constructor(session, id) {
35 | super(session, id);
36 |
37 | /* NDI sender associated to this handle, when active */
38 | this.name = null;
39 | }
40 |
41 | /* The custom "handleMessage" needed for handling Janus NDI plugin messages */
42 | handleMessage(janus_message) {
43 | const { plugindata, transaction } = janus_message;
44 | if(plugindata && plugindata.data && plugindata.data.ndi) {
45 | const message_data = plugindata.data;
46 | const { ndi, error, error_code, name } = message_data;
47 |
48 | /* Prepare an object for the output Janode event */
49 | const janode_event = this._newPluginEvent(janus_message);
50 |
51 | /* Add the NDI sender name, if available */
52 | if(name)
53 | janode_event.data.name = name;
54 |
55 | /* The plugin will emit an event only if the handle does not own the transaction */
56 | /* That means that a transaction has already been closed or this is an async event */
57 | const emit = (this.ownsTransaction(transaction) === false);
58 |
59 | switch(ndi) {
60 | /* Success response */
61 | case 'success':
62 | /* Senders list API */
63 | if(typeof message_data.list !== 'undefined') {
64 | janode_event.data.list = message_data.list;
65 | janode_event.event = PLUGIN_EVENT.LIST;
66 | break;
67 | }
68 | /* In this case the "ndi" field of the Janode event is "success" */
69 | janode_event.event = PLUGIN_EVENT.SUCCESS;
70 | break;
71 |
72 | /* Error response */
73 | case 'error':
74 | /* Janus NDI plugin error */
75 | janode_event.event = PLUGIN_EVENT.ERROR;
76 | janode_event.data = new Error(`${error_code} ${error}`);
77 | /* In case of error, close a transaction */
78 | this.closeTransactionWithError(transaction, janode_event.data);
79 | break;
80 |
81 | /* Generic event (including asynchronous errors) */
82 | case 'event':
83 | /* Janus NDI plugin error */
84 | if(error) {
85 | janode_event.event = PLUGIN_EVENT.ERROR;
86 | janode_event.data = new Error(`${error_code} ${error}`);
87 | /* In case of error, close a transaction */
88 | this.closeTransactionWithError(transaction, janode_event.data);
89 | break;
90 | }
91 | /* Asynchronous success for this handle */
92 | if(typeof message_data.result !== 'undefined') {
93 | const { event } = message_data.result;
94 | switch(event) {
95 | /* NDI sender translation started */
96 | case 'translating':
97 | janode_event.event = PLUGIN_EVENT.TRANSLATING;
98 | janode_event.data.name = name;
99 | break;
100 |
101 | /* WebRTC PeerConnection configured */
102 | case 'configured':
103 | janode_event.event = PLUGIN_EVENT.CONFIGURED;
104 | janode_event.data.name = name;
105 | break;
106 |
107 | /* NDI tally information available */
108 | case 'tally':
109 | janode_event.event = PLUGIN_EVENT.TALLY;
110 | janode_event.data.name = message_data.result.name;
111 | janode_event.data.preview = message_data.result.preview;
112 | janode_event.data.program = message_data.result.program;
113 | break;
114 |
115 | }
116 | }
117 | break;
118 | }
119 |
120 | /* The event has been handled */
121 | if(janode_event.event) {
122 | /* Try to close the transaction */
123 | this.closeTransactionWithSuccess(transaction, janus_message);
124 | /* If the transaction was not owned, emit the event */
125 | if(emit)
126 | this.emit(janode_event.event, janode_event.data);
127 | return janode_event;
128 | }
129 | }
130 |
131 | /* The event has not been handled, return a falsy value */
132 | return null;
133 | }
134 |
135 | /*
136 | *
137 | * These are the APIs that users need to work with the Janus NDI plugin
138 | *
139 | */
140 |
141 | /* Pre-create a reusable NDI sender */
142 | async create({ name, placeholder, width, height, keep_ratio }) {
143 | const body = {
144 | request: REQUEST_CREATE,
145 | name
146 | };
147 | if(typeof placeholder === 'string')
148 | body.placeholder = placeholder;
149 | if(typeof width === 'number')
150 | body.width = width;
151 | if(typeof height === 'number')
152 | body.height = height;
153 | if(typeof keep_ratio === 'boolean')
154 | body.keep_ratio = keep_ratio;
155 |
156 | const response = await this.message(body);
157 | const { event, data: evtdata } = this._getPluginEvent(response);
158 | if(event === PLUGIN_EVENT.SUCCESS)
159 | return evtdata;
160 | const error = new Error(`unexpected response to ${body.request} request`);
161 | throw(error);
162 | }
163 |
164 | /* Update the placeholder image for an existing NDI sender */
165 | async updateImg({ name, image, width, height, keep_ratio }) {
166 | const body = {
167 | request: REQUEST_UPDATE_IMG,
168 | name,
169 | image
170 | };
171 | if(typeof width === 'number')
172 | body.width = width;
173 | if(typeof height === 'number')
174 | body.height = height;
175 | if(typeof keep_ratio === 'boolean')
176 | body.keep_ratio = keep_ratio;
177 |
178 | const response = await this.message(body);
179 | const { event, data: evtdata } = this._getPluginEvent(response);
180 | if(event === PLUGIN_EVENT.SUCCESS)
181 | return evtdata;
182 | const error = new Error(`unexpected response to ${body.request} request`);
183 | throw(error);
184 | }
185 |
186 | /* List available NDI senders */
187 | async list() {
188 | const body = {
189 | request: REQUEST_LIST,
190 | };
191 |
192 | const response = await this.message(body);
193 | const { event, data: evtdata } = this._getPluginEvent(response);
194 | if(event === PLUGIN_EVENT.LIST)
195 | return evtdata;
196 | const error = new Error(`unexpected response to ${body.request} request`);
197 | throw(error);
198 | }
199 |
200 | /* Destroy a shared NDI sender */
201 | async destroy({ name }) {
202 | const body = {
203 | request: REQUEST_DESTROY,
204 | name,
205 | };
206 |
207 | const response = await this.message(body);
208 | const { event, data: evtdata } = this._getPluginEvent(response);
209 | if(event === PLUGIN_EVENT.SUCCESS)
210 | return evtdata;
211 | const error = new Error(`unexpected response to ${body.request} request`);
212 | throw(error);
213 | }
214 |
215 | /* Setup a new WebRTC PeerConnection to translate to NDI */
216 | async translate({ name, metadata, width, height, fps, strict, onDisconnect, videocodec, jsep = null }) {
217 | const body = {
218 | request: REQUEST_TRANSLATE,
219 | name,
220 | };
221 | if(typeof metadata === 'string')
222 | body.metadata = metadata;
223 | if(typeof width === 'number')
224 | body.width = width;
225 | if(typeof height === 'number')
226 | body.height = height;
227 | if(typeof fps === 'number')
228 | body.fps = fps;
229 | if(typeof strict === 'boolean')
230 | body.strict = strict;
231 | if(typeof onDisconnect === 'object' && onDisconnect)
232 | body.ondisconnect = onDisconnect;
233 | if(typeof videocodec === 'string')
234 | body.videocodec = videocodec;
235 |
236 | const response = await this.message(body, jsep);
237 | const { event, data: evtdata } = this._getPluginEvent(response);
238 | if(event === PLUGIN_EVENT.TRANSLATING)
239 | return evtdata;
240 | const error = new Error(`unexpected response to ${body.request} request`);
241 | throw(error);
242 | }
243 |
244 | /* Configure an established WebRTC PeerConnection */
245 | async configure({ keyframe, bitrate, paused }) {
246 | const body = {
247 | request: REQUEST_CONFIGURE,
248 | };
249 | if(typeof keyframe === 'boolean')
250 | body.keyframe = keyframe;
251 | if(typeof bitrate === 'number')
252 | body.bitrate = bitrate;
253 | if(typeof paused === 'boolean')
254 | body.paused = paused;
255 |
256 | const response = await this.message(body);
257 | const { event, data: evtdata } = this._getPluginEvent(response);
258 | if(event === PLUGIN_EVENT.CONFIGURED)
259 | return evtdata;
260 | const error = new Error(`unexpected response to ${body.request} request`);
261 | throw(error);
262 | }
263 |
264 | /* Hangup an NDI sender's WebRTC PeerConnection */
265 | async hangup() {
266 | const body = {
267 | request: REQUEST_HANGUP,
268 | };
269 |
270 | const response = await this.message(body);
271 | const { event, data: evtdata } = this._getPluginEvent(response);
272 | if(event === PLUGIN_EVENT.HANGINGUP)
273 | return evtdata;
274 | const error = new Error(`unexpected response to ${body.request} request`);
275 | throw(error);
276 | }
277 |
278 | /* Start the NDI test pattern */
279 | async startTestPattern() {
280 | const body = {
281 | request: REQUEST_START_TEST_PATTERN,
282 | };
283 |
284 | const response = await this.message(body);
285 | const { event, data: evtdata } = this._getPluginEvent(response);
286 | if(event === PLUGIN_EVENT.SUCCESS)
287 | return evtdata;
288 | const error = new Error(`unexpected response to ${body.request} request`);
289 | throw(error);
290 | }
291 |
292 | /* Stop the NDI test pattern */
293 | async stopTestPattern() {
294 | const body = {
295 | request: REQUEST_STOP_TEST_PATTERN,
296 | };
297 |
298 | const response = await this.message(body);
299 | const { event, data: evtdata } = this._getPluginEvent(response);
300 | if(event === PLUGIN_EVENT.SUCCESS)
301 | return evtdata;
302 | const error = new Error(`unexpected response to ${body.request} request`);
303 | throw(error);
304 | }
305 |
306 | }
307 |
308 | /* The exported plugin descriptor */
309 | export default {
310 | id: PLUGIN_ID,
311 | Handle: JanusNdiHandle,
312 |
313 | EVENT: {
314 | /* NDI tally information */
315 | JANUS_NDI_TALLY: PLUGIN_EVENT.TALLY,
316 |
317 | /* Generic Janus NDI plugin error */
318 | JANUS_NDI_ERROR: PLUGIN_EVENT.ERROR,
319 | },
320 | };
321 |
--------------------------------------------------------------------------------
/demo/ndi.js:
--------------------------------------------------------------------------------
1 | // We import the settings.js file to know which address we should contact
2 | // to talk to Janus, and optionally which STUN/TURN servers should be
3 | // used as well. Specifically, that file defines the "server" and
4 | // "iceServers" properties we'll pass when creating the Janus session.
5 |
6 | var janus = null;
7 | var ndi = null;
8 | var opaqueId = "ndi-"+Janus.randomString(12);
9 |
10 | var vcodec = (getQueryStringValue("vcodec") !== "" ? getQueryStringValue("vcodec") : null);
11 | var localTracks = {}, localVideos = 0;
12 |
13 | $(document).ready(function() {
14 | // Initialize the library (all console debuggers enabled)
15 | Janus.init({debug: "all", callback: function() {
16 | // Use a button to start the demo
17 | $('#start').one('click', function() {
18 | $(this).attr('disabled', true).unbind('click');
19 | // Make sure the browser supports WebRTC
20 | if(!Janus.isWebrtcSupported()) {
21 | bootbox.alert("No WebRTC support... ");
22 | return;
23 | }
24 | // Create session
25 | janus = new Janus(
26 | {
27 | server: server,
28 | success: function() {
29 | // Attach to NDI plugin
30 | janus.attach(
31 | {
32 | plugin: "janus.plugin.ndi",
33 | opaqueId: opaqueId,
34 | success: function(pluginHandle) {
35 | $('#details').remove();
36 | ndi = pluginHandle;
37 | Janus.log("Plugin attached! (" + ndi.getPlugin() + ", id=" + ndi.getId() + ")");
38 | // We're connected to the plugin, show the settings
39 | $('#videos').removeClass('hide');
40 | $('#name').removeAttr('disabled');
41 | $('#start').removeAttr('disabled').html("Stop")
42 | .click(function() {
43 | $(this).attr('disabled', true);
44 | janus.destroy();
45 | });
46 | },
47 | error: function(error) {
48 | console.error(" -- Error attaching plugin...", error);
49 | bootbox.alert("Error attaching plugin... " + error);
50 | },
51 | consentDialog: function(on) {
52 | Janus.debug("Consent dialog should be " + (on ? "on" : "off") + " now");
53 | if(on) {
54 | // Darken screen and show hint
55 | $.blockUI({
56 | message: '
');
158 | }
159 | }
160 | } else {
161 | // New video track: create a stream out of it
162 | localVideos++;
163 | $('#videoright .no-video-container').remove();
164 | stream = new MediaStream([track]);
165 | localTracks[trackId] = stream;
166 | Janus.log("Created local stream:", stream);
167 | Janus.log(stream.getTracks());
168 | Janus.log(stream.getVideoTracks());
169 | $('#videoright').prepend('');
170 | Janus.attachMediaStream($('#myvideo' + trackId).get(0), stream);
171 | }
172 | if(ndi.webrtcStuff.pc.iceConnectionState !== "completed" &&
173 | ndi.webrtcStuff.pc.iceConnectionState !== "connected") {
174 | $('#videoright').parent().parent().block({
175 | message: 'Publishing...',
176 | css: {
177 | border: 'none',
178 | backgroundColor: 'transparent',
179 | color: 'white'
180 | }
181 | });
182 | }
183 | },
184 | oncleanup: function() {
185 | Janus.log(" ::: Got a cleanup notification :::");
186 | $('#myvideo').remove();
187 | $('#waitingvideo').remove();
188 | $("#videoright").parent().parent().unblock();
189 | $('#name').removeAttr('disabled');
190 | }
191 | });
192 | },
193 | error: function(error) {
194 | Janus.error(error);
195 | bootbox.alert(error, function() {
196 | window.location.reload();
197 | });
198 | },
199 | destroyed: function() {
200 | window.location.reload();
201 | }
202 | });
203 | });
204 | }});
205 | });
206 |
207 | function checkEnter(event) {
208 | let theCode = event.keyCode ? event.keyCode : event.which ? event.which : event.charCode;
209 | if(theCode == 13) {
210 | publishMedia();
211 | return false;
212 | } else {
213 | return true;
214 | }
215 | }
216 |
217 | function publishMedia() {
218 | let ndiname = $('#name').val();
219 | if(!ndiname || ndiname === '')
220 | return;
221 | $('#name').attr('disabled', true);
222 | // Publish audio and video
223 | ndi.createOffer(
224 | {
225 | tracks: [
226 | { type: 'audio', capture: true, recv: false },
227 | { type: 'video', capture: true, recv: false }
228 | ],
229 | success: function(jsep) {
230 | Janus.debug("Got SDP!", jsep);
231 | // Send a request to the plugin
232 | let translate = {
233 | request: "translate",
234 | // If an NDI sender wasn't previously created with "create"
235 | // (which allows the NDI sender to survive tearing down
236 | // the PeerConnection) it will be created now here
237 | name: ndiname,
238 | // To force scaling to a specific resolution (e.g., to avoid
239 | // the NDI video becoming smaller because of a decreasing
240 | // resolution in the WebRTC stream), set width and height too
241 | //~ width: 640,
242 | //~ height: 480,
243 | // If you know the FPS of the video (or what you asked for),
244 | // you can tell the NDI plugin to better inform recipients
245 | //~ fps: 30,
246 | // You can provide optional NDI metadata as well
247 | //~ metadata: '',
248 | // To send a static image as the last frame before disconnecting,
249 | // you can specify the path to the image and the background color
250 | //~ ondisconnect: {
251 | //~ image: "https://www.meetecho.com/en/img/meetecho.png",
252 | //~ color: "#FFFFFF"
253 | //~ },
254 | // To force the decoder to drop a frame in case some packets
255 | // result missing (looking at sequence numbers), you need
256 | // to set the strict property to true (it's false by default)
257 | //~ strict: true
258 | }
259 | if(vcodec)
260 | translate["videocodec"] = vcodec;
261 | ndi.send({ message: translate, jsep: jsep });
262 | },
263 | error: function(error) {
264 | Janus.error("WebRTC error:", error);
265 | bootbox.alert("WebRTC error... " + error.message);
266 | }
267 | });
268 | }
269 |
270 | // Helper to parse query string
271 | function getQueryStringValue(name) {
272 | name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
273 | let regex = new RegExp("[\\?&]" + name + "=([^]*)"),
274 | results = regex.exec(location.search);
275 | return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
276 | }
277 |
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
1 | # Janus NDI Plugin API
2 |
3 | This document describes how to interact with the Janus NDI Plugin, using the Janus or Admin API. The first section will cover how the plugin works in general: a section containing info on the API itself will follow.
4 |
5 | ## Converting a WebRTC PeerConnection to an NDI source
6 |
7 | In order to generate an NDI source that other applications (e.g., OBS) can consume, the Janus NDI Plugin needs to have access to a live WebRTC stream it can decode and process accordingly: more precisely, the Janus NDI Plugin works under the assumption that there will always be a 1-1 relationship between a specific PeerConnection and the NDI source it will create on its behalf. From a negotiation perspective, the plugin always expects an offer, which means it's up to the application to provide info on the stream to "translate", and how to translate it. A simplified diagram is provided below:
8 |
9 | ```
10 | +-------------+ +-------+ +-----------+ +-----+
11 | | Application | | Janus | | NDIplugin | | OBS |
12 | +-------------+ +-------+ +-----------+ +-----+
13 | | | | |
14 | | message(details, SDP offer) | | |
15 | |-------------------------------->| | |
16 | | | ------------------------\ | |
17 | | |-| create PeerConnection | | |
18 | | | |-----------------------| | |
19 | | | | |
20 | | | message(details, SDP offer) | |
21 | | |---------------------------------->| |
22 | | | | -------------------------\ |
23 | | | |-| process asynchronously | |
24 | | | | |------------------------| |
25 | | | | |
26 | | | ack | |
27 | | |<----------------------------------| |
28 | | | | |
29 | | ack | | |
30 | |<--------------------------------| | |
31 | | | | --------------------------------------\ |
32 | | | |-| create answer, decoders, NDI sender | |
33 | | | | |-------------------------------------| |
34 | | | | |
35 | | | event(details, SDP answer) | |
36 | | |<----------------------------------| |
37 | | | | |
38 | | event(details, SDP answer) | | |
39 | |<--------------------------------| | |
40 | | | | |
41 | | SRTP (audio/video) | | |
42 | |-------------------------------->| | |
43 | | | | |
44 | | | RTP (audio/video) | |
45 | | |---------------------------------->| |
46 | | | | --------------------------------------\ |
47 | | | |-| asynchronously decode and translate | |
48 | | | | |-------------------------------------| |
49 | | | | |
50 | | | | NDI (audio/video) |
51 | | | |----------------------------------------->|
52 | | | | |
53 | ```
54 |
55 | Giving for granted that the application already created a Janus session and attached to the Janus NDI Plugin as usual, the process to create a new NDI source is pretty straightforward:
56 |
57 | 1. The application negotiates a new PeerConnection with the plugin, sending an SDP offer, and including some plugin-related details (e.g., NDI name to use).
58 | 2. The PeerConnection negotiation is handled as usual by Janus.
59 | 3. The plugin creates the resources it needs, including the audio/video decoders and the NDI new sender.
60 | 4. After an SDP answer is sent back asynchronously and a PeerConnection eventually created, audio and video start flowing via WebRTC.
61 | 5. The plugin decodes all audio and video packets, and "translates" them to NDI.
62 | 6. Applications interested in the stream (e.g., OBS) can subscribe to the NDI feed and receive it.
63 |
64 | Considering the Janus NDI Plugin will be located in a private network (the same as the one the NDI consumers will be in), most of the times there will not be a direct ingestion by browsers to the plugin. A common scenario will be the Janus NDI Plugin actually receiving SDP offers from other servers (e.g., a Janus instance in the cloud implementing a videoconferencing application). How to orchestrate the communication is out of scope to this document: the only relevant piece of information is that the Janus NDI Plugin expects an offer, and so it will be up to the controlling application to make sure this happens as expected (e.g., by triggering a subscription on a remote VideoRoom instance to get an offer to use, and passing the answer generated by the NDI plugin back to the remote VideoRoom).
65 |
66 | Once an WebRTC-to-NDI session has been established, there will be a few things that can be done to tweak the behaviour dynamically, as explained in the API section. Tally information will also be notified via dedicated events.
67 |
68 | ## API
69 |
70 | As most existing Janus plugin, the NDI plugin uses the `request` attribute to identify the specific request to perform in its custom API. The Janus NDI Plugin supports a few different requests:
71 |
72 | * `create`: create a new NDI sender, with a placeholder image (optional);
73 | * `update_img`: change the placeholder image to use for a shared NDI sender;
74 | * `list`: list the existing shared NDI senders;
75 | * `destroy`: destroy a shared NDI sender;
76 | * `translate`: create a new WebRTC-to-NDI session (possibly referring to an existing NDI sender);
77 | * `configure`: perform a tweak on an existing WebRTC-to-NDI session;
78 | * `hangup`: tear down an existing WebRTC-to-NDI session;
79 | * `start_test_pattern`: send a preconfigured static video test pattern via NDI (useful for testing purposes);
80 | * `stop_test_pattern`: stop the static test pattern.
81 |
82 | At the time of writing, only a single event is available instead:
83 |
84 | * `tally`: provide live updates on tally information.
85 |
86 | The following subsections will provide more details on all of the above.
87 |
88 | ### create
89 |
90 | While an NDI sender can be created on the fly with `translate`, as we'll see later, in some cases it may be helpful to pre-create an NDI sender independently of whether or not there's a WebRTC PeerConnection to translate. This may come in handy when you want an NDI stream to be always available as a placeholder image (e.g., to fill slots in a produced layout), and then dynamically feed it with a specific stream later on. This is what `create` allows you to do.
91 |
92 | The only mandatory argument in the `create` request is `name`, which specifies which name the NDI sender will need to use: this is how NDI consumers will identify the streams when listing available sources. You can also specify an `placeholder` to use as a placeholder (as a `file://` or `http://`/`https://` url): if you don't specify an `image`, the default test pattern will be used instead. The optional `width` and `height` attributes can be used to force the placeholder image to be resized to a specific resolution, and `keep_ratio` dictates whether aspect ratio should be preserved when resizing: when preserving the aspect ratio, horizontal or vertical black stripes may be added to the image to fit the target resolution.
93 |
94 | The format of the `create` request is the following:
95 |
96 | {
97 | "request": "create",
98 | "name": "",
99 | "placeholder": "",
100 | "width": ,
101 | "height": ,
102 | "keep_ratio":
103 | }
104 |
105 | It's a synchronous request, which means it can also be triggered via Admin API, which makes it easy to "fire" via, e.g., a curl one-liner:
106 |
107 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "create", "name": "lorenzo" } }' http://localhost:7088/admin
108 |
109 | A successful processing of the request will look like this:
110 |
111 | {
112 | "ndi": "success"
113 | }
114 |
115 | ### update_img
116 |
117 | An NDI sender created with `create` will send a specific image any time a PeerConnection is not actively feeding it with live video. Normally, an image is only provided when `create` is called, but in case the image needs to be changed dynamically (e.g., to re-use the same NDI session for different people, or to provide context-specific images), then `update_img` can be used for the purpose.
118 |
119 | The only mandatory arguments in the `update_ing` request are `name`, which specifies the NDI sender to update, and `image`, to provide the new image to use as a placeholder (as a `file://` or `http://`/`https://` url). In case updating the image fails, the previous one will remain active in the sender. As in `create`, the `width`, `height` and `keep_ratio` attributes can be provided as well.
120 |
121 | The format of the `update_img` request is the following:
122 |
123 | {
124 | "request": "update_img",
125 | "name": "",
126 | "image": "",
127 | "width": ,
128 | "height": ,
129 | "keep_ratio":
130 | }
131 |
132 | It's a synchronous request, which means it can also be triggered via Admin API, which makes it easy to "fire" via, e.g., a curl one-liner:
133 |
134 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "update_img", "name": "lorenzo", "placeholder": "file:///home/lminiero/Downloads/lminiero-square.png" } }' http://localhost:7088/admin
135 |
136 | A successful processing of the request will look like this:
137 |
138 | {
139 | "ndi": "success"
140 | }
141 |
142 | ### list
143 |
144 | You can list the existing shared NDI senders with the `list` request.
145 |
146 | The format of the `list` request is the following:
147 |
148 | {
149 | "request": "list"
150 | }
151 |
152 | It's a synchronous request, which means it can also be triggered via Admin API, which makes it easy to "fire" via, e.g., a curl one-liner:
153 |
154 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "list" } }' http://localhost:7088/admin
155 |
156 | A successful processing of the request will look like this:
157 |
158 | {
159 | "ndi": "success",
160 | "list": [
161 | {
162 | "name": "",
163 | "busy": ,
164 | "placeholder": ,
165 | "last_updated":
166 | },
167 | ... other senders ...
168 | ]
169 | }
170 |
171 | ### destroy
172 |
173 | An NDI sender created with `create` survives PeerConnections being closed. This means that an ad-hoc request is needed to get rid of it, when it's no longer needed, which is what `destroy` is for. Notice that an NDI sender can only be destroyed if it's not actually in use: if a WebRTC PeerConnection is currently feeding it, `destroy` will return an error: you'll need the PeerConnection to be closed first.
174 |
175 | The only mandatory argument in the `create` request is `name`, which specifies the name of the NDI sender to destroy.
176 |
177 | The format of the `destroy` request is the following:
178 |
179 | {
180 | "request": "destroy",
181 | "name": ""
182 | }
183 |
184 | It's a synchronous request, which means it can also be triggered via Admin API, which makes it easy to "fire" via, e.g., a curl one-liner:
185 |
186 | curl -d '{ "janus": "message_plugin", "transaction": "123", "admin_secret": "janusoverlord", "plugin": "janus.plugin.ndi", "request": { "request": "destroy", "name": "lorenzo" } }' http://localhost:7088/admin
187 |
188 | A successful processing of the request will look like this:
189 |
190 | {
191 | "ndi": "success"
192 | }
193 |
194 | ### translate
195 |
196 | As explained in a previous section, the Janus NDI Plugin expects an SDP offer to kickstart the WebRTC-to-NDI translation: this process is made possible by the `translate` request itself, which needs to include the WebRTC SDP offer itself, and some details on the NDI translation to perform.
197 |
198 | The only mandatory argument in the `translate` request is `name`, which specifies which name the NDI sender will need to use: this is how NDI consumers will identify the streams when listing available sources. If this name refers to an NDI sender previously created with `create`, then the stream will be sent there, otherwise a new NDI sender will be created from scratch: in the latter case, the NDI sender will also be automatically destroyed when the PeerConnection is closed. NDI metadata can also be sent, optionally, by providing the XML data to advertise in the `metadata` property.
199 |
200 | By default the WebRTC stream will be translated "as is" to NDI: this means that, if the video resolution changes during the session (which browsers can do in response to CPU usage or RTCP feedback), then the same resolution changes will be visible in the NDI stream too. While NDI applications do have a way to "lock" resolutions, it may sometimes be helpful to enforce a static resolution from the source itself: this is something you can do via the optional `width` and `height` arguments, that if set will force the plugin to always scale the incoming video to the provided resolution, thus providing NDI consumers with a consistent feed; notice that this scaling procedure does NOT take aspect ratio into account, which means that if the resolution provided has a different aspect ration than the actual video, the video will be stretched. An `fps` can be provided as well, which is only informational though, as it's advertised when sending packets but not enforced.
201 |
202 | Finally, a `strict` boolean can specify whether the "strict mode" should be enforced when decoding videos. By default, the decoder is more tolerant, and so will accept broken frames which will result in a smoother experience, but also in occasional video artifacts in case of unrecovered packet losses; enabling "strict mode" will discard frames where packets have been detected as missing, thus resulting in video freezes when that happens, until a keyframe recovers the picture.
203 |
204 | The format of the `translate` request is the following:
205 |
206 | {
207 | "request": "translate",
208 | "name": "",
209 | "metadata": "",
210 | "width": ,
211 | "height": ,
212 | "fps": ,
213 | "strict": ,
214 | "ondisconnect": { // Optional image to show when the user disconnects (assuming no placeholder is used)
215 | "image": "",
216 | "color": ""
217 | },
218 | "videocodec": "