@zero-server/sdk 0.9.10 → 1.0.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/README.md +65 -3
- package/index.js +19 -0
- package/lib/webrtc/cascade.js +577 -0
- package/lib/webrtc/cluster.js +149 -3
- package/lib/webrtc/index.js +73 -11
- package/lib/webrtc/mcu/ffmpeg.js +172 -0
- package/lib/webrtc/mcu/index.js +211 -0
- package/lib/webrtc/observe.js +38 -0
- package/lib/webrtc/recording.js +410 -0
- package/lib/webrtc/room.js +42 -0
- package/lib/webrtc/sfu/index.js +129 -0
- package/lib/webrtc/sfu/livekit.js +304 -0
- package/lib/webrtc/sfu/mediasoup.js +482 -13
- package/lib/webrtc/sfu/memory.js +316 -3
- package/lib/webrtc/signaling.js +175 -1
- package/package.json +1 -1
- package/types/webrtc.d.ts +348 -0
package/lib/webrtc/cluster.js
CHANGED
|
@@ -16,6 +16,7 @@ const crypto = require('node:crypto');
|
|
|
16
16
|
// --- Channel naming ---
|
|
17
17
|
|
|
18
18
|
const CH_ANNOUNCE = 'zs:rtc:announce';
|
|
19
|
+
const CH_LOAD = 'zs:rtc:load';
|
|
19
20
|
const chRoom = (name) => `zs:rtc:room:${name}`;
|
|
20
21
|
const chNode = (id) => `zs:rtc:node:${id}`;
|
|
21
22
|
|
|
@@ -38,6 +39,18 @@ class ClusterCoordinator
|
|
|
38
39
|
* @param {object} [opts]
|
|
39
40
|
* @param {string} [opts.nodeId] - Stable id for this node. Defaults to
|
|
40
41
|
* a random 8-byte hex string.
|
|
42
|
+
* @param {string} [opts.region] - Region tag (e.g. `'us-east'`). Surfaces
|
|
43
|
+
* in load announcements and lets the
|
|
44
|
+
* cascade's bridge selector prefer
|
|
45
|
+
* same-region peers.
|
|
46
|
+
* @param {Function} [opts.loadProbe] - `() => { cpu?:number, producers?:number,
|
|
47
|
+
* consumers?:number, bandwidthIn?:number,
|
|
48
|
+
* bandwidthOut?:number, custom?:object }`.
|
|
49
|
+
* Sync or async. When provided, the
|
|
50
|
+
* coordinator publishes a load snapshot
|
|
51
|
+
* every `opts.loadIntervalMs` ms.
|
|
52
|
+
* @param {number} [opts.loadIntervalMs=5000] - 0 disables the periodic timer
|
|
53
|
+
* (callers can call `publishLoad()` manually).
|
|
41
54
|
*/
|
|
42
55
|
constructor(hub, adapter, opts = {})
|
|
43
56
|
{
|
|
@@ -47,6 +60,12 @@ class ClusterCoordinator
|
|
|
47
60
|
this.adapter = adapter;
|
|
48
61
|
/** @type {string} */
|
|
49
62
|
this.nodeId = opts.nodeId || crypto.randomBytes(8).toString('hex');
|
|
63
|
+
/** @type {string|null} */
|
|
64
|
+
this.region = opts.region || null;
|
|
65
|
+
/** @type {Function|null} */
|
|
66
|
+
this._loadProbe = typeof opts.loadProbe === 'function' ? opts.loadProbe : null;
|
|
67
|
+
/** @type {number} */
|
|
68
|
+
this._loadIntervalMs = opts.loadIntervalMs == null ? 5000 : opts.loadIntervalMs;
|
|
50
69
|
|
|
51
70
|
/**
|
|
52
71
|
* Remote peer directory. `peerId -> { nodeId, room }`.
|
|
@@ -54,6 +73,14 @@ class ClusterCoordinator
|
|
|
54
73
|
*/
|
|
55
74
|
this._remotePeers = new Map();
|
|
56
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Remote node directory. `nodeId -> { region, load, lastSeen }`.
|
|
78
|
+
* Populated from `zs:rtc:load` and `hello` announcements.
|
|
79
|
+
* @type {Map<string, {region:string|null, load:object|null, lastSeen:number}>}
|
|
80
|
+
*/
|
|
81
|
+
this._nodes = new Map();
|
|
82
|
+
this._nodes.set(this.nodeId, { region: this.region, load: null, lastSeen: Date.now() });
|
|
83
|
+
|
|
57
84
|
/** @type {Map<string, Function|null>} */
|
|
58
85
|
this._roomSubs = new Map();
|
|
59
86
|
|
|
@@ -63,6 +90,9 @@ class ClusterCoordinator
|
|
|
63
90
|
/** @type {boolean} */
|
|
64
91
|
this._closed = false;
|
|
65
92
|
|
|
93
|
+
/** @type {NodeJS.Timeout|null} */
|
|
94
|
+
this._loadTimer = null;
|
|
95
|
+
|
|
66
96
|
this._wire();
|
|
67
97
|
}
|
|
68
98
|
|
|
@@ -71,14 +101,16 @@ class ClusterCoordinator
|
|
|
71
101
|
{
|
|
72
102
|
const offAnnounce = this.adapter.subscribe(CH_ANNOUNCE, (m) => this._onAnnounce(m));
|
|
73
103
|
const offNode = this.adapter.subscribe(chNode(this.nodeId), (m) => this._onNodeMsg(m));
|
|
104
|
+
const offLoad = this.adapter.subscribe(CH_LOAD, (m) => this._onLoad(m));
|
|
74
105
|
if (typeof offAnnounce === 'function') this._unsubs.push(offAnnounce);
|
|
75
106
|
if (typeof offNode === 'function') this._unsubs.push(offNode);
|
|
107
|
+
if (typeof offLoad === 'function') this._unsubs.push(offLoad);
|
|
76
108
|
|
|
77
109
|
this._onJoin = ({ peer, room }) =>
|
|
78
110
|
{
|
|
79
111
|
this._ensureRoomSub(room.name);
|
|
80
112
|
this._safePub(CH_ANNOUNCE, {
|
|
81
|
-
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: room.name,
|
|
113
|
+
kind: 'join', nodeId: this.nodeId, region: this.region, peerId: peer.id, room: room.name,
|
|
82
114
|
});
|
|
83
115
|
};
|
|
84
116
|
this._onLeave = ({ peer, room }) =>
|
|
@@ -91,7 +123,14 @@ class ClusterCoordinator
|
|
|
91
123
|
this.hub.on('leave', this._onLeave);
|
|
92
124
|
|
|
93
125
|
// Ask existing nodes to rebroadcast their directory.
|
|
94
|
-
this._safePub(CH_ANNOUNCE, { kind: 'hello', nodeId: this.nodeId });
|
|
126
|
+
this._safePub(CH_ANNOUNCE, { kind: 'hello', nodeId: this.nodeId, region: this.region });
|
|
127
|
+
|
|
128
|
+
// Periodic load broadcast.
|
|
129
|
+
if (this._loadProbe && this._loadIntervalMs > 0)
|
|
130
|
+
{
|
|
131
|
+
this._loadTimer = setInterval(() => { this.publishLoad().catch(() => {}); }, this._loadIntervalMs);
|
|
132
|
+
if (this._loadTimer && typeof this._loadTimer.unref === 'function') this._loadTimer.unref();
|
|
133
|
+
}
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
/** @private */
|
|
@@ -106,6 +145,7 @@ class ClusterCoordinator
|
|
|
106
145
|
_onAnnounce(m)
|
|
107
146
|
{
|
|
108
147
|
if (!m || m.nodeId === this.nodeId || this._closed) return;
|
|
148
|
+
if (m.region !== undefined) this._touchNode(m.nodeId, { region: m.region });
|
|
109
149
|
if (m.kind === 'join')
|
|
110
150
|
{
|
|
111
151
|
this._remotePeers.set(m.peerId, { nodeId: m.nodeId, room: m.room });
|
|
@@ -124,13 +164,36 @@ class ClusterCoordinator
|
|
|
124
164
|
if (peer.room)
|
|
125
165
|
{
|
|
126
166
|
this._safePub(CH_ANNOUNCE, {
|
|
127
|
-
kind: 'join', nodeId: this.nodeId, peerId: peer.id, room: peer.room.name,
|
|
167
|
+
kind: 'join', nodeId: this.nodeId, region: this.region, peerId: peer.id, room: peer.room.name,
|
|
128
168
|
});
|
|
129
169
|
}
|
|
130
170
|
}
|
|
171
|
+
// Also replay our latest load so the newcomer's selector works immediately.
|
|
172
|
+
const me = this._nodes.get(this.nodeId);
|
|
173
|
+
if (me && me.load)
|
|
174
|
+
{
|
|
175
|
+
this._safePub(CH_LOAD, { nodeId: this.nodeId, region: this.region, load: me.load, at: Date.now() });
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
}
|
|
133
179
|
|
|
180
|
+
/** @private */
|
|
181
|
+
_onLoad(m)
|
|
182
|
+
{
|
|
183
|
+
if (!m || !m.nodeId || m.nodeId === this.nodeId || this._closed) return;
|
|
184
|
+
this._touchNode(m.nodeId, { region: m.region == null ? null : m.region, load: m.load || null });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @private */
|
|
188
|
+
_touchNode(nodeId, patch)
|
|
189
|
+
{
|
|
190
|
+
const cur = this._nodes.get(nodeId) || { region: null, load: null, lastSeen: 0 };
|
|
191
|
+
if (patch.region !== undefined) cur.region = patch.region;
|
|
192
|
+
if (patch.load !== undefined) cur.load = patch.load;
|
|
193
|
+
cur.lastSeen = Date.now();
|
|
194
|
+
this._nodes.set(nodeId, cur);
|
|
195
|
+
}
|
|
196
|
+
|
|
134
197
|
/** @private */
|
|
135
198
|
_onNodeMsg(m)
|
|
136
199
|
{
|
|
@@ -199,11 +262,91 @@ class ClusterCoordinator
|
|
|
199
262
|
});
|
|
200
263
|
}
|
|
201
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Publish a load snapshot now. Returns the snapshot that was sent.
|
|
267
|
+
* Called automatically on the configured interval when `loadProbe`
|
|
268
|
+
* is set, but can also be invoked manually (e.g. from tests, or to
|
|
269
|
+
* publish immediately after a producer count changes).
|
|
270
|
+
*/
|
|
271
|
+
async publishLoad()
|
|
272
|
+
{
|
|
273
|
+
if (!this._loadProbe || this._closed) return null;
|
|
274
|
+
let load;
|
|
275
|
+
try { load = await this._loadProbe(); }
|
|
276
|
+
catch (err) { this.hub.emit('clusterError', err); return null; }
|
|
277
|
+
if (!load || typeof load !== 'object') return null;
|
|
278
|
+
this._touchNode(this.nodeId, { load });
|
|
279
|
+
this._safePub(CH_LOAD, { nodeId: this.nodeId, region: this.region, load, at: Date.now() });
|
|
280
|
+
return load;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Snapshot of every known node (including self).
|
|
285
|
+
* @returns {Array<{nodeId:string, region:string|null, load:object|null, lastSeen:number}>}
|
|
286
|
+
*/
|
|
287
|
+
nodes()
|
|
288
|
+
{
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const [nodeId, n] of this._nodes)
|
|
291
|
+
out.push({ nodeId, region: n.region, load: n.load, lastSeen: n.lastSeen });
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Pick a node for a new bridge using one of the built-in selectors
|
|
297
|
+
* or a custom comparator. Returns the chosen `nodeId` (which may be
|
|
298
|
+
* this node's own id when it wins).
|
|
299
|
+
*
|
|
300
|
+
* @param {object} [opts]
|
|
301
|
+
* @param {'local-only'|'least-loaded'|'region-aware'|'region-aware-least-loaded'} [opts.strategy='region-aware-least-loaded']
|
|
302
|
+
* @param {string} [opts.preferRegion] - Override the local region preference.
|
|
303
|
+
* @param {(a, b) => number} [opts.compare] - Custom comparator; smaller wins.
|
|
304
|
+
* @returns {string} nodeId
|
|
305
|
+
*/
|
|
306
|
+
selectBridge(opts)
|
|
307
|
+
{
|
|
308
|
+
const o = opts || {};
|
|
309
|
+
const strategy = o.strategy || 'region-aware-least-loaded';
|
|
310
|
+
if (strategy === 'local-only') return this.nodeId;
|
|
311
|
+
const preferRegion = o.preferRegion !== undefined ? o.preferRegion : this.region;
|
|
312
|
+
const candidates = this.nodes();
|
|
313
|
+
if (candidates.length === 0) return this.nodeId;
|
|
314
|
+
const loadScore = (n) =>
|
|
315
|
+
{
|
|
316
|
+
const l = n.load;
|
|
317
|
+
if (!l) return Number.POSITIVE_INFINITY;
|
|
318
|
+
if (typeof l.cpu === 'number') return l.cpu;
|
|
319
|
+
if (typeof l.producers === 'number') return l.producers;
|
|
320
|
+
return Number.POSITIVE_INFINITY;
|
|
321
|
+
};
|
|
322
|
+
let compare;
|
|
323
|
+
if (typeof o.compare === 'function') compare = o.compare;
|
|
324
|
+
else if (strategy === 'least-loaded') compare = (a, b) => loadScore(a) - loadScore(b);
|
|
325
|
+
else if (strategy === 'region-aware')
|
|
326
|
+
compare = (a, b) =>
|
|
327
|
+
{
|
|
328
|
+
const aHit = a.region && a.region === preferRegion ? 0 : 1;
|
|
329
|
+
const bHit = b.region && b.region === preferRegion ? 0 : 1;
|
|
330
|
+
return aHit - bHit;
|
|
331
|
+
};
|
|
332
|
+
else /* region-aware-least-loaded (default) */
|
|
333
|
+
compare = (a, b) =>
|
|
334
|
+
{
|
|
335
|
+
const aHit = a.region && a.region === preferRegion ? 0 : 1;
|
|
336
|
+
const bHit = b.region && b.region === preferRegion ? 0 : 1;
|
|
337
|
+
if (aHit !== bHit) return aHit - bHit;
|
|
338
|
+
return loadScore(a) - loadScore(b);
|
|
339
|
+
};
|
|
340
|
+
const sorted = candidates.slice().sort(compare);
|
|
341
|
+
return sorted[0].nodeId;
|
|
342
|
+
}
|
|
343
|
+
|
|
202
344
|
/** Tear down all subscriptions and clear remote state. */
|
|
203
345
|
close()
|
|
204
346
|
{
|
|
205
347
|
if (this._closed) return;
|
|
206
348
|
this._closed = true;
|
|
349
|
+
if (this._loadTimer) { try { clearInterval(this._loadTimer); } catch { /* ignore */ } this._loadTimer = null; }
|
|
207
350
|
try { this.hub.off('join', this._onJoin); } catch { /* ignore */ }
|
|
208
351
|
try { this.hub.off('leave', this._onLeave); } catch { /* ignore */ }
|
|
209
352
|
for (const off of this._unsubs) { try { off(); } catch { /* ignore */ } }
|
|
@@ -214,6 +357,7 @@ class ClusterCoordinator
|
|
|
214
357
|
this._unsubs.length = 0;
|
|
215
358
|
this._roomSubs.clear();
|
|
216
359
|
this._remotePeers.clear();
|
|
360
|
+
this._nodes.clear();
|
|
217
361
|
if (this.hub._cluster === this) this.hub._cluster = null;
|
|
218
362
|
}
|
|
219
363
|
|
|
@@ -335,4 +479,6 @@ module.exports = {
|
|
|
335
479
|
useCluster,
|
|
336
480
|
ClusterCoordinator,
|
|
337
481
|
MemoryClusterAdapter,
|
|
482
|
+
CH_ANNOUNCE,
|
|
483
|
+
CH_LOAD,
|
|
338
484
|
};
|
package/lib/webrtc/index.js
CHANGED
|
@@ -117,6 +117,10 @@ const {
|
|
|
117
117
|
const {
|
|
118
118
|
useCluster, ClusterCoordinator, MemoryClusterAdapter,
|
|
119
119
|
} = require('./cluster');
|
|
120
|
+
const { useCascade, CascadeCoordinator, CH_CASCADE } = require('./cascade');
|
|
121
|
+
const { McuAdapter, MemoryMcuAdapter } = require('./mcu');
|
|
122
|
+
const { FfmpegMcuAdapter } = require('./mcu/ffmpeg');
|
|
123
|
+
const { RecordingManager, IngressManager } = require('./recording');
|
|
120
124
|
const { runWebRTCCommand } = require('./cli');
|
|
121
125
|
const { SfuAdapter, loadSfuAdapter } = require('./sfu');
|
|
122
126
|
const { MemorySfuAdapter } = require('./sfu/memory');
|
|
@@ -125,22 +129,66 @@ const { LiveKitSfuAdapter } = require('./sfu/livekit');
|
|
|
125
129
|
const { spawnBotPeer } = require('./bot');
|
|
126
130
|
|
|
127
131
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
+
* One-call WebRTC wiring: build a `SignalingHub`, optionally mount an SFU
|
|
133
|
+
* adapter, optionally bind a metrics registry / tracer, and attach a WS
|
|
134
|
+
* route to `app` so peers can connect at `opts.path` (default `/rtc`).
|
|
135
|
+
*
|
|
136
|
+
* @param {object} app Zero Server app handle (must expose `app.ws(path, handler)`).
|
|
137
|
+
* @param {object} [opts] {@link SignalingHubOptions} plus the extras below.
|
|
138
|
+
* @param {string} [opts.path='/rtc'] WS mount point for signaling.
|
|
139
|
+
* @param {object|string} [opts.sfu] SfuAdapter instance or spec (`memory` | `mediasoup` | `livekit` | package).
|
|
140
|
+
* @param {object} [opts.sfuOpts] Forwarded when `opts.sfu` is a string.
|
|
141
|
+
* @param {'mesh'|'sfu'|'mcu'|'auto'} [opts.topology='auto']
|
|
142
|
+
* @param {number} [opts.maxMeshPeers=4]
|
|
143
|
+
* @param {object} [opts.metrics] Prometheus-compatible registry.
|
|
144
|
+
* @param {object} [opts.tracer] OTel-shaped tracer.
|
|
145
|
+
* @returns {SignalingHub}
|
|
146
|
+
*
|
|
147
|
+
* @example | Drop-in wiring
|
|
148
|
+
* const app = createApp();
|
|
149
|
+
* const hub = createWebRTC(app, {
|
|
150
|
+
* joinTokenSecret: process.env.WEBRTC_JWT_SECRET,
|
|
151
|
+
* topology: 'auto',
|
|
152
|
+
* sfu: 'memory',
|
|
153
|
+
* metrics: app.metrics,
|
|
154
|
+
* });
|
|
155
|
+
* app.listen(3000);
|
|
132
156
|
*/
|
|
133
|
-
|
|
157
|
+
function createWebRTC(app, opts = {})
|
|
134
158
|
{
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
159
|
+
if (!app || typeof app.ws !== 'function')
|
|
160
|
+
{
|
|
161
|
+
throw new WebRTCError(
|
|
162
|
+
'createWebRTC requires a Zero Server app exposing `app.ws(path, handler)`',
|
|
163
|
+
{ code: 'WEBRTC_INVALID_APP' },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const path = opts.path || '/rtc';
|
|
167
|
+
const hubOpts = { ...opts };
|
|
168
|
+
delete hubOpts.path;
|
|
169
|
+
delete hubOpts.metrics;
|
|
170
|
+
delete hubOpts.tracer;
|
|
171
|
+
const hub = new SignalingHub(hubOpts);
|
|
172
|
+
if (opts.metrics || opts.tracer)
|
|
173
|
+
{
|
|
174
|
+
bindObservability(hub, { metrics: opts.metrics, tracer: opts.tracer });
|
|
175
|
+
}
|
|
176
|
+
app.ws(path, (ws, req) =>
|
|
177
|
+
{
|
|
178
|
+
const peer = hub.attach(ws, {
|
|
179
|
+
user: req && req.user,
|
|
180
|
+
ip: req && req.ip,
|
|
181
|
+
origin: req && req.headers && req.headers.origin,
|
|
182
|
+
});
|
|
183
|
+
if (ws && typeof ws.on === 'function')
|
|
184
|
+
ws.on('close', () => peer.close());
|
|
185
|
+
});
|
|
186
|
+
return hub;
|
|
187
|
+
}
|
|
140
188
|
|
|
141
189
|
module.exports = {
|
|
142
190
|
// Signaling
|
|
143
|
-
createWebRTC
|
|
191
|
+
createWebRTC,
|
|
144
192
|
SignalingHub,
|
|
145
193
|
Room,
|
|
146
194
|
Peer,
|
|
@@ -203,6 +251,20 @@ module.exports = {
|
|
|
203
251
|
ClusterCoordinator,
|
|
204
252
|
MemoryClusterAdapter,
|
|
205
253
|
|
|
254
|
+
// Cross-node SFU cascade
|
|
255
|
+
useCascade,
|
|
256
|
+
CascadeCoordinator,
|
|
257
|
+
CH_CASCADE,
|
|
258
|
+
|
|
259
|
+
// MCU (multipoint control unit) mixers
|
|
260
|
+
McuAdapter,
|
|
261
|
+
MemoryMcuAdapter,
|
|
262
|
+
FfmpegMcuAdapter,
|
|
263
|
+
|
|
264
|
+
// Recording / egress / ingress facade
|
|
265
|
+
RecordingManager,
|
|
266
|
+
IngressManager,
|
|
267
|
+
|
|
206
268
|
// CLI
|
|
207
269
|
runWebRTCCommand,
|
|
208
270
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/mcu/ffmpeg
|
|
3
|
+
* @description Spawns one ffmpeg child process per mix and implements the
|
|
4
|
+
* {@link McuAdapter} contract. The integrator wires mediasoup
|
|
5
|
+
* `PlainTransport` RTP feeds into ffmpeg via `opts.inputs` and consumes
|
|
6
|
+
* ffmpeg's output via `opts.outputs`; the adapter only owns the child
|
|
7
|
+
* lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Gated on `ffmpeg-static` (or an explicit `{ ffmpegPath }`) so missing
|
|
10
|
+
* binaries fail loudly. For a fully managed equivalent prefer LiveKit
|
|
11
|
+
* egress via {@link LiveKitSfuAdapter}.
|
|
12
|
+
*
|
|
13
|
+
* @example | Spawn an audio mix
|
|
14
|
+
* const mcu = new FfmpegMcuAdapter({ sfu });
|
|
15
|
+
* const mix = await mcu.mix('lobby', {
|
|
16
|
+
* producerIds: ['p1', 'p2'],
|
|
17
|
+
* inputs: [{ sdp: '/tmp/in1.sdp' }, { sdp: '/tmp/in2.sdp' }],
|
|
18
|
+
* outputs: [{ url: 'rtp://127.0.0.1:5004' }],
|
|
19
|
+
* kind: 'audio',
|
|
20
|
+
* });
|
|
21
|
+
* await mcu.unmix(mix.mixedProducerId);
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const { McuAdapter } = require('./index');
|
|
27
|
+
const { WebRTCError } = require('../../errors');
|
|
28
|
+
|
|
29
|
+
// --- FfmpegMcuAdapter ---
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ffmpeg-backed MCU adapter.
|
|
33
|
+
*
|
|
34
|
+
* @class
|
|
35
|
+
* @section MCU
|
|
36
|
+
*/
|
|
37
|
+
class FfmpegMcuAdapter extends McuAdapter
|
|
38
|
+
{
|
|
39
|
+
/**
|
|
40
|
+
* @constructor
|
|
41
|
+
* @param {object} [opts]
|
|
42
|
+
* @param {object} [opts.sfu]
|
|
43
|
+
* @param {string} [opts.ffmpegPath] - Overrides the `ffmpeg-static` lookup.
|
|
44
|
+
* @param {Function} [opts.spawn] - Injected for tests (defaults to `child_process.spawn`).
|
|
45
|
+
*/
|
|
46
|
+
constructor(opts)
|
|
47
|
+
{
|
|
48
|
+
super(opts);
|
|
49
|
+
const o = opts || {};
|
|
50
|
+
this._ffmpegPath = o.ffmpegPath || null;
|
|
51
|
+
if (!this._ffmpegPath)
|
|
52
|
+
{
|
|
53
|
+
try
|
|
54
|
+
{
|
|
55
|
+
// eslint-disable-next-line global-require
|
|
56
|
+
this._ffmpegPath = require('ffmpeg-static');
|
|
57
|
+
}
|
|
58
|
+
catch
|
|
59
|
+
{
|
|
60
|
+
throw new WebRTCError(
|
|
61
|
+
'FfmpegMcuAdapter requires `ffmpeg-static` (npm i ffmpeg-static) or { ffmpegPath } in options',
|
|
62
|
+
{ code: 'WEBRTC_MCU_NO_FFMPEG' },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
this._spawn = o.spawn || require('child_process').spawn;
|
|
67
|
+
this._mixes = new Map();
|
|
68
|
+
this._nextId = 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Spawn an ffmpeg process to mix the given producers. The caller is
|
|
73
|
+
* responsible for wiring the SFU's PlainTransport RTP feeds into
|
|
74
|
+
* ffmpeg via `opts.inputs` (an array of `{ producerId, sdp, host, port }`)
|
|
75
|
+
* and consuming ffmpeg's output via `opts.outputs`.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} roomId
|
|
78
|
+
* @param {object} opts - `{ producerIds, inputs, outputs, kind, layout, args? }`
|
|
79
|
+
* @section Mixing
|
|
80
|
+
*/
|
|
81
|
+
async mix(roomId, opts)
|
|
82
|
+
{
|
|
83
|
+
const o = opts || {};
|
|
84
|
+
if (!roomId) throw new WebRTCError('mix: roomId required', { code: 'WEBRTC_MCU_BAD_ARGS' });
|
|
85
|
+
const id = `mcu-ffmpeg:${roomId}:${++this._nextId}`;
|
|
86
|
+
const args = this._buildArgs(o);
|
|
87
|
+
const child = this._spawn(this._ffmpegPath, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
88
|
+
const entry = {
|
|
89
|
+
id, room: roomId, kind: o.kind || 'audio', layout: o.layout || 'grid',
|
|
90
|
+
sources: new Set(Array.isArray(o.producerIds) ? o.producerIds : []),
|
|
91
|
+
child, exited: false,
|
|
92
|
+
};
|
|
93
|
+
child.once('exit', (code, signal) => { entry.exited = true; entry.exitCode = code; entry.exitSignal = signal; });
|
|
94
|
+
this._mixes.set(id, entry);
|
|
95
|
+
return { mixedProducerId: id, kind: entry.kind, layout: entry.layout, sources: [...entry.sources], pid: child.pid };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async unmix(mixedProducerId)
|
|
99
|
+
{
|
|
100
|
+
const m = this._mixes.get(mixedProducerId);
|
|
101
|
+
if (!m) return false;
|
|
102
|
+
this._mixes.delete(mixedProducerId);
|
|
103
|
+
try { m.child.kill('SIGTERM'); } catch { /* swallow */ }
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async setLayout(mixedProducerId, layout)
|
|
108
|
+
{
|
|
109
|
+
const m = this._mixes.get(mixedProducerId);
|
|
110
|
+
if (!m) throw new WebRTCError('setLayout: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
111
|
+
// ffmpeg can't change filter graph on the fly without restart.
|
|
112
|
+
m.layout = typeof layout === 'string' ? layout : (layout && layout.name) || m.layout;
|
|
113
|
+
return m.layout;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async addSource(mixedProducerId, producerId)
|
|
117
|
+
{
|
|
118
|
+
const m = this._mixes.get(mixedProducerId);
|
|
119
|
+
if (!m) throw new WebRTCError('addSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
120
|
+
m.sources.add(producerId);
|
|
121
|
+
return m.sources.size;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async removeSource(mixedProducerId, producerId)
|
|
125
|
+
{
|
|
126
|
+
const m = this._mixes.get(mixedProducerId);
|
|
127
|
+
if (!m) throw new WebRTCError('removeSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
128
|
+
m.sources.delete(producerId);
|
|
129
|
+
return m.sources.size;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stats()
|
|
133
|
+
{
|
|
134
|
+
const mixes = [];
|
|
135
|
+
for (const m of this._mixes.values())
|
|
136
|
+
{
|
|
137
|
+
mixes.push({ id: m.id, room: m.room, sources: [...m.sources], layout: m.layout, kind: m.kind, pid: m.child && m.child.pid, exited: m.exited });
|
|
138
|
+
}
|
|
139
|
+
return { mixes };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async close()
|
|
143
|
+
{
|
|
144
|
+
for (const id of [...this._mixes.keys()]) await this.unmix(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
_buildArgs(opts)
|
|
148
|
+
{
|
|
149
|
+
const o = opts || {};
|
|
150
|
+
if (Array.isArray(o.args)) return o.args;
|
|
151
|
+
const inputs = Array.isArray(o.inputs) ? o.inputs : [];
|
|
152
|
+
const args = ['-y', '-loglevel', 'warning'];
|
|
153
|
+
for (const inp of inputs)
|
|
154
|
+
{
|
|
155
|
+
if (inp.sdp) args.push('-protocol_whitelist', 'pipe,udp,rtp', '-f', 'sdp', '-i', inp.sdp);
|
|
156
|
+
else if (inp.url) args.push('-i', inp.url);
|
|
157
|
+
}
|
|
158
|
+
if (o.kind === 'audio' || !o.kind)
|
|
159
|
+
args.push('-filter_complex', `amix=inputs=${Math.max(inputs.length, 1)}`, '-c:a', 'libopus');
|
|
160
|
+
else
|
|
161
|
+
args.push('-filter_complex', `xstack=inputs=${Math.max(inputs.length, 1)}`, '-c:v', 'libvpx');
|
|
162
|
+
const outputs = Array.isArray(o.outputs) ? o.outputs : [];
|
|
163
|
+
for (const out of outputs)
|
|
164
|
+
{
|
|
165
|
+
if (out.url) args.push('-f', out.format || 'rtp', out.url);
|
|
166
|
+
else if (out.file) args.push('-f', out.format || 'mp4', out.file);
|
|
167
|
+
}
|
|
168
|
+
return args;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { FfmpegMcuAdapter };
|