@zero-server/webrtc 0.9.9 → 1.0.0

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.
@@ -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
  };
@@ -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
- * @private
129
- * Sentinel for surfaces that are intentionally not exported yet. Reserved
130
- * for future top-level shortcuts; current consumers should construct a
131
- * `SignalingHub` directly and use `bindObservability(hub, { app })`.
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
- const notImplemented = (name) =>
157
+ function createWebRTC(app, opts = {})
134
158
  {
135
- throw new WebRTCError(
136
- `${name} is not implemented yet - construct \`new SignalingHub(opts)\` directly and wire it via \`app.ws()\`.`,
137
- { code: 'WEBRTC_NOT_IMPLEMENTED' },
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: () => notImplemented('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 };