@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/mcu
|
|
3
|
+
* @description Optional MCU (multipoint control unit) layer that sits on
|
|
4
|
+
* top of an {@link SfuAdapter}. Where an SFU forwards each publisher's
|
|
5
|
+
* stream untouched, an MCU mixes them down to a smaller number of
|
|
6
|
+
* composite tracks (one audio mix + one tiled video) so receivers only
|
|
7
|
+
* have to decode O(1) streams instead of O(N). The right topology for
|
|
8
|
+
* SIP gateways, low-end clients, regulated recording, and ultra-large
|
|
9
|
+
* rooms where consumer decode cost is the bottleneck.
|
|
10
|
+
*
|
|
11
|
+
* zero-server ships no native mixer — JS-side Opus/H264 transcoding is
|
|
12
|
+
* CPU-prohibitive. The MCU layer is a contract: adapters can spawn
|
|
13
|
+
* ffmpeg (the included {@link FfmpegMcuAdapter}), shell out to a
|
|
14
|
+
* GPU-backed mixer, or call into a managed service. The
|
|
15
|
+
* {@link MemoryMcuAdapter} is bookkeeping only for tests and capacity
|
|
16
|
+
* planning.
|
|
17
|
+
*
|
|
18
|
+
* @example | Bookkeeping-only mixer (tests, capacity planning)
|
|
19
|
+
* const mcu = new MemoryMcuAdapter({ sfu });
|
|
20
|
+
* const { mixedProducerId } = await mcu.mix('lobby', {
|
|
21
|
+
* producerIds: ['p1', 'p2', 'p3'],
|
|
22
|
+
* kind: 'audio',
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* @example | Real audio mixing via ffmpeg
|
|
26
|
+
* const mcu = new FfmpegMcuAdapter({ sfu });
|
|
27
|
+
* const mix = await mcu.mix('lobby', {
|
|
28
|
+
* producerIds: ['p1', 'p2'],
|
|
29
|
+
* inputs: [{ sdp: '/tmp/in1.sdp' }, { sdp: '/tmp/in2.sdp' }],
|
|
30
|
+
* outputs: [{ url: 'rtp://127.0.0.1:5004' }],
|
|
31
|
+
* kind: 'audio',
|
|
32
|
+
* });
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const { WebRTCError } = require('../../errors');
|
|
38
|
+
|
|
39
|
+
/** @typedef {'grid'|'presenter'|'presenter-strip'|'dominant'|'pip'} McuLayout */
|
|
40
|
+
|
|
41
|
+
// --- McuAdapter base ---
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Abstract MCU base class. Adapter authors override `mix` / `unmix` /
|
|
45
|
+
* `setLayout` to back the composite with a real mixer. All methods default
|
|
46
|
+
* to throwing `WEBRTC_MCU_NOT_IMPLEMENTED` so missing capability surfaces
|
|
47
|
+
* immediately rather than silently no-oping.
|
|
48
|
+
*
|
|
49
|
+
* @class
|
|
50
|
+
* @section MCU
|
|
51
|
+
*/
|
|
52
|
+
class McuAdapter
|
|
53
|
+
{
|
|
54
|
+
/**
|
|
55
|
+
* @constructor
|
|
56
|
+
* @param {object} [opts]
|
|
57
|
+
* @param {object} [opts.sfu] - Bound SfuAdapter, used by subclasses that need to ingest/produce.
|
|
58
|
+
* @param {string} [opts.name] - Adapter name, defaults to the constructor name.
|
|
59
|
+
*/
|
|
60
|
+
constructor(opts)
|
|
61
|
+
{
|
|
62
|
+
const o = opts || {};
|
|
63
|
+
this.sfu = o.sfu || null;
|
|
64
|
+
this.name = o.name || this.constructor.name;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// -- Mixing --
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Start a new composite.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} _roomId
|
|
73
|
+
* @param {object} _opts - `{ producerIds, kind:'audio'|'video'|'av', layout?, output? }`
|
|
74
|
+
* @returns {Promise<{mixedProducerId:string, kind:string, layout:McuLayout, sources:string[]}>}
|
|
75
|
+
* @section Mixing
|
|
76
|
+
*/
|
|
77
|
+
// eslint-disable-next-line no-unused-vars
|
|
78
|
+
async mix(_roomId, _opts) { throw new WebRTCError(`${this.name}.mix not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Tear down a composite.
|
|
82
|
+
* @param {string} _mixedProducerId
|
|
83
|
+
* @section Mixing
|
|
84
|
+
*/
|
|
85
|
+
// eslint-disable-next-line no-unused-vars
|
|
86
|
+
async unmix(_mixedProducerId) { throw new WebRTCError(`${this.name}.unmix not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Change video layout on the fly.
|
|
90
|
+
* @param {string} _mixedProducerId
|
|
91
|
+
* @param {McuLayout|object} _layout
|
|
92
|
+
* @section Mixing
|
|
93
|
+
*/
|
|
94
|
+
// eslint-disable-next-line no-unused-vars
|
|
95
|
+
async setLayout(_mixedProducerId, _layout) { throw new WebRTCError(`${this.name}.setLayout not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Add a producer to an existing mix.
|
|
99
|
+
* @param {string} _mixedProducerId
|
|
100
|
+
* @param {string} _producerId
|
|
101
|
+
* @section Mixing
|
|
102
|
+
*/
|
|
103
|
+
// eslint-disable-next-line no-unused-vars
|
|
104
|
+
async addSource(_mixedProducerId, _producerId) { throw new WebRTCError(`${this.name}.addSource not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove a producer from a mix.
|
|
108
|
+
* @param {string} _mixedProducerId
|
|
109
|
+
* @param {string} _producerId
|
|
110
|
+
* @section Mixing
|
|
111
|
+
*/
|
|
112
|
+
// eslint-disable-next-line no-unused-vars
|
|
113
|
+
async removeSource(_mixedProducerId, _producerId) { throw new WebRTCError(`${this.name}.removeSource not implemented`, { code: 'WEBRTC_MCU_NOT_IMPLEMENTED' }); }
|
|
114
|
+
|
|
115
|
+
// -- Inspection --
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Snapshot of active mixes.
|
|
119
|
+
* @returns {{mixes: Array<{id:string, sources:string[], layout:McuLayout, kind:string}>}}
|
|
120
|
+
* @section Inspection
|
|
121
|
+
*/
|
|
122
|
+
stats() { return { mixes: [] }; }
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Tear down every active mix.
|
|
126
|
+
* @section Lifecycle
|
|
127
|
+
*/
|
|
128
|
+
async close() { /* override in subclasses */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- MemoryMcuAdapter ---
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Bookkeeping-only MCU. Doesn't touch media — useful for tests and for
|
|
135
|
+
* apps that want to model the MCU topology cost without paying for real
|
|
136
|
+
* mixing. Produces synthetic mixed-producer ids of the form
|
|
137
|
+
* `mcu:<roomId>:<n>`.
|
|
138
|
+
*
|
|
139
|
+
* @class
|
|
140
|
+
* @section MCU
|
|
141
|
+
*/
|
|
142
|
+
class MemoryMcuAdapter extends McuAdapter
|
|
143
|
+
{
|
|
144
|
+
constructor(opts)
|
|
145
|
+
{
|
|
146
|
+
super(opts);
|
|
147
|
+
this._next = 0;
|
|
148
|
+
/** @type {Map<string, {id:string, room:string, kind:string, layout:McuLayout, sources:Set<string>, output:object|null}>} */
|
|
149
|
+
this._mixes = new Map();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_id(roomId) { return `mcu:${roomId}:${++this._next}`; }
|
|
153
|
+
|
|
154
|
+
async mix(roomId, opts)
|
|
155
|
+
{
|
|
156
|
+
const o = opts || {};
|
|
157
|
+
if (!roomId) throw new WebRTCError('mix: roomId required', { code: 'WEBRTC_MCU_BAD_ARGS' });
|
|
158
|
+
const kind = o.kind || 'audio';
|
|
159
|
+
const layout = o.layout || (kind === 'audio' ? 'audio-only' : 'grid');
|
|
160
|
+
const sources = new Set(Array.isArray(o.producerIds) ? o.producerIds : []);
|
|
161
|
+
const id = this._id(roomId);
|
|
162
|
+
this._mixes.set(id, { id, room: roomId, kind, layout, sources, output: o.output || null });
|
|
163
|
+
return { mixedProducerId: id, kind, layout, sources: [...sources] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async unmix(mixedProducerId)
|
|
167
|
+
{
|
|
168
|
+
const m = this._mixes.get(mixedProducerId);
|
|
169
|
+
if (!m) return false;
|
|
170
|
+
this._mixes.delete(mixedProducerId);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async setLayout(mixedProducerId, layout)
|
|
175
|
+
{
|
|
176
|
+
const m = this._mixes.get(mixedProducerId);
|
|
177
|
+
if (!m) throw new WebRTCError('setLayout: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
178
|
+
m.layout = typeof layout === 'string' ? layout : (layout && layout.name) || m.layout;
|
|
179
|
+
return m.layout;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async addSource(mixedProducerId, producerId)
|
|
183
|
+
{
|
|
184
|
+
const m = this._mixes.get(mixedProducerId);
|
|
185
|
+
if (!m) throw new WebRTCError('addSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
186
|
+
m.sources.add(producerId);
|
|
187
|
+
return m.sources.size;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async removeSource(mixedProducerId, producerId)
|
|
191
|
+
{
|
|
192
|
+
const m = this._mixes.get(mixedProducerId);
|
|
193
|
+
if (!m) throw new WebRTCError('removeSource: unknown mix', { code: 'WEBRTC_MCU_NO_MIX' });
|
|
194
|
+
m.sources.delete(producerId);
|
|
195
|
+
return m.sources.size;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
stats()
|
|
199
|
+
{
|
|
200
|
+
const mixes = [];
|
|
201
|
+
for (const m of this._mixes.values())
|
|
202
|
+
{
|
|
203
|
+
mixes.push({ id: m.id, room: m.room, sources: [...m.sources], layout: m.layout, kind: m.kind });
|
|
204
|
+
}
|
|
205
|
+
return { mixes };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async close() { this._mixes.clear(); }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { McuAdapter, MemoryMcuAdapter };
|
package/lib/webrtc/observe.js
CHANGED
|
@@ -52,6 +52,21 @@ function _registerMetrics(registry)
|
|
|
52
52
|
help: 'Detected ICE restarts (ufrag rotation) per room.',
|
|
53
53
|
labels: ['room'],
|
|
54
54
|
}),
|
|
55
|
+
peersPerMeshRoom: registry.gauge({
|
|
56
|
+
name: 'zs_webrtc_peers_per_mesh_room',
|
|
57
|
+
help: 'Current peer count for rooms still in full-mesh topology.',
|
|
58
|
+
labels: ['room'],
|
|
59
|
+
}),
|
|
60
|
+
meshOverflow: registry.counter({
|
|
61
|
+
name: 'zs_webrtc_mesh_overflow_total',
|
|
62
|
+
help: 'Rooms that exceeded `maxMeshPeers` while still in mesh topology (signals N² uplink risk).',
|
|
63
|
+
labels: ['room'],
|
|
64
|
+
}),
|
|
65
|
+
topologyPromotions: registry.counter({
|
|
66
|
+
name: 'zs_webrtc_topology_promotions_total',
|
|
67
|
+
help: 'Auto-promotions from one topology to another (e.g. mesh→sfu).',
|
|
68
|
+
labels: ['room', 'from', 'to'],
|
|
69
|
+
}),
|
|
55
70
|
};
|
|
56
71
|
}
|
|
57
72
|
|
|
@@ -73,6 +88,8 @@ function _bindMetrics(hub, m)
|
|
|
73
88
|
m.peersActive.inc({ room: room.name });
|
|
74
89
|
// Room newly non-empty?
|
|
75
90
|
if (before === 0) m.roomsActive.inc();
|
|
91
|
+
if (room.topology === 'mesh')
|
|
92
|
+
m.peersPerMeshRoom.set({ room: room.name }, room.size);
|
|
76
93
|
});
|
|
77
94
|
|
|
78
95
|
hub.on('leave', ({ peer, room }) =>
|
|
@@ -80,6 +97,27 @@ function _bindMetrics(hub, m)
|
|
|
80
97
|
m.peersActive.dec({ room: room.name });
|
|
81
98
|
if (m.peersActive.get({ room: room.name }) <= 0)
|
|
82
99
|
m.roomsActive.dec();
|
|
100
|
+
if (room.topology === 'mesh')
|
|
101
|
+
m.peersPerMeshRoom.set({ room: room.name }, room.size);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
hub.on('peer:limit:reached', ({ room }) =>
|
|
105
|
+
{
|
|
106
|
+
if (room.topology === 'mesh')
|
|
107
|
+
m.meshOverflow.inc({ room: room.name });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
hub.on('topology:promoted', ({ room, from, to }) =>
|
|
111
|
+
{
|
|
112
|
+
m.topologyPromotions.inc({ room: room.name, from, to });
|
|
113
|
+
// Promotion drains the mesh gauge.
|
|
114
|
+
m.peersPerMeshRoom.set({ room: room.name }, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
hub.on('topology:demoted', ({ room, from, to }) =>
|
|
118
|
+
{
|
|
119
|
+
m.topologyPromotions.inc({ room: room.name, from, to });
|
|
120
|
+
m.peersPerMeshRoom.set({ room: room.name }, room.size);
|
|
83
121
|
});
|
|
84
122
|
|
|
85
123
|
hub.on('joinFailed', ({ reason }) =>
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module webrtc/recording
|
|
3
|
+
* @description Adapter-agnostic recording / egress / ingress facade.
|
|
4
|
+
* {@link RecordingManager} auto-detects which pipeline the bound
|
|
5
|
+
* {@link SfuAdapter} supports (`livekit` egress, `ffmpeg` child, or
|
|
6
|
+
* `memory` bookkeeping) and exposes a uniform `startRecording` /
|
|
7
|
+
* `stopRecording` surface. {@link IngressManager} wraps the adapter's
|
|
8
|
+
* `createIngress` / `deleteIngress` calls for WHIP / RTMP / SIP /
|
|
9
|
+
* URL-pull sources.
|
|
10
|
+
*
|
|
11
|
+
* @example | Auto-pipeline recording on top of any adapter
|
|
12
|
+
* const rec = new RecordingManager({ adapter: hub.sfu });
|
|
13
|
+
* const { id, stop } = await rec.startRecording('lobby', {
|
|
14
|
+
* layout: 'grid',
|
|
15
|
+
* format: 'mp4',
|
|
16
|
+
* sink: { file: '/var/recordings/lobby.mp4' },
|
|
17
|
+
* });
|
|
18
|
+
* // later
|
|
19
|
+
* await stop();
|
|
20
|
+
*
|
|
21
|
+
* @example | Force the ffmpeg pipeline
|
|
22
|
+
* const rec = new RecordingManager({ adapter: hub.sfu, ffmpegPath: '/usr/bin/ffmpeg' });
|
|
23
|
+
* await rec.startRecording('lobby', {
|
|
24
|
+
* pipeline: 'ffmpeg',
|
|
25
|
+
* inputs: [{ sdp: '/tmp/in.sdp' }],
|
|
26
|
+
* sink: { file: '/tmp/out.mp4' },
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* @example | Create a WHIP ingress and route it into a room
|
|
30
|
+
* const ing = new IngressManager({ adapter: hub.sfu });
|
|
31
|
+
* const stream = await ing.createIngress({ kind: 'whip', name: 'studio', roomName: 'lobby' });
|
|
32
|
+
* // publish using stream.native.url
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const { WebRTCError } = require('../errors');
|
|
38
|
+
|
|
39
|
+
/** @typedef {'mp4'|'webm'|'mka'|'ogg'|'hls'} RecordingFormat */
|
|
40
|
+
/** @typedef {'grid'|'presenter'|'presenter-strip'|'dominant'|'speaker'|'audio-only'} RecordingLayout */
|
|
41
|
+
|
|
42
|
+
// --- RecordingManager ---
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Adapter-agnostic recording facade. Pipeline auto-resolves from
|
|
46
|
+
* `adapter.startRoomCompositeEgress` (LiveKit) → `livekit`; fall back is
|
|
47
|
+
* `memory` (bookkeeping only). Pass `pipeline: 'ffmpeg'` to spawn ffmpeg
|
|
48
|
+
* children.
|
|
49
|
+
*
|
|
50
|
+
* @class
|
|
51
|
+
* @section Recording
|
|
52
|
+
*/
|
|
53
|
+
class RecordingManager
|
|
54
|
+
{
|
|
55
|
+
/**
|
|
56
|
+
* @constructor
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {object} opts.adapter - SfuAdapter (or anything exposing egress methods).
|
|
59
|
+
* @param {Function} [opts.spawn] - For `pipeline: 'ffmpeg'` (defaults to `child_process.spawn`).
|
|
60
|
+
* @param {string} [opts.ffmpegPath]
|
|
61
|
+
*/
|
|
62
|
+
constructor(opts)
|
|
63
|
+
{
|
|
64
|
+
const o = opts || {};
|
|
65
|
+
if (!o.adapter) throw new WebRTCError('RecordingManager requires { adapter }', { code: 'WEBRTC_RECORDING_NO_ADAPTER' });
|
|
66
|
+
this.adapter = o.adapter;
|
|
67
|
+
this._spawn = o.spawn || null;
|
|
68
|
+
this._ffmpegPath = o.ffmpegPath || null;
|
|
69
|
+
this._next = 0;
|
|
70
|
+
/** @type {Map<string, {id:string, roomName:string, kind:string, backend:string, native:object|null, child:object|null, opts:object, status:string, startedAt:number, stoppedAt:number|null}>} */
|
|
71
|
+
this._records = new Map();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_id() { return `rec-${++this._next}-${Date.now().toString(36)}`; }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Start a new recording for `roomName`. The `pipeline` field selects
|
|
78
|
+
* the backend:
|
|
79
|
+
*
|
|
80
|
+
* - `'livekit'` — call `adapter.startRoomCompositeEgress`.
|
|
81
|
+
* - `'livekit-track'` — call `adapter.startTrackEgress`.
|
|
82
|
+
* - `'ffmpeg'` — spawn an ffmpeg child process (requires
|
|
83
|
+
* `{ ffmpegPath }` or `ffmpeg-static`; caller wires RTP).
|
|
84
|
+
* - `'memory'` — bookkeeping only (default when no other backend
|
|
85
|
+
* can be inferred).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} roomName
|
|
88
|
+
* @param {object} [opts]
|
|
89
|
+
* @returns {Promise<{id:string, status:string, stop:Function, info():object}>}
|
|
90
|
+
* @section Recording
|
|
91
|
+
*/
|
|
92
|
+
async startRecording(roomName, opts)
|
|
93
|
+
{
|
|
94
|
+
if (!roomName) throw new WebRTCError('startRecording: roomName required', { code: 'WEBRTC_RECORDING_BAD_ARGS' });
|
|
95
|
+
const o = opts || {};
|
|
96
|
+
const pipeline = o.pipeline || this._inferPipeline();
|
|
97
|
+
const id = this._id();
|
|
98
|
+
const entry = {
|
|
99
|
+
id, roomName,
|
|
100
|
+
kind: o.kind || 'composite',
|
|
101
|
+
backend: pipeline,
|
|
102
|
+
native: null,
|
|
103
|
+
child: null,
|
|
104
|
+
opts: o,
|
|
105
|
+
status: 'starting',
|
|
106
|
+
startedAt: Date.now(),
|
|
107
|
+
stoppedAt: null,
|
|
108
|
+
};
|
|
109
|
+
this._records.set(id, entry);
|
|
110
|
+
|
|
111
|
+
try
|
|
112
|
+
{
|
|
113
|
+
if (pipeline === 'livekit')
|
|
114
|
+
{
|
|
115
|
+
if (typeof this.adapter.startRoomCompositeEgress !== 'function')
|
|
116
|
+
throw new WebRTCError('adapter does not implement startRoomCompositeEgress', { code: 'WEBRTC_RECORDING_NO_BACKEND' });
|
|
117
|
+
entry.native = await this.adapter.startRoomCompositeEgress(roomName, o);
|
|
118
|
+
}
|
|
119
|
+
else if (pipeline === 'livekit-track')
|
|
120
|
+
{
|
|
121
|
+
if (typeof this.adapter.startTrackEgress !== 'function')
|
|
122
|
+
throw new WebRTCError('adapter does not implement startTrackEgress', { code: 'WEBRTC_RECORDING_NO_BACKEND' });
|
|
123
|
+
entry.native = await this.adapter.startTrackEgress(roomName, o);
|
|
124
|
+
}
|
|
125
|
+
else if (pipeline === 'ffmpeg')
|
|
126
|
+
{
|
|
127
|
+
entry.child = this._spawnFfmpeg(o);
|
|
128
|
+
}
|
|
129
|
+
else if (pipeline === 'memory')
|
|
130
|
+
{
|
|
131
|
+
/* nothing to do — caller models the cost */
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
{
|
|
135
|
+
throw new WebRTCError(`unknown recording pipeline: ${pipeline}`, { code: 'WEBRTC_RECORDING_BAD_PIPELINE' });
|
|
136
|
+
}
|
|
137
|
+
entry.status = 'recording';
|
|
138
|
+
}
|
|
139
|
+
catch (err)
|
|
140
|
+
{
|
|
141
|
+
entry.status = 'failed';
|
|
142
|
+
entry.error = err && err.message;
|
|
143
|
+
entry.stoppedAt = Date.now();
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id,
|
|
149
|
+
status: entry.status,
|
|
150
|
+
stop: () => this.stopRecording(id),
|
|
151
|
+
info: () => this._snapshot(entry),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stop an active recording by id. Idempotent.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} id
|
|
159
|
+
* @returns {Promise<boolean>} true if the recording was stopped here.
|
|
160
|
+
* @section Recording
|
|
161
|
+
*/
|
|
162
|
+
async stopRecording(id)
|
|
163
|
+
{
|
|
164
|
+
const entry = this._records.get(id);
|
|
165
|
+
if (!entry) return false;
|
|
166
|
+
if (entry.status === 'stopped' || entry.status === 'failed') return false;
|
|
167
|
+
entry.status = 'stopping';
|
|
168
|
+
try
|
|
169
|
+
{
|
|
170
|
+
if (entry.backend === 'livekit' || entry.backend === 'livekit-track')
|
|
171
|
+
{
|
|
172
|
+
const egressId = entry.native && (entry.native.egressId || entry.native.egress_id || entry.native.id);
|
|
173
|
+
if (egressId && typeof this.adapter.stopEgress === 'function')
|
|
174
|
+
await this.adapter.stopEgress(egressId);
|
|
175
|
+
}
|
|
176
|
+
else if (entry.backend === 'ffmpeg')
|
|
177
|
+
{
|
|
178
|
+
if (entry.child && typeof entry.child.kill === 'function')
|
|
179
|
+
{
|
|
180
|
+
try { entry.child.kill('SIGTERM'); } catch { /* swallow */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
entry.status = 'stopped';
|
|
184
|
+
entry.stoppedAt = Date.now();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
catch (err)
|
|
188
|
+
{
|
|
189
|
+
entry.status = 'failed';
|
|
190
|
+
entry.error = err && err.message;
|
|
191
|
+
entry.stoppedAt = Date.now();
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* List every recording the manager knows about.
|
|
198
|
+
* @returns {Array<object>}
|
|
199
|
+
* @section Inspection
|
|
200
|
+
*/
|
|
201
|
+
list()
|
|
202
|
+
{
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const entry of this._records.values()) out.push(this._snapshot(entry));
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Aggregate counts for observability.
|
|
210
|
+
* @returns {{recording:number, stopped:number, failed:number, total:number}}
|
|
211
|
+
* @section Inspection
|
|
212
|
+
*/
|
|
213
|
+
stats()
|
|
214
|
+
{
|
|
215
|
+
let recording = 0, stopped = 0, failed = 0;
|
|
216
|
+
for (const e of this._records.values())
|
|
217
|
+
{
|
|
218
|
+
if (e.status === 'recording' || e.status === 'starting' || e.status === 'stopping') recording++;
|
|
219
|
+
else if (e.status === 'stopped') stopped++;
|
|
220
|
+
else if (e.status === 'failed') failed++;
|
|
221
|
+
}
|
|
222
|
+
return { recording, stopped, failed, total: this._records.size };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stop every active recording. Safe to call during shutdown.
|
|
227
|
+
* @section Lifecycle
|
|
228
|
+
*/
|
|
229
|
+
async close()
|
|
230
|
+
{
|
|
231
|
+
const active = [...this._records.values()].filter((e) => e.status === 'recording' || e.status === 'starting');
|
|
232
|
+
for (const e of active)
|
|
233
|
+
{
|
|
234
|
+
try { await this.stopRecording(e.id); } catch { /* swallow */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @private */
|
|
239
|
+
_snapshot(e)
|
|
240
|
+
{
|
|
241
|
+
return {
|
|
242
|
+
id: e.id,
|
|
243
|
+
roomName: e.roomName,
|
|
244
|
+
kind: e.kind,
|
|
245
|
+
backend: e.backend,
|
|
246
|
+
status: e.status,
|
|
247
|
+
startedAt: e.startedAt,
|
|
248
|
+
stoppedAt: e.stoppedAt,
|
|
249
|
+
pid: e.child && e.child.pid,
|
|
250
|
+
native: e.native ? { id: e.native.egressId || e.native.id || null } : null,
|
|
251
|
+
error: e.error || null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @private */
|
|
256
|
+
_inferPipeline()
|
|
257
|
+
{
|
|
258
|
+
if (typeof this.adapter.startRoomCompositeEgress === 'function') return 'livekit';
|
|
259
|
+
return 'memory';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @private */
|
|
263
|
+
_spawnFfmpeg(opts)
|
|
264
|
+
{
|
|
265
|
+
const spawn = this._spawn || require('child_process').spawn;
|
|
266
|
+
let path = this._ffmpegPath;
|
|
267
|
+
if (!path)
|
|
268
|
+
{
|
|
269
|
+
try { path = require('ffmpeg-static'); }
|
|
270
|
+
catch
|
|
271
|
+
{
|
|
272
|
+
throw new WebRTCError(
|
|
273
|
+
'ffmpeg pipeline requires `ffmpeg-static` (npm i ffmpeg-static) or { ffmpegPath }',
|
|
274
|
+
{ code: 'WEBRTC_RECORDING_NO_FFMPEG' },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const args = Array.isArray(opts.args) ? opts.args : this._buildFfmpegArgs(opts);
|
|
279
|
+
return spawn(path, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @private */
|
|
283
|
+
_buildFfmpegArgs(opts)
|
|
284
|
+
{
|
|
285
|
+
const o = opts || {};
|
|
286
|
+
const inputs = Array.isArray(o.inputs) ? o.inputs : [];
|
|
287
|
+
const sink = o.sink || {};
|
|
288
|
+
const format = o.format || (sink.file && sink.file.endsWith('.webm') ? 'webm' : 'mp4');
|
|
289
|
+
const args = ['-y', '-loglevel', 'warning'];
|
|
290
|
+
for (const inp of inputs)
|
|
291
|
+
{
|
|
292
|
+
if (inp.sdp) args.push('-protocol_whitelist', 'pipe,udp,rtp', '-f', 'sdp', '-i', inp.sdp);
|
|
293
|
+
else if (inp.url) args.push('-i', inp.url);
|
|
294
|
+
}
|
|
295
|
+
if (sink.file) args.push('-f', format, sink.file);
|
|
296
|
+
else if (sink.url) args.push('-f', sink.format || 'rtp', sink.url);
|
|
297
|
+
return args;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- IngressManager ---
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Tracks ingresses created via the adapter (WHIP / RTMP / URL pull / SIP)
|
|
305
|
+
* so callers don't need their own bookkeeping. Delegates the actual
|
|
306
|
+
* lifecycle to `adapter.createIngress` / `adapter.deleteIngress`.
|
|
307
|
+
*
|
|
308
|
+
* @class
|
|
309
|
+
* @section Recording
|
|
310
|
+
*/
|
|
311
|
+
class IngressManager
|
|
312
|
+
{
|
|
313
|
+
/**
|
|
314
|
+
* @constructor
|
|
315
|
+
* @param {object} opts
|
|
316
|
+
* @param {object} opts.adapter
|
|
317
|
+
*/
|
|
318
|
+
constructor(opts)
|
|
319
|
+
{
|
|
320
|
+
const o = opts || {};
|
|
321
|
+
if (!o.adapter) throw new WebRTCError('IngressManager requires { adapter }', { code: 'WEBRTC_INGRESS_NO_ADAPTER' });
|
|
322
|
+
this.adapter = o.adapter;
|
|
323
|
+
this._next = 0;
|
|
324
|
+
/** @type {Map<string, {id:string, kind:string, roomName:string|null, native:object, opts:object, createdAt:number}>} */
|
|
325
|
+
this._ingresses = new Map();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_id() { return `ing-${++this._next}-${Date.now().toString(36)}`; }
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Create an ingress (WHIP / RTMP / URL pull / SIP).
|
|
332
|
+
* @param {object} opts - `{ kind, roomName, name, ... }`
|
|
333
|
+
* @section Recording
|
|
334
|
+
*/
|
|
335
|
+
async createIngress(opts)
|
|
336
|
+
{
|
|
337
|
+
const o = opts || {};
|
|
338
|
+
if (typeof this.adapter.createIngress !== 'function')
|
|
339
|
+
throw new WebRTCError('adapter does not implement createIngress', { code: 'WEBRTC_INGRESS_NO_BACKEND' });
|
|
340
|
+
const native = await this.adapter.createIngress(o);
|
|
341
|
+
const id = this._id();
|
|
342
|
+
const entry = {
|
|
343
|
+
id,
|
|
344
|
+
kind: o.kind || o.inputType || 'rtmp',
|
|
345
|
+
roomName: o.roomName || o.room || null,
|
|
346
|
+
native,
|
|
347
|
+
opts: o,
|
|
348
|
+
createdAt: Date.now(),
|
|
349
|
+
};
|
|
350
|
+
this._ingresses.set(id, entry);
|
|
351
|
+
return { id, native, info: () => this._snapshot(entry) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Tear down an ingress by id. Idempotent.
|
|
356
|
+
* @param {string} id
|
|
357
|
+
* @returns {Promise<boolean>}
|
|
358
|
+
* @section Recording
|
|
359
|
+
*/
|
|
360
|
+
async deleteIngress(id)
|
|
361
|
+
{
|
|
362
|
+
const entry = this._ingresses.get(id);
|
|
363
|
+
if (!entry) return false;
|
|
364
|
+
this._ingresses.delete(id);
|
|
365
|
+
if (typeof this.adapter.deleteIngress === 'function')
|
|
366
|
+
{
|
|
367
|
+
const ingressId = entry.native && (entry.native.ingressId || entry.native.id);
|
|
368
|
+
if (ingressId) await this.adapter.deleteIngress(ingressId);
|
|
369
|
+
}
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* List every known ingress.
|
|
375
|
+
* @returns {Array<object>}
|
|
376
|
+
* @section Inspection
|
|
377
|
+
*/
|
|
378
|
+
list()
|
|
379
|
+
{
|
|
380
|
+
const out = [];
|
|
381
|
+
for (const entry of this._ingresses.values()) out.push(this._snapshot(entry));
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Tear down every active ingress. Safe to call during shutdown.
|
|
387
|
+
* @section Lifecycle
|
|
388
|
+
*/
|
|
389
|
+
async close()
|
|
390
|
+
{
|
|
391
|
+
for (const id of [...this._ingresses.keys()])
|
|
392
|
+
{
|
|
393
|
+
try { await this.deleteIngress(id); } catch { /* swallow */ }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @private */
|
|
398
|
+
_snapshot(e)
|
|
399
|
+
{
|
|
400
|
+
return {
|
|
401
|
+
id: e.id,
|
|
402
|
+
kind: e.kind,
|
|
403
|
+
roomName: e.roomName,
|
|
404
|
+
createdAt: e.createdAt,
|
|
405
|
+
native: e.native ? { id: e.native.ingressId || e.native.id || null, url: e.native.url || e.native.streamKey || null } : null,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = { RecordingManager, IngressManager };
|