@camstack/addon-webrtc-adaptive 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-webrtc-adaptive",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Adaptive WebRTC streaming engine for CamStack — quality adaptation, RTCP monitoring, multi-camera",
5
5
  "keywords": [
6
6
  "camstack",
@@ -1,561 +0,0 @@
1
- import { ICamstackAddon, IConfigurable, AddonManifest, AddonContext, CapabilityProviderMap, ConfigUISchema } from '@camstack/types';
2
- import { EventEmitter } from 'node:events';
3
-
4
- /**
5
- * Core types for media stream servers.
6
- * Protocol-agnostic frame types used across all server implementations.
7
- */
8
- type VideoCodec = "H264" | "H265";
9
- type AudioCodec = "Aac" | "Adpcm" | "Opus" | "Pcmu" | "Pcma";
10
- /**
11
- * A single video frame in Annex-B format (with start codes).
12
- */
13
- interface VideoFrame {
14
- /** Raw video data in Annex-B format (NAL units with 0x00000001 start codes) */
15
- data: Buffer;
16
- /** Video codec type */
17
- codec: VideoCodec;
18
- /** True if this is a keyframe (IDR for H.264, IRAP for H.265) */
19
- isKeyframe: boolean;
20
- /** Presentation timestamp in microseconds (monotonic) */
21
- timestampMicros: number;
22
- /** Optional: frame width in pixels */
23
- width?: number;
24
- /** Optional: frame height in pixels */
25
- height?: number;
26
- }
27
- /**
28
- * A single audio frame.
29
- * For AAC: data should include ADTS headers.
30
- * For other codecs: raw audio samples.
31
- */
32
- interface AudioFrame {
33
- /** Raw audio data */
34
- data: Buffer;
35
- /** Audio codec */
36
- codec: AudioCodec;
37
- /** Sample rate in Hz */
38
- sampleRate: number;
39
- /** Number of channels */
40
- channels: number;
41
- /** Presentation timestamp in microseconds */
42
- timestampMicros: number;
43
- }
44
- /**
45
- * Tagged union for video/audio frames.
46
- */
47
- type MediaFrame = {
48
- type: "video";
49
- frame: VideoFrame;
50
- } | {
51
- type: "audio";
52
- frame: AudioFrame;
53
- };
54
- /**
55
- * Primary frame source interface.
56
- * Yields MediaFrame objects. Returning from the generator signals end of stream.
57
- * Throwing signals an error that should trigger reconnection.
58
- */
59
- type FrameSource = AsyncGenerator<MediaFrame, void, unknown>;
60
- /**
61
- * Factory that creates a new FrameSource.
62
- * Called each time the server needs to start/restart the stream.
63
- * The server is responsible for calling return() to stop the source.
64
- */
65
- type FrameSourceFactory = () => FrameSource;
66
- /**
67
- * Minimal logger interface compatible with console.
68
- */
69
- interface Logger {
70
- log(message?: unknown, ...args: unknown[]): void;
71
- info(message?: unknown, ...args: unknown[]): void;
72
- warn(message?: unknown, ...args: unknown[]): void;
73
- error(message?: unknown, ...args: unknown[]): void;
74
- debug(message?: unknown, ...args: unknown[]): void;
75
- }
76
- /**
77
- * Accept Console or partial Logger implementations.
78
- */
79
- type LoggerLike = Partial<Logger> | Console;
80
- /**
81
- * Wrap a LoggerLike into a full Logger (no-op for missing methods).
82
- */
83
- declare function asLogger(logger?: LoggerLike): Logger;
84
- /**
85
- * Create a logger that discards all output.
86
- */
87
- declare function createNullLogger(): Logger;
88
-
89
- /**
90
- * Adaptive FFmpeg source — RTSP→FrameSource pipeline with hot-swappable encoding params.
91
- *
92
- * Spawns ffmpeg to transcode an RTSP source to raw H.264 Annex-B on stdout.
93
- * Supports dynamic bitrate/resolution changes by restarting ffmpeg with new params,
94
- * gating the transition on the next keyframe to avoid visual artifacts.
95
- */
96
-
97
- interface EncodingParams {
98
- /** Max video bitrate in kbps (e.g. 8000 for 8 Mbps). */
99
- maxBitrateKbps: number;
100
- /** Output width (0 = keep source). */
101
- width: number;
102
- /** Output height (0 = keep source). */
103
- height: number;
104
- /** H.264 preset (default: "ultrafast"). */
105
- preset?: string;
106
- }
107
- /** Audio mode: "copy" = passthrough G.711, "opus" = transcode to Opus, "off" = no audio */
108
- type AudioMode = "copy" | "opus" | "off";
109
- interface AdaptiveFfmpegSourceOptions {
110
- /** RTSP source URL. */
111
- rtspUrl: string;
112
- /** Initial encoding parameters. */
113
- initialParams: EncodingParams;
114
- /** Audio mode (default: "copy"). */
115
- audioMode?: AudioMode;
116
- /** FFmpeg binary path (default: "ffmpeg"). */
117
- ffmpegPath?: string;
118
- /** Logger instance. */
119
- logger?: Logger;
120
- /** Label for logging. */
121
- label?: string;
122
- }
123
- declare class AdaptiveFfmpegSource {
124
- private readonly rtspUrl;
125
- private readonly ffmpegPath;
126
- private readonly logger;
127
- private readonly label;
128
- private readonly audioMode;
129
- private currentParams;
130
- private proc;
131
- private audioProc;
132
- private closed;
133
- /** Push callback for the frame source. */
134
- private pushFrame;
135
- private closeSource;
136
- /** The FrameSource async generator. Created once, survives ffmpeg restarts. */
137
- readonly source: FrameSource;
138
- constructor(options: AdaptiveFfmpegSourceOptions);
139
- /** Start the ffmpeg process with current encoding params. */
140
- start(): Promise<void>;
141
- /** Get the current encoding parameters. */
142
- getParams(): Readonly<EncodingParams>;
143
- /**
144
- * Hot-swap encoding parameters.
145
- * Stops the current ffmpeg and starts a new one with updated params.
146
- * The FrameSource continues seamlessly — the new ffmpeg's first keyframe
147
- * is gated internally so consumers see a clean transition.
148
- */
149
- updateParams(params: Partial<EncodingParams>): Promise<void>;
150
- /** Stop the source and kill ffmpeg. */
151
- stop(): Promise<void>;
152
- private spawnFfmpeg;
153
- private killFfmpeg;
154
- }
155
-
156
- /**
157
- * Adaptive quality controller — state machine for bitrate/resolution/source adaptation.
158
- *
159
- * Monitors packet loss, jitter, and RTT from RTCP reports and client-reported stats.
160
- * Makes quality decisions using a three-tier degradation/recovery model:
161
- *
162
- * Level 1 — Reduce bitrate cap (ffmpeg -maxrate)
163
- * Level 2 — Downscale resolution (ffmpeg -vf scale=)
164
- * Level 3 — Switch source stream (main → sub via replaceTrack)
165
- *
166
- * Hysteresis prevents oscillation: degradation requires 2 consecutive bad intervals,
167
- * recovery requires 3 consecutive good intervals.
168
- */
169
-
170
- type QualityTier = "high" | "medium" | "low";
171
- interface QualityProfile {
172
- tier: QualityTier;
173
- /** Encoding params to apply. */
174
- encoding: EncodingParams;
175
- /** Source stream profile ("main" | "sub"). */
176
- sourceProfile: "main" | "sub";
177
- }
178
- interface StreamStats {
179
- /** Packet loss ratio (0.0 – 1.0). */
180
- packetLoss: number;
181
- /** Jitter in milliseconds. */
182
- jitterMs: number;
183
- /** Round-trip time in milliseconds. */
184
- rttMs: number;
185
- /** Timestamp of this measurement. */
186
- timestamp: number;
187
- }
188
- interface AdaptiveControllerOptions {
189
- /** Available quality profiles, ordered from highest to lowest. */
190
- profiles: QualityProfile[];
191
- /** Packet loss threshold to degrade (default: 0.02 = 2%). */
192
- degradeThreshold?: number;
193
- /** Packet loss threshold to recover (default: 0.005 = 0.5%). */
194
- recoverThreshold?: number;
195
- /** Consecutive bad intervals before degradation (default: 2). */
196
- degradeCount?: number;
197
- /** Consecutive good intervals before recovery (default: 3). */
198
- recoverCount?: number;
199
- /** Callback when quality should change. */
200
- onQualityChange: (from: QualityProfile, to: QualityProfile) => void | Promise<void>;
201
- /** Logger. */
202
- logger?: Logger;
203
- }
204
- declare class AdaptiveController {
205
- private readonly profiles;
206
- private readonly degradeThreshold;
207
- private readonly recoverThreshold;
208
- private readonly degradeCount;
209
- private readonly recoverCount;
210
- private readonly onQualityChange;
211
- private readonly logger;
212
- private currentIndex;
213
- private consecutiveBad;
214
- private consecutiveGood;
215
- private switching;
216
- /** Smoothed stats per session (aggregated for decisions). */
217
- private readonly sessionStats;
218
- /** Manual override tier (null = auto). */
219
- private forcedTier;
220
- constructor(options: AdaptiveControllerOptions);
221
- /** Get the current quality profile. */
222
- get currentProfile(): QualityProfile;
223
- /** Get the current quality tier. */
224
- get currentTier(): QualityTier;
225
- /** Get aggregated stats summary. */
226
- getAggregatedStats(): {
227
- packetLoss: number;
228
- jitterMs: number;
229
- rttMs: number;
230
- };
231
- /**
232
- * Report stats from a session (RTCP or client-reported).
233
- * Call this periodically (e.g. every 3–5 seconds).
234
- */
235
- reportStats(sessionId: string, stats: StreamStats): void;
236
- /** Remove a session's stats (call on session close). */
237
- removeSession(sessionId: string): void;
238
- /** Force a specific quality tier (null = auto). */
239
- forceQuality(tier: QualityTier | null): void;
240
- /** Check if auto-adaptation is active (not forced). */
241
- get isAuto(): boolean;
242
- private evaluate;
243
- private degrade;
244
- private recover;
245
- private switchTo;
246
- }
247
-
248
- /**
249
- * Adaptive WebRTC session — extends the base session pattern with:
250
- * - RTCP Receiver Report monitoring (packet loss, jitter, RTT)
251
- * - Source replacement (replaceTrack for seamless quality switching)
252
- * - Stats emission for the AdaptiveController
253
- *
254
- * Uses werift (optional peer dependency) for server-side WebRTC.
255
- */
256
-
257
- interface AdaptiveSessionOptions {
258
- sessionId: string;
259
- source: FrameSource;
260
- intercom?: {
261
- onAudioReceived: (data: Buffer, format: AudioCodec) => void | Promise<void>;
262
- };
263
- iceConfig?: {
264
- stunServers?: string[];
265
- turnServers?: Array<{
266
- urls: string;
267
- username?: string;
268
- credential?: string;
269
- }>;
270
- portRange?: [number, number];
271
- additionalHostAddresses?: string[];
272
- };
273
- /** Callback for RTCP stats (called every ~3s). */
274
- onStats?: (stats: SessionStats) => void;
275
- /** Enable verbose frame/RTP logging. */
276
- debug?: boolean;
277
- logger: Logger;
278
- }
279
- interface SessionStats {
280
- sessionId: string;
281
- /** Fraction of packets lost (0.0–1.0). */
282
- packetLoss: number;
283
- /** Interarrival jitter in ms. */
284
- jitterMs: number;
285
- /** Round-trip time in ms (from RTCP SR/RR). */
286
- rttMs: number;
287
- /** Total packets received. */
288
- packetsReceived: number;
289
- /** Total packets lost. */
290
- packetsLost: number;
291
- /** Timestamp. */
292
- timestamp: number;
293
- }
294
- interface SessionInfo {
295
- sessionId: string;
296
- state: "new" | "connecting" | "connected" | "disconnected" | "closed";
297
- createdAt: number;
298
- }
299
- declare class AdaptiveSession {
300
- private readonly sessionId;
301
- private source;
302
- private readonly logger;
303
- private readonly intercom;
304
- private readonly iceConfig;
305
- private readonly onStats;
306
- readonly debug: boolean;
307
- private readonly createdAt;
308
- private state;
309
- private pc;
310
- private videoTrack;
311
- private audioTrack;
312
- /** Transceiver senders for direct sendRtp (more reliable than track.writeRtp) */
313
- private videoSender;
314
- private audioSender;
315
- private feedAbort;
316
- private closed;
317
- private statsTimer;
318
- /** RTP sequence number counter (must increment per packet). */
319
- private videoSeqNum;
320
- private audioSeqNum;
321
- /** Previous RTCP stats for delta calculation. */
322
- private prevPacketsReceived;
323
- private prevPacketsLost;
324
- constructor(options: AdaptiveSessionOptions);
325
- /** Build PeerConnection options including H.264 codec config. */
326
- private buildPcOptions;
327
- /** Create offer SDP (server → client). */
328
- createOffer(): Promise<{
329
- sdp: string;
330
- type: "offer";
331
- }>;
332
- /** Handle WHEP answer: client sends SDP answer, we set remote description and start feeding. */
333
- handleAnswer(answer: {
334
- sdp: string;
335
- type: "answer";
336
- }): Promise<void>;
337
- /**
338
- * Handle WHEP offer: client sends SDP offer, we create answer.
339
- *
340
- * Uses the server-creates-offer pattern internally: we create our own offer
341
- * with sendonly tracks, then use the client's offer codecs to build a
342
- * compatible answer. This avoids werift transceiver direction issues.
343
- */
344
- handleOffer(clientOffer: {
345
- sdp: string;
346
- type: "offer";
347
- }): Promise<{
348
- sdp: string;
349
- type: "answer";
350
- }>;
351
- /** Add ICE candidate. */
352
- addIceCandidate(candidate: any): Promise<void>;
353
- /**
354
- * Detach the frame source (for connection pooling).
355
- * The session stays alive (ICE/DTLS connected) but stops feeding frames.
356
- * Call replaceSource() later to reattach a camera.
357
- */
358
- detachSource(): void;
359
- /** Whether the session has an active feed (vs idle/pooled). */
360
- get isFeeding(): boolean;
361
- /**
362
- * Replace the frame source (for seamless source switching).
363
- * The new source will take effect at the next keyframe.
364
- */
365
- replaceSource(newSource: FrameSource): void;
366
- getInfo(): SessionInfo;
367
- close(): Promise<void>;
368
- private startFeedingFrames;
369
- /** Build a serialized RTP packet for sender.sendRtp(). */
370
- private buildRtpBuffer;
371
- /** Max RTP payload size (MTU 1200 to stay under typical network MTU). */
372
- private static readonly MAX_RTP_PAYLOAD;
373
- private rtpPacketsSent;
374
- private writeVideoNals;
375
- private writeAudio;
376
- private startStatsCollection;
377
- private collectStats;
378
- }
379
-
380
- /**
381
- * Adaptive WebRTC streaming server — multi-camera manager with quality adaptation.
382
- *
383
- * For each camera:
384
- * - Spawns AdaptiveFfmpegSource to transcode RTSP → H.264 with configurable params
385
- * - Uses StreamFanout to distribute frames to multiple viewer sessions
386
- * - AdaptiveController monitors stats and adjusts quality per-camera
387
- *
388
- * Usage:
389
- * const server = new AdaptiveStreamServer({ ... });
390
- * server.addCamera("front_door", { rtspUrl: "rtsp://...", profiles: [...] });
391
- * const { sessionId, answer } = await server.handleWhepOffer("front_door", sdpOffer);
392
- */
393
-
394
- interface AdaptiveStreamServerOptions {
395
- /** FFmpeg binary path (default: "ffmpeg"). */
396
- ffmpegPath?: string;
397
- /** STUN servers for ICE. */
398
- stunServers?: string[];
399
- /** TURN servers for ICE. */
400
- turnServers?: Array<{
401
- urls: string;
402
- username?: string;
403
- credential?: string;
404
- }>;
405
- /** ICE port range. */
406
- icePortRange?: [number, number];
407
- /** Additional host IPs to announce. */
408
- iceAdditionalHostAddresses?: string[];
409
- /** Logger. */
410
- logger?: Logger;
411
- }
412
- interface CameraConfig {
413
- /** Primary RTSP source URL (typically main stream). */
414
- rtspUrl: string;
415
- /** Secondary RTSP source URL (typically sub stream, for Level 3 switching). */
416
- subRtspUrl?: string;
417
- /** Quality profiles ordered from highest to lowest quality. */
418
- profiles: QualityProfile[];
419
- /** Audio mode: "copy" (G.711 passthrough), "opus" (transcode), "off" (no audio). Default: "copy". */
420
- audioMode?: "copy" | "opus" | "off";
421
- }
422
- /** Default quality profiles for adaptive streaming. */
423
- declare function createDefaultProfiles(): QualityProfile[];
424
- declare class AdaptiveStreamServer extends EventEmitter {
425
- private readonly ffmpegPath;
426
- private readonly stunServers;
427
- private readonly turnServers;
428
- private readonly icePortRange;
429
- private readonly iceAdditionalHostAddresses;
430
- private readonly logger;
431
- private readonly cameras;
432
- private readonly sessionCamera;
433
- private stopped;
434
- constructor(options?: AdaptiveStreamServerOptions);
435
- /** Register a camera with adaptive streaming. */
436
- addCamera(name: string, config: CameraConfig): void;
437
- /** Remove a camera and close all its sessions. */
438
- removeCamera(name: string): Promise<void>;
439
- getCameraNames(): string[];
440
- /**
441
- * Create an adaptive session for a camera.
442
- * Returns a server-generated SDP offer that the client must answer.
443
- *
444
- * Flow: createSession() → server offer → client sets remote, creates answer → handleAnswer()
445
- */
446
- createSession(cameraName: string): Promise<{
447
- sessionId: string;
448
- sdpOffer: string;
449
- }>;
450
- /**
451
- * Handle the client's SDP answer for an adaptive session.
452
- * Call after createSession() with the client's answer.
453
- */
454
- handleAnswer(sessionId: string, sdpAnswer: string): Promise<void>;
455
- /**
456
- * Convenience: handleWhepOffer is NOT supported — werift requires server-initiated offers.
457
- * Use createSession() + handleAnswer() instead.
458
- */
459
- handleWhepOffer(_cameraName: string, _sdpOffer: string): Promise<{
460
- sessionId: string;
461
- sdpAnswer: string;
462
- }>;
463
- /** Pooled sessions: sessionId → true (idle, no camera attached). */
464
- private readonly pooledSessions;
465
- /**
466
- * Create a pooled session (no camera attached yet).
467
- * The SDP exchange happens, ICE connects, but no ffmpeg is started.
468
- * Call attachCamera() later to start feeding frames.
469
- */
470
- createPooledSession(): Promise<{
471
- sessionId: string;
472
- sdpOffer: string;
473
- }>;
474
- /**
475
- * Attach a camera to a pooled session.
476
- * Starts the ffmpeg transcoder and begins feeding frames.
477
- */
478
- attachCamera(sessionId: string, cameraName: string): Promise<void>;
479
- /**
480
- * Detach a camera from a session (session returns to pool).
481
- */
482
- detachCamera(sessionId: string): Promise<void>;
483
- /** Check if a session is in the idle pool. */
484
- isPooledSession(sessionId: string): boolean;
485
- /** Set debug flag on all sessions for a camera. */
486
- setDebug(cameraName: string, debug: boolean): number;
487
- /** Get count of idle pooled sessions. */
488
- getPoolSize(): number;
489
- /** Close a specific session. */
490
- closeSession(sessionId: string): Promise<void>;
491
- /**
492
- * Report client-side stats for a session (supplements RTCP monitoring).
493
- * Call from tRPC route when the client pushes stats.
494
- */
495
- reportClientStats(sessionId: string, stats: StreamStats): {
496
- currentTier: QualityTier;
497
- currentBitrateKbps: number;
498
- currentResolution: {
499
- width: number;
500
- height: number;
501
- };
502
- sourceProfile: "main" | "sub";
503
- } | null;
504
- /** Force quality for a camera (null = auto). */
505
- forceQuality(cameraName: string, tier: QualityTier | null): boolean;
506
- /** Get current quality info for a camera. */
507
- getCameraQuality(cameraName: string): {
508
- tier: QualityTier;
509
- encoding: EncodingParams;
510
- isAuto: boolean;
511
- stats: {
512
- packetLoss: number;
513
- jitterMs: number;
514
- rttMs: number;
515
- };
516
- sessionCount: number;
517
- sourceProfile: "main" | "sub";
518
- } | null;
519
- /** Get all sessions. */
520
- getSessions(cameraName?: string): SessionInfo[];
521
- getSessionCount(cameraName?: string): number;
522
- /** Stop all cameras and sessions. */
523
- stop(): Promise<void>;
524
- /** Get the currently active fanout for a camera. */
525
- private getActiveFanout;
526
- private ensureCameraRunning;
527
- private scheduleCameraAutoStop;
528
- /**
529
- * Handle a quality change from the AdaptiveController.
530
- * When the sourceProfile changes (main ↔ sub), performs a seamless source
531
- * switch for all active sessions. When only encoding params change (same
532
- * sourceProfile), updates ffmpeg params in-place.
533
- */
534
- private handleQualityChange;
535
- /**
536
- * Switch all active sessions from one source to another (main ↔ sub).
537
- *
538
- * Steps:
539
- * 1. Create/start the target ffmpeg source + fanout
540
- * 2. For each session: subscribe to new fanout, call replaceSource()
541
- * 3. Unsubscribe all from old fanout
542
- * 4. Stop old ffmpeg + fanout (save resources)
543
- * 5. Update activeSourceProfile
544
- */
545
- private switchSource;
546
- }
547
-
548
- declare class WebrtcAdaptiveAddon implements ICamstackAddon, IConfigurable {
549
- readonly manifest: AddonManifest;
550
- private server;
551
- private currentConfig;
552
- initialize(context: AddonContext): Promise<void>;
553
- shutdown(): Promise<void>;
554
- getCapabilityProvider<K extends keyof CapabilityProviderMap>(name: K): CapabilityProviderMap[K] | null;
555
- getConfigSchema(): ConfigUISchema;
556
- getConfig(): Record<string, unknown>;
557
- onConfigChange(config: Record<string, unknown>): Promise<void>;
558
- getServer(): AdaptiveStreamServer | null;
559
- }
560
-
561
- export { type AudioFrame as A, type CameraConfig as C, type EncodingParams as E, type FrameSource as F, type Logger as L, type MediaFrame as M, type QualityProfile as Q, type SessionInfo as S, type VideoCodec as V, WebrtcAdaptiveAddon as W, type VideoFrame as a, AdaptiveController as b, type AdaptiveControllerOptions as c, AdaptiveFfmpegSource as d, type AdaptiveFfmpegSourceOptions as e, AdaptiveSession as f, type AdaptiveSessionOptions as g, AdaptiveStreamServer as h, type AdaptiveStreamServerOptions as i, type AudioCodec as j, type AudioMode as k, type FrameSourceFactory as l, type LoggerLike as m, type QualityTier as n, type SessionStats as o, type StreamStats as p, asLogger as q, createDefaultProfiles as r, createNullLogger as s };