├── .gitignore ├── demo ├── favicon.ico ├── up_arrow.png ├── css │ └── demo.css ├── settings.js ├── ndi.html └── ndi.js ├── CHANGELOG.md ├── conf └── janus.plugin.ndi.jcfg.sample ├── janode ├── eslint.config.js ├── package.json ├── README.md └── src │ └── ndi.js ├── Makefile ├── README.md ├── docs └── API.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | 3 | node_modules 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetecho/janus-ndi/HEAD/demo/favicon.ico -------------------------------------------------------------------------------- /demo/up_arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetecho/janus-ndi/HEAD/demo/up_arrow.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | ## [v0.0.4] - 2024-05-XX 7 | 8 | - First public release of the NDI plugin 9 | -------------------------------------------------------------------------------- /conf/janus.plugin.ndi.jcfg.sample: -------------------------------------------------------------------------------- 1 | # Configuration of the Janus NDI plugin. 2 | 3 | general: { 4 | 5 | #buffer_size = 200 # Jitter buffer, in milliseconds (default=200) 6 | #events = true # Whether events should be sent to event 7 | # handlers (default is false) 8 | } 9 | -------------------------------------------------------------------------------- /janode/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import js from '@eslint/js'; 3 | 4 | export default [ 5 | { 6 | files: [ 7 | 'src/**/*.js', 8 | 'examples/**/*.js' 9 | ], 10 | languageOptions: { 11 | ecmaVersion: 'latest', 12 | sourceType: 'module', 13 | globals: { 14 | ...globals.browser, 15 | ...globals.node 16 | } 17 | }, 18 | rules: { 19 | ...js.configs.recommended.rules, 20 | 'no-unused-vars': [ 21 | 'warn', 22 | { 23 | 'args': 'all', 24 | 'vars': 'all', 25 | 'caughtErrors': 'all', 26 | 'argsIgnorePattern': '^_', 27 | 'varsIgnorePattern': '^_', 28 | 'caughtErrorsIgnorePattern': '^_' 29 | } 30 | ], 31 | 'indent': [ 32 | 'warn', 33 | 'tab', 34 | { 35 | 'SwitchCase': 1 36 | } 37 | ], 38 | 'quotes': [ 39 | 'warn', 40 | 'single' 41 | ], 42 | 'semi': [ 43 | 'warn', 44 | 'always' 45 | ], 46 | 'no-empty': 'off', 47 | 'multiline-comment-style': 0 48 | } 49 | } 50 | ]; 51 | -------------------------------------------------------------------------------- /janode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "janode-ndi", 3 | "description": "Janode module for the Janus NDI plugin", 4 | "version": "1.0.0", 5 | "type": "module", 6 | "keywords": [ 7 | "ndi", 8 | "janus", 9 | "webrtc", 10 | "meetecho" 11 | ], 12 | "author": { 13 | "name": "Lorenzo Miniero", 14 | "email": "lorenzo@meetecho.com", 15 | "url": "https://www.meetecho.com" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/meetecho/janus-ndi.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/meetecho/janus-ndi/issues" 23 | }, 24 | "license": "ISC", 25 | "main": "src/ndi.js", 26 | "exports": { 27 | ".": "./src/ndi.js" 28 | }, 29 | "files": [ 30 | "src/ndi.js" 31 | ], 32 | "dependencies": { 33 | "janode": "^1.8.0" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.4.0", 37 | "eslint": "^9.4.0", 38 | "globals": "^15.4.0" 39 | }, 40 | "engines": { 41 | "node": " >=18.18.0" 42 | }, 43 | "scripts": { 44 | "build": "npm install --omit=dev", 45 | "lint": "npx eslint --debug" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /janode/README.md: -------------------------------------------------------------------------------- 1 | # Janode module for Janus NDI plugin 2 | 3 | [Janode](https://github.com/meetecho/janode/) is a Node.js, browser compatible, adapter for the Janus WebRTC server. It provides an extensible mechanism for adding support to more plugins than those available out of the box. This is an implementation of a Janode module for the Janus NDI plugin. 4 | 5 | You can refer to the [Janode documentation](https://meetecho.github.io/janode/) for more info on how to use Janode itself. For those familiar with Janode, using the NDI plugin should be fairly trivial. 6 | 7 | ## Example of usage 8 | 9 | ```js 10 | import Janode from 'janode'; 11 | const { Logger } = Janode; 12 | import JanusNdiPlugin from 'janode-ndi'; 13 | 14 | const connection = await Janode.connect({ 15 | is_admin: false, 16 | address: { 17 | url: 'ws://127.0.0.1:8188/', 18 | apisecret: 'secret' 19 | } 20 | }); 21 | const session = await connection.create(); 22 | 23 | // Attach to the NDI plugin 24 | const ndiHandle = await session.attach(JanusNdiPlugin) 25 | 26 | // Subscribe to tally events 27 | ndiHandle.on(JanusNdiPlugin.EVENT.JANUS_NDI_TALLY, evtdata => Logger.info('tally', evtdata)); 28 | 29 | // Start the NDI test pattern 30 | await ndiHandle.startTestPattern(); 31 | 32 | // Publish to the NDI plugin 33 | const { jsep: answer } = await ndiHandle.translate({ name: 'My NDI Video', jsep: offer }); 34 | 35 | // Refer to the code for more requests 36 | ``` 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Folder of the Janus installation prefix (we add /include/janus for the headers) 2 | JANUSP ?= /opt/janus 3 | 4 | CFGDIR = conf 5 | BLDDIR = build 6 | 7 | SOURCE = src/janus_ndi.c 8 | TARGET = janus_ndi.so 9 | CFGFILE = janus.plugin.ndi.jcfg.sample 10 | 11 | CFLAGS += -I$(JANUSP)/include $(shell pkg-config --cflags glib-2.0 jansson opus libcurl) -D_GNU_SOURCE -DHAVE_SRTP_2 12 | LDFLAGS += $(shell pkg-config --libs glib-2.0 jansson opus libcurl) 13 | 14 | JCFLAGS = -g -O2 -fstack-protector -Wall -Wextra -Wformat=2 -Wpointer-arith \ 15 | -Wstrict-prototypes -Wredundant-decls -Wwrite-strings \ 16 | -Waggregate-return -Wlarger-than=65536 -Winline -Wpacked \ 17 | -Winit-self -Wno-unused-parameter -Wno-missing-field-initializers \ 18 | -Wno-override-init 19 | 20 | # Uncomment if you want to build with libasan (for debugging leaks) 21 | ASAN = -O1 -g3 -ggdb3 -fno-omit-frame-pointer -fsanitize=address -fno-sanitize-recover=all -fsanitize-address-use-after-scope 22 | ASAN_LIBS = -fsanitize=address 23 | 24 | # Copy the NDI includes and shared objects to the right place 25 | NDI = -I/usr/include/NDI 26 | NDI_LIBS = -lndi 27 | LIBAV = $(shell pkg-config --cflags libavutil libavcodec libavformat libswscale libswresample) 28 | LIBAV_LIBS = $(shell pkg-config --libs libavutil libavcodec libavformat libswscale libswresample) 29 | 30 | all: $(BLDDIR)/$(TARGET) $(BLDDIR)/$(TOOL) 31 | 32 | demo: $(BLDDIR)/$(DEMO) 33 | 34 | $(BLDDIR)/$(TARGET): $(SOURCE) 35 | @mkdir -p $(dir $@) 36 | $(CC) -fPIC -shared -o $@ $< $(JCFLAGS) $(CFLAGS) $(ASAN) $(NDI) $(LIBAV) $(LDFLAGS) -ldl -rdynamic $(ASAN_LIBS) $(NDI_LIBS) $(LIBAV_LIBS) 37 | 38 | clean: 39 | rm -rf $(BLDDIR) 40 | 41 | install: all 42 | rm -f $(JANUSP)/lib/janus/plugins/janus_ndi.so 43 | install $(BLDDIR)/$(TARGET) $(JANUSP)/lib/janus/plugins/ 44 | install -m 0644 $(CFGDIR)/$(CFGFILE) $(JANUSP)/etc/janus/ 45 | 46 | .PHONY: all install clean 47 | -------------------------------------------------------------------------------- /demo/css/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 80px; 3 | } 4 | 5 | a { 6 | text-decoration: none; 7 | } 8 | 9 | .hide { 10 | display: none !important; 11 | } 12 | 13 | .z-2 { 14 | z-index: 2000; 15 | } 16 | 17 | .rounded { 18 | border-radius: 5px; 19 | } 20 | 21 | .centered { 22 | display: block; 23 | margin: auto; 24 | } 25 | 26 | .relative { 27 | position: relative; 28 | } 29 | 30 | .top-left { 31 | position: absolute; 32 | top: 0px; 33 | left: 0px; 34 | } 35 | 36 | .top-right { 37 | position: absolute; 38 | top: 0px; 39 | right: 0px; 40 | } 41 | 42 | .bottom-left { 43 | position: absolute; 44 | bottom: 0px; 45 | left: 0px; 46 | } 47 | 48 | .bottom-right { 49 | position: absolute; 50 | bottom: 0px; 51 | right: 0px; 52 | } 53 | 54 | .navbar-brand { 55 | margin-left: 0px !important; 56 | } 57 | 58 | .navbar { 59 | -webkit-box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49); 60 | -moz-box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49); 61 | box-shadow: 0px 3px 5px rgba(100, 100, 100, 0.49); 62 | } 63 | 64 | .navbar-header { 65 | padding-left: 40px; 66 | } 67 | 68 | .btn-group-xs > .btn, .btn-xs { 69 | padding: 1px 5px; 70 | font-size: 12px; 71 | line-height: 1.5; 72 | border-radius: 3px; 73 | } 74 | 75 | .divider { 76 | width: 100%; 77 | text-align: center; 78 | } 79 | 80 | .divider hr { 81 | margin-left: auto; 82 | margin-right: auto; 83 | width: 45%; 84 | } 85 | 86 | .fa-2 { 87 | font-size: 2em !important; 88 | } 89 | .fa-3 { 90 | font-size: 4em !important; 91 | } 92 | .fa-4 { 93 | font-size: 7em !important; 94 | } 95 | .fa-xl { 96 | font-size: 12em !important; 97 | } 98 | .fa-6 { 99 | font-size: 20em !important; 100 | } 101 | 102 | div.no-video-container { 103 | position: relative; 104 | } 105 | 106 | .no-video-icon { 107 | width: 100%; 108 | height: 240px; 109 | text-align: center; 110 | padding-top: 5rem !important; 111 | } 112 | 113 | .no-video-text { 114 | text-align: center; 115 | position: absolute; 116 | bottom: 0px; 117 | right: 0px; 118 | left: 0px; 119 | font-size: 24px; 120 | } 121 | 122 | .meetecho-logo { 123 | padding: 12px !important; 124 | } 125 | 126 | .meetecho-logo > img { 127 | height: 26px; 128 | } 129 | 130 | pre { 131 | white-space: pre-wrap; 132 | white-space: -moz-pre-wrap; 133 | white-space: -pre-wrap; 134 | white-space: -o-pre-wrap; 135 | word-wrap: break-word; 136 | background-color: #f5f5f5; 137 | } 138 | 139 | .bg-gray { 140 | background-color: #f5f5f5; 141 | } 142 | 143 | .januscon { 144 | font-weight: bold; 145 | animation: pulsating 1s infinite; 146 | } 147 | @keyframes pulsating { 148 | 30% { 149 | color: #FFD700; 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /demo/settings.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | // We use this shared JavaScript file as a simple way to have all demos 4 | // refer to the same settings, e.g., in terms of which server to connect 5 | // to or which STUN/TURN servers to use. This is helpful any time Janus 6 | // and its demos need to be deployed in a different environment, and 7 | // so all demos can be installed as are, by just updating the settings.js 8 | // file accordingly to account for the custom changes. 9 | // 10 | // We make use of this 'server' variable to provide the address of the 11 | // Janus API backend. By default, in this example we assume that Janus is 12 | // co-located with the web server hosting the HTML pages but listening 13 | // on a different port (8088, the default for HTTP in Janus), which is 14 | // why we make use of the 'window.location.hostname' base address. Since 15 | // Janus can also do HTTPS, and considering we don't really want to make 16 | // use of HTTP for Janus if your demos are served on HTTPS, we also rely 17 | // on the 'window.location.protocol' prefix to build the variable, in 18 | // particular to also change the port used to contact Janus (8088 for 19 | // HTTP and 8089 for HTTPS, if enabled). 20 | // In case you place Janus behind an Apache frontend (as we did on the 21 | // online demos at http://janus.conf.meetecho.com) you can just use a 22 | // relative path for the variable, e.g.: 23 | // 24 | // var server = "/janus"; 25 | // 26 | // which will take care of this on its own. 27 | // 28 | // If you want to use the WebSockets frontend to Janus, instead (which 29 | // is what we recommend, since they're more efficient than the long polling 30 | // we do with HTTP), you'll have to pass a different kind of address, e.g.: 31 | // 32 | // var server = "ws://" + window.location.hostname + ":8188"; 33 | // 34 | // Of course this assumes that support for WebSockets has been built in 35 | // when compiling the server. Notice that the "ws://" prefix assumes 36 | // plain HTTP usage, so "wss://" should be used instead when using 37 | // WebSockets on HTTPS.// 38 | // 39 | // If you have multiple options available, and want to let the library 40 | // autodetect the best way to contact your server (or pool of servers), 41 | // you can also pass an array of servers, e.g., to provide alternative 42 | // means of access (e.g., try WebSockets first and, if that fails, fall 43 | // back to plain HTTP) or just have failover servers: 44 | // 45 | // var server = [ 46 | // "ws://" + window.location.hostname + ":8188", 47 | // "/janus" 48 | // ]; 49 | // 50 | // This will tell the library to try connecting to each of the servers 51 | // in the presented order. The first working server will be used for 52 | // the whole session. 53 | // 54 | var server = null; 55 | if(window.location.protocol === 'http:') 56 | server = "http://" + window.location.hostname + ":8088/janus"; 57 | else 58 | server = "https://" + window.location.hostname + ":8089/janus"; 59 | 60 | // When creating a Janus object, we can also specify which STUN/TURN 61 | // servers we'd like to use to gather additional candidates. This is 62 | // done by passing an "iceServers" property when creating the Janus 63 | // object, meaning that the same set of servers will be used for all 64 | // PeerConnections that will be initialized within the context of the 65 | // new Janus session. When no iceServers object is provided, the janus.js 66 | // library automatically uses the free Google STUN servers, which means 67 | // it's equivalent to setting: 68 | // 69 | // var iceServers = [{urls: "stun:stun.l.google.com:19302"}]; 70 | // 71 | // Here are some examples of how an iceServers field may look like to 72 | // support TURN instead. Notice that, when a TURN server is configured, 73 | // there's no need to set a STUN one as well, since the TURN server will 74 | // be automatically contacted as a STUN server too, meaning it will be 75 | // used to gather both server reflexive and relay candidates. 76 | // 77 | // var iceServers = [{urls: "turn:yourturnserver.com:3478", username: "janususer", credential: "januspwd"}] 78 | // var iceServers: [{urls: "turn:yourturnserver.com:443?transport=tcp", username: "janususer", credential: "januspwd"}] 79 | // var iceServers: [{urls: "turns:yourturnserver.com:443?transport=tcp", username: "janususer", credential: "januspwd"}] 80 | // 81 | // By default we leave the iceServers variable empty, which again means 82 | // janus.js will fallback to the Google STUN server by default: 83 | // 84 | var iceServers = null; 85 | -------------------------------------------------------------------------------- /demo/ndi.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Janus WebRTC Server: NDI 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 42 | 43 |
44 |
45 |
46 |
47 |

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: '
', 57 | css: { 58 | border: 'none', 59 | padding: '15px', 60 | backgroundColor: 'transparent', 61 | color: '#aaa', 62 | top: '10px', 63 | left: (navigator.mozGetUserMedia ? '-100px' : '300px') 64 | } }); 65 | } else { 66 | // Restore screen 67 | $.unblockUI(); 68 | } 69 | }, 70 | iceState: function(state) { 71 | Janus.log("ICE state changed to " + state); 72 | }, 73 | mediaState: function(medium, on) { 74 | Janus.log("Janus " + (on ? "started" : "stopped") + " receiving our " + medium); 75 | }, 76 | webrtcState: function(on) { 77 | Janus.log("Janus says our WebRTC PeerConnection is " + (on ? "up" : "down") + " now"); 78 | $('#videoright').parent().parent().unblock(); 79 | }, 80 | slowLink: function(uplink, lost) { 81 | Janus.warn("Janus reports problems " + (uplink ? "sending" : "receiving") + 82 | " packets on this PeerConnection (" + lost + " lost packets)"); 83 | }, 84 | onmessage: function(msg, jsep) { 85 | Janus.debug(" ::: Got a message :::", msg); 86 | if(msg.error) { 87 | bootbox.alert(msg.error); 88 | } 89 | if(jsep) { 90 | Janus.debug("Handling SDP as well...", jsep); 91 | ndi.handleRemoteJsep({ jsep: jsep }); 92 | } 93 | // Tally update? 94 | if(msg.result) { 95 | if(msg.result.preview === true) { 96 | $('#preview').removeClass('hide'); 97 | } else if(msg.result.preview === false) { 98 | $('#preview').addClass('hide'); 99 | } 100 | if(msg.result.program === true) { 101 | $('#program').removeClass('hide'); 102 | } else if(msg.result.program === false) { 103 | $('#program').addClass('hide'); 104 | } 105 | } 106 | }, 107 | onlocaltrack: function(track, on) { 108 | Janus.debug("Local track " + (on ? "added" : "removed") + ":", track); 109 | // We use the track ID as name of the element, but it may contain invalid characters 110 | let trackId = track.id.replace(/[{}]/g, ""); 111 | if(!on) { 112 | // Track removed, get rid of the stream and the rendering 113 | let stream = localTracks[trackId]; 114 | if(stream) { 115 | try { 116 | let tracks = stream.getTracks(); 117 | for(let i in tracks) { 118 | let mst = tracks[i]; 119 | if(mst !== null && mst !== undefined) 120 | mst.stop(); 121 | } 122 | } catch(e) {} 123 | } 124 | if(track.kind === "video") { 125 | $('#myvideo' + trackId).remove(); 126 | localVideos--; 127 | if(localVideos === 0) { 128 | // No video, at least for now: show a placeholder 129 | if($('#videoright .no-video-container').length === 0) { 130 | $('#videoright').prepend( 131 | '
' + 132 | '' + 133 | 'No webcam available' + 134 | '
'); 135 | } 136 | } 137 | } 138 | delete localTracks[trackId]; 139 | return; 140 | } 141 | // If we're here, a new track was added 142 | let stream = localTracks[trackId]; 143 | if(stream) { 144 | // We've been here already 145 | return; 146 | } 147 | $('#videos').removeClass('hide').removeClass('hide'); 148 | if(track.kind === "audio") { 149 | // We ignore local audio tracks, they'd generate echo anyway 150 | if(localVideos === 0) { 151 | // No video, at least for now: show a placeholder 152 | if($('#videoright .no-video-container').length === 0) { 153 | $('#videoright').prepend( 154 | '
' + 155 | '' + 156 | 'No webcam available' + 157 | '
'); 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('