node-av 6.1.1 → 6.2.0-beta.10

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.
Files changed (94) hide show
  1. package/README.md +29 -0
  2. package/dist/api/audio-frame-buffer.d.ts +73 -1
  3. package/dist/api/audio-frame-buffer.js +127 -1
  4. package/dist/api/audio-frame-buffer.js.map +1 -1
  5. package/dist/api/bitstream-filter.d.ts +82 -4
  6. package/dist/api/bitstream-filter.js +197 -100
  7. package/dist/api/bitstream-filter.js.map +1 -1
  8. package/dist/api/decoder.d.ts +5 -0
  9. package/dist/api/decoder.js +12 -5
  10. package/dist/api/decoder.js.map +1 -1
  11. package/dist/api/demuxer.d.ts +10 -1
  12. package/dist/api/demuxer.js +114 -31
  13. package/dist/api/demuxer.js.map +1 -1
  14. package/dist/api/encoder-pool.d.ts +43 -1
  15. package/dist/api/encoder-pool.js +113 -9
  16. package/dist/api/encoder-pool.js.map +1 -1
  17. package/dist/api/encoder.d.ts +2 -0
  18. package/dist/api/encoder.js +105 -41
  19. package/dist/api/encoder.js.map +1 -1
  20. package/dist/api/filter-complex.d.ts +16 -10
  21. package/dist/api/filter-complex.js +129 -81
  22. package/dist/api/filter-complex.js.map +1 -1
  23. package/dist/api/filter-presets.d.ts +15 -1
  24. package/dist/api/filter-presets.js +23 -5
  25. package/dist/api/filter-presets.js.map +1 -1
  26. package/dist/api/filter.js +15 -6
  27. package/dist/api/filter.js.map +1 -1
  28. package/dist/api/fmp4-stream.d.ts +211 -4
  29. package/dist/api/fmp4-stream.js +550 -84
  30. package/dist/api/fmp4-stream.js.map +1 -1
  31. package/dist/api/hardware.js +4 -3
  32. package/dist/api/hardware.js.map +1 -1
  33. package/dist/api/index.d.ts +1 -0
  34. package/dist/api/index.js.map +1 -1
  35. package/dist/api/io-stream.js +61 -5
  36. package/dist/api/io-stream.js.map +1 -1
  37. package/dist/api/muxer.d.ts +13 -0
  38. package/dist/api/muxer.js +82 -19
  39. package/dist/api/muxer.js.map +1 -1
  40. package/dist/api/pipeline.d.ts +122 -0
  41. package/dist/api/pipeline.js +25 -7
  42. package/dist/api/pipeline.js.map +1 -1
  43. package/dist/api/rtp-stream.d.ts +146 -1
  44. package/dist/api/rtp-stream.js +533 -125
  45. package/dist/api/rtp-stream.js.map +1 -1
  46. package/dist/api/scaler.d.ts +102 -16
  47. package/dist/api/scaler.js +316 -106
  48. package/dist/api/scaler.js.map +1 -1
  49. package/dist/api/types.d.ts +17 -0
  50. package/dist/api/types.js +2 -0
  51. package/dist/api/types.js.map +1 -0
  52. package/dist/api/utilities/async-queue.d.ts +3 -0
  53. package/dist/api/utilities/async-queue.js +8 -0
  54. package/dist/api/utilities/async-queue.js.map +1 -1
  55. package/dist/api/webrtc-stream.d.ts +17 -10
  56. package/dist/api/webrtc-stream.js +117 -24
  57. package/dist/api/webrtc-stream.js.map +1 -1
  58. package/dist/api/whisper.d.ts +2 -0
  59. package/dist/api/whisper.js +63 -23
  60. package/dist/api/whisper.js.map +1 -1
  61. package/dist/ffmpeg/install.js +115 -29
  62. package/dist/ffmpeg/install.js.map +1 -1
  63. package/dist/lib/binding.d.ts +1 -0
  64. package/dist/lib/binding.js.map +1 -1
  65. package/dist/lib/codec-context.d.ts +0 -2
  66. package/dist/lib/codec-context.js +4 -22
  67. package/dist/lib/codec-context.js.map +1 -1
  68. package/dist/lib/filter-context.d.ts +0 -1
  69. package/dist/lib/filter-context.js +2 -11
  70. package/dist/lib/filter-context.js.map +1 -1
  71. package/dist/lib/format-context.d.ts +45 -4
  72. package/dist/lib/format-context.js +50 -16
  73. package/dist/lib/format-context.js.map +1 -1
  74. package/dist/lib/frame.d.ts +12 -12
  75. package/dist/lib/frame.js +13 -20
  76. package/dist/lib/frame.js.map +1 -1
  77. package/dist/lib/hardware-frames-context.d.ts +0 -1
  78. package/dist/lib/hardware-frames-context.js +2 -8
  79. package/dist/lib/hardware-frames-context.js.map +1 -1
  80. package/dist/lib/native-types.d.ts +2 -2
  81. package/dist/lib/option.d.ts +26 -0
  82. package/dist/lib/option.js +42 -18
  83. package/dist/lib/option.js.map +1 -1
  84. package/dist/lib/packet.d.ts +7 -1
  85. package/dist/lib/packet.js +9 -2
  86. package/dist/lib/packet.js.map +1 -1
  87. package/dist/lib/stream.d.ts +3 -4
  88. package/dist/lib/stream.js +17 -24
  89. package/dist/lib/stream.js.map +1 -1
  90. package/dist/webrtc/index.d.ts +1 -0
  91. package/dist/webrtc/index.js.map +1 -1
  92. package/install/check.js +26 -63
  93. package/package.json +36 -19
  94. package/build_mac_local.sh +0 -69
@@ -1,10 +1,10 @@
1
1
  import { isRtcp, RtpPacket } from 'werift';
2
- import { AV_HWDEVICE_TYPE_NONE } from '../constants/constants.js';
2
+ import { AV_HWDEVICE_TYPE_NONE, AV_PICTURE_TYPE_I, AV_PIX_FMT_YUV420P, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
3
3
  import { FF_ENCODER_LIBOPUS, FF_ENCODER_LIBX264, FF_ENCODER_LIBX265 } from '../constants/encoders.js';
4
4
  import { Codec } from '../lib/codec.js';
5
- import { avChannelLayoutDefault } from '../lib/utilities.js';
5
+ import { Rational } from '../lib/rational.js';
6
+ import { avChannelLayoutDefault, avGetPixFmtName } from '../lib/utilities.js';
6
7
  import { MAX_PACKET_SIZE } from './constants.js';
7
- import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
8
8
  import { Decoder } from './decoder.js';
9
9
  import { Demuxer } from './demuxer.js';
10
10
  import { Encoder } from './encoder.js';
@@ -13,6 +13,49 @@ import { FilterAPI } from './filter.js';
13
13
  import { HardwareContext } from './hardware.js';
14
14
  import { Muxer } from './muxer.js';
15
15
  import { pipeline } from './pipeline.js';
16
+ import { pickSupportedLayout, pickSupportedPixelFormat, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
17
+ /**
18
+ * Check whether a streaming-class input is a frame source (not a URL or Demuxer).
19
+ *
20
+ * @param input - The input passed to `create()`
21
+ *
22
+ * @returns True if `input` is a {@link MediaFrameSource}
23
+ *
24
+ * @internal
25
+ */
26
+ export function isMediaFrameSource(input) {
27
+ return typeof input !== 'string' && !(input instanceof Demuxer);
28
+ }
29
+ /**
30
+ * Combine one or two pipeline controls behind a single {@link PipelineControl}.
31
+ *
32
+ * The frame-source path runs separate video/audio pipelines; this presents them
33
+ * as one control so `stop()`/`completion` work uniformly.
34
+ *
35
+ * @internal
36
+ */
37
+ class CombinedPipelineControl {
38
+ controls;
39
+ constructor(controls) {
40
+ this.controls = controls;
41
+ }
42
+ stop() {
43
+ for (const control of this.controls) {
44
+ control.stop();
45
+ }
46
+ }
47
+ isStopped() {
48
+ return this.controls.every((control) => control.isStopped());
49
+ }
50
+ get completion() {
51
+ return Promise.all(this.controls.map((control) => control.completion)).then(() => undefined);
52
+ }
53
+ get progress() {
54
+ // Delegate to the first control (video when present) - a merged view is not
55
+ // meaningful across independently-clocked streams.
56
+ return this.controls[0]?.progress ?? { frames: 0, bytes: 0, time: 0, fps: 0, bitrate: 0, speed: 0, elapsed: 0 };
57
+ }
58
+ }
16
59
  /**
17
60
  * Generic RTP streaming with automatic codec detection and transcoding.
18
61
  *
@@ -50,6 +93,7 @@ export class RTPStream {
50
93
  inputUrl;
51
94
  inputOptions;
52
95
  input;
96
+ source;
53
97
  videoOutput;
54
98
  audioOutput;
55
99
  hardwareContext;
@@ -60,9 +104,15 @@ export class RTPStream {
60
104
  audioFilter;
61
105
  audioEncoder;
62
106
  pipeline;
107
+ startPromise;
63
108
  signal;
64
109
  supportedVideoCodecs;
65
110
  supportedAudioCodecs;
111
+ videoKeyframeRequested = false;
112
+ abortHandler;
113
+ stopRequested = false;
114
+ stopPromise;
115
+ startAbort;
66
116
  /**
67
117
  * @param input - Media input URL or pre-opened Demuxer
68
118
  *
@@ -76,10 +126,14 @@ export class RTPStream {
76
126
  if (typeof input === 'string') {
77
127
  this.inputUrl = input;
78
128
  }
79
- else {
129
+ else if (input instanceof Demuxer) {
80
130
  this.inputUrl = null;
81
131
  this.input = input;
82
132
  }
133
+ else {
134
+ this.inputUrl = null;
135
+ this.source = input;
136
+ }
83
137
  const inputUrl = this.inputUrl ?? '';
84
138
  this.inputOptions = {
85
139
  ...options.inputOptions,
@@ -111,9 +165,10 @@ export class RTPStream {
111
165
  ssrc: options.video?.ssrc,
112
166
  payloadType: options.video?.payloadType,
113
167
  mtu: options.video?.mtu ?? MAX_PACKET_SIZE,
114
- fps: options.video?.fps ?? 20,
168
+ fps: options.video?.fps,
115
169
  width: options.video?.width,
116
170
  height: options.video?.height,
171
+ bitrate: options.video?.bitrate,
117
172
  encoderOptions: options.video?.encoderOptions ?? {},
118
173
  },
119
174
  audio: {
@@ -199,6 +254,47 @@ export class RTPStream {
199
254
  getInput() {
200
255
  return this.input;
201
256
  }
257
+ /**
258
+ * Request that the next encoded video frame is a keyframe.
259
+ *
260
+ * Used to answer receiver feedback (e.g. a WebRTC PLI) so a receiver that
261
+ * missed the stream start or lost packets can resync without waiting for the
262
+ * next scheduled GOP keyframe. Currently effective for frame-source inputs
263
+ * ({@link MediaFrameSource}); for demuxer inputs the encoder's regular GOP
264
+ * cadence applies.
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * transceiver.sender.onPictureLossIndication.subscribe(() => {
269
+ * stream.requestVideoKeyframe();
270
+ * });
271
+ * ```
272
+ */
273
+ requestVideoKeyframe() {
274
+ this.videoKeyframeRequested = true;
275
+ }
276
+ /**
277
+ * Pass video frames through, forcing a keyframe when one was requested.
278
+ *
279
+ * Sets `pictType = I` on the next frame after {@link requestVideoKeyframe};
280
+ * combined with the encoder's `forced-idr` option this produces an IDR the
281
+ * receiver can resync on.
282
+ *
283
+ * @param src - The user-provided video frame source
284
+ *
285
+ * @yields {Frame | null} The unchanged frames (with pictType forced when requested)
286
+ *
287
+ * @internal
288
+ */
289
+ async *wrapVideoSource(src) {
290
+ for await (const frame of src) {
291
+ if (frame && this.videoKeyframeRequested) {
292
+ this.videoKeyframeRequested = false;
293
+ frame.pictType = AV_PICTURE_TYPE_I;
294
+ }
295
+ yield frame;
296
+ }
297
+ }
202
298
  /**
203
299
  * Start streaming media to RTP packets.
204
300
  *
@@ -227,17 +323,55 @@ export class RTPStream {
227
323
  * ```
228
324
  */
229
325
  async start() {
326
+ // Idempotent: concurrent and repeated calls share a single startup (e.g.
327
+ // an explicit start() racing WebRTCStream.setOffer()'s automatic one).
328
+ this.startPromise ??= this.doStart().catch((error) => {
329
+ this.startPromise = undefined;
330
+ throw error;
331
+ });
332
+ await this.startPromise;
333
+ }
334
+ /**
335
+ * Perform the actual startup (see {@link start}).
336
+ *
337
+ * @internal
338
+ */
339
+ async doStart() {
340
+ // A start racing an in-flight stop() waits for the teardown to finish,
341
+ // otherwise it would build the pipeline on top of freed resources.
342
+ if (this.stopPromise) {
343
+ await this.stopPromise;
344
+ }
230
345
  if (this.pipeline) {
231
346
  return;
232
347
  }
233
348
  this.signal?.throwIfAborted();
234
- this.signal?.addEventListener('abort', () => this.stop(), { once: true });
349
+ if (this.signal && !this.abortHandler) {
350
+ // Stop on abort. Keep a reference so stop() can detach the listener again,
351
+ // otherwise restarts would stack one listener per start(). Errors from the
352
+ // async stop() are swallowed here; they already surface via onClose(error).
353
+ this.abortHandler = () => {
354
+ this.abortHandler = undefined;
355
+ this.stop().catch(() => { });
356
+ };
357
+ this.signal.addEventListener('abort', this.abortHandler, { once: true });
358
+ }
359
+ // Lets stop() interrupt a startup that is blocked in the input open
360
+ // (an RTSP connect can take seconds).
361
+ this.startAbort = new AbortController();
362
+ // Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
363
+ if (this.source) {
364
+ await this.startFromFrames();
365
+ return;
366
+ }
235
367
  if (!this.input) {
236
368
  if (!this.inputUrl) {
237
369
  throw new Error('No input URL or Demuxer provided');
238
370
  }
239
- this.input = await Demuxer.open(this.inputUrl, this.inputOptions);
371
+ const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
372
+ this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
240
373
  }
374
+ this.throwIfStopRequested();
241
375
  const videoStream = this.input.video();
242
376
  const audioStream = this.input.audio();
243
377
  // Setup video transcoding if needed
@@ -260,6 +394,21 @@ export class RTPStream {
260
394
  const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
261
395
  (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
262
396
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
397
+ // Get first supported codec
398
+ const targetCodecId = this.options.supportedVideoCodecs[0];
399
+ if (!targetCodecId) {
400
+ throw new Error('No supported video codec specified for transcoding');
401
+ }
402
+ let encoderCodec = null;
403
+ if (typeof targetCodecId === 'string') {
404
+ encoderCodec = Codec.findEncoderByName(targetCodecId);
405
+ }
406
+ else {
407
+ encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
408
+ }
409
+ if (!encoderCodec) {
410
+ throw new Error(`No encoder found for codec ID ${targetCodecId}`);
411
+ }
263
412
  // Create filter chain only if needed
264
413
  if (needsScale || needsFps) {
265
414
  const filterChain = FilterPreset.chain(this.hardwareContext);
@@ -273,25 +422,26 @@ export class RTPStream {
273
422
  if (needsFps) {
274
423
  filterChain.filter('fps', { fps: this.options.video.fps });
275
424
  }
425
+ // Software scaling leaves the graph output format unconstrained, so swscale keeps the
426
+ // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
427
+ // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
428
+ // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
429
+ // one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
430
+ // profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
431
+ // codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
432
+ // negotiate their format through the frames context instead.
433
+ if (needsScale && !this.hardwareContext) {
434
+ const formats = encoderCodec.pixelFormats;
435
+ const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
436
+ const targetFmtName = avGetPixFmtName(targetFmt);
437
+ if (targetFmtName) {
438
+ filterChain.filter('format', { pix_fmts: targetFmtName });
439
+ }
440
+ }
276
441
  this.videoFilter = FilterAPI.create(filterChain.build(), {
277
442
  hardware: this.hardwareContext,
278
443
  });
279
444
  }
280
- // Get first supported codec
281
- const targetCodecId = this.options.supportedVideoCodecs[0];
282
- if (!targetCodecId) {
283
- throw new Error('No supported video codec specified for transcoding');
284
- }
285
- let encoderCodec = null;
286
- if (typeof targetCodecId === 'string') {
287
- encoderCodec = Codec.findEncoderByName(targetCodecId);
288
- }
289
- else {
290
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
291
- }
292
- if (!encoderCodec) {
293
- throw new Error(`No encoder found for codec ID ${targetCodecId}`);
294
- }
295
445
  let encoderOptions = {};
296
446
  if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
297
447
  encoderOptions.preset = 'ultrafast';
@@ -301,60 +451,20 @@ export class RTPStream {
301
451
  ...encoderOptions,
302
452
  ...this.options.video.encoderOptions,
303
453
  };
454
+ const bitrate = this.options.video.bitrate;
304
455
  this.videoEncoder = await Encoder.create(encoderCodec, {
305
456
  decoder: this.videoDecoder,
306
457
  maxBFrames: 0,
307
458
  options: encoderOptions,
459
+ configure: (ctx) => {
460
+ this.applyBitrate(ctx, bitrate);
461
+ },
308
462
  });
309
- }
310
- // Initialize RTP sequence numbers and timestamps
311
- let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
312
- let videoTimestamp = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
313
- let audioSequenceNumber = Math.floor(Math.random() * 0xffff);
314
- // Calculate video timestamp increment
315
- const videoStreamFps = videoStream ? videoStream.avgFrameRate.num / videoStream.avgFrameRate.den : 20;
316
- let fps = this.options.video.fps ?? videoStreamFps;
317
- if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
318
- fps = 20; // Default to 20 FPS if invalid
319
- }
320
- const videoTimestampIncrement = 90000 / fps;
321
- // Build RTP muxer options for video
322
- const videoMuxerOptions = {};
323
- if (this.options.video.ssrc !== undefined) {
324
- videoMuxerOptions.ssrc = this.options.video.ssrc;
325
- }
326
- if (this.options.video.payloadType !== undefined) {
327
- videoMuxerOptions.payload_type = this.options.video.payloadType;
463
+ this.throwIfStopRequested();
328
464
  }
329
465
  // Setup video output
330
- this.videoOutput = await Muxer.open({
331
- write: (buffer) => {
332
- if (isRtcp(buffer)) {
333
- // Ignore RTCP packets
334
- return buffer.length;
335
- }
336
- const rtpPacket = RtpPacket.deSerialize(buffer);
337
- // Fix sequence number - ensure continuous sequence
338
- rtpPacket.header.sequenceNumber = videoSequenceNumber;
339
- videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
340
- // Fix timestamp - calculate based on FPS
341
- // All packets in same frame have same timestamp (marker=false)
342
- // Only increment timestamp when frame ends (marker=true)
343
- rtpPacket.header.timestamp = videoTimestamp;
344
- // Increment timestamp for next frame when current frame ends
345
- if (rtpPacket.header.marker) {
346
- videoTimestamp = (videoTimestamp + videoTimestampIncrement) >>> 0; // Unsigned 32-bit wrap
347
- }
348
- this.options.onVideoPacket(rtpPacket);
349
- return buffer.length;
350
- },
351
- }, {
352
- input: this.input,
353
- copyInitialNonkeyframes: true,
354
- format: 'rtp',
355
- maxPacketSize: this.options.video.mtu,
356
- options: videoMuxerOptions,
357
- });
466
+ this.videoOutput = await this.createVideoOutput();
467
+ this.throwIfStopRequested();
358
468
  // Setup audio if available and needs transcoding
359
469
  if (audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId)) {
360
470
  this.audioDecoder = await Decoder.create(audioStream, {
@@ -401,50 +511,298 @@ export class RTPStream {
401
511
  filter: this.audioFilter,
402
512
  options: encoderOptions,
403
513
  });
514
+ this.throwIfStopRequested();
404
515
  }
405
516
  // Setup audio output if available
406
517
  if (audioStream) {
407
- // Build RTP muxer options for audio
408
- const audioMuxerOptions = {};
409
- if (this.options.audio.ssrc !== undefined) {
410
- audioMuxerOptions.ssrc = this.options.audio.ssrc;
411
- }
412
- if (this.options.audio.payloadType !== undefined) {
413
- audioMuxerOptions.payload_type = this.options.audio.payloadType;
414
- }
415
- this.audioOutput = await Muxer.open({
416
- write: (buffer) => {
417
- if (isRtcp(buffer)) {
418
- // Ignore RTCP packets
419
- return buffer.length;
420
- }
421
- const rtpPacket = RtpPacket.deSerialize(buffer);
422
- // Fix sequence number - ensure continuous sequence
423
- rtpPacket.header.sequenceNumber = audioSequenceNumber;
424
- audioSequenceNumber = (audioSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
425
- this.options.onAudioPacket(rtpPacket);
426
- return buffer.length;
427
- },
428
- }, {
429
- input: this.input,
430
- copyInitialNonkeyframes: true,
431
- format: 'rtp',
432
- maxPacketSize: this.options.audio.mtu,
433
- options: audioMuxerOptions,
434
- });
518
+ this.audioOutput = await this.createAudioOutput();
519
+ this.throwIfStopRequested();
435
520
  }
436
521
  // Start pipeline in background (don't await)
437
- this.runPipeline()
522
+ this.attachCompletion(this.runPipeline());
523
+ }
524
+ /**
525
+ * Abort an in-flight startup once stop() was requested.
526
+ *
527
+ * Checked after every await in the startup path: the resources created so
528
+ * far are all assigned to instance fields, so the stop() that is waiting on
529
+ * this startup tears them down the moment it unwinds.
530
+ *
531
+ * @throws {Error} If stop() was called while starting
532
+ *
533
+ * @internal
534
+ */
535
+ throwIfStopRequested() {
536
+ if (this.stopRequested) {
537
+ throw new Error('RTPStream stopped during start');
538
+ }
539
+ }
540
+ /**
541
+ * Cap the video bitrate on the encoder context.
542
+ *
543
+ * Sets the target bitrate together with a VBV cap (maxrate + a 1s buffer).
544
+ * Encoder.create() always seeds a default target bitrate (1 Mbps for video),
545
+ * so the target MUST be lowered along with the cap.
546
+ *
547
+ * @param ctx - Encoder codec context
548
+ *
549
+ * @param bitrate - Maximum bitrate in bits per second, or undefined
550
+ *
551
+ * @internal
552
+ */
553
+ applyBitrate(ctx, bitrate) {
554
+ if (!bitrate || bitrate <= 0) {
555
+ return;
556
+ }
557
+ const rate = Math.round(bitrate);
558
+ ctx.bitRate = BigInt(rate);
559
+ ctx.rcMaxRate = BigInt(rate);
560
+ ctx.rcBufferSize = rate;
561
+ }
562
+ /**
563
+ * Wire the pipeline completion to the onClose/error callbacks.
564
+ *
565
+ * On success invokes `onClose()`; on error stops the stream and invokes
566
+ * `onClose(error)`. Runs in the background (not awaited).
567
+ *
568
+ * @param completion - The pipeline completion promise
569
+ *
570
+ * @internal
571
+ */
572
+ attachCompletion(completion) {
573
+ completion
438
574
  .then(() => {
439
575
  // Pipeline completed successfully
440
576
  this.options.onClose?.();
441
577
  })
442
578
  .catch(async (error) => {
579
+ // A stop() in flight or an aborted signal means the owner is tearing
580
+ // the stream down - the pipeline unwinding with an AbortError (or an
581
+ // interrupted FFmpeg call) is the expected shutdown path, not a failure.
582
+ if (this.isDeliberateStop(error)) {
583
+ await this.stop().catch(() => { });
584
+ this.options.onClose?.();
585
+ return;
586
+ }
443
587
  console.error('[RTPStream] Pipeline error:', error);
444
588
  await this.stop();
445
589
  this.options.onClose?.(error);
446
590
  });
447
591
  }
592
+ /**
593
+ * Whether a pipeline rejection was caused by an intentional shutdown.
594
+ *
595
+ * @param error - The pipeline rejection reason
596
+ *
597
+ * @returns True when stop() is in flight or the owner's signal aborted
598
+ *
599
+ * @internal
600
+ */
601
+ isDeliberateStop(error) {
602
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
603
+ }
604
+ /**
605
+ * Set up encoders and RTP outputs for a pre-composited frame source and start
606
+ * encoding. No demuxer or decoder is involved - the provided frames are encoded
607
+ * straight to RTP. Video and audio (whichever are present) run as separate
608
+ * pipelines combined behind a single control.
609
+ *
610
+ * @throws {Error} If the source has neither video nor audio frames
611
+ *
612
+ * @internal
613
+ */
614
+ async startFromFrames() {
615
+ const source = this.source;
616
+ if (!source.video && !source.audio) {
617
+ throw new Error('Frame source must provide video and/or audio frames');
618
+ }
619
+ const controls = [];
620
+ const opts = this.signal ? { signal: this.signal } : undefined;
621
+ // Video
622
+ if (source.video) {
623
+ if (this.options.hardware === 'auto') {
624
+ this.hardwareContext = HardwareContext.auto();
625
+ }
626
+ else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
627
+ this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
628
+ }
629
+ const targetCodecId = this.options.supportedVideoCodecs[0] ?? FF_ENCODER_LIBX264;
630
+ let encoderCodec = null;
631
+ if (typeof targetCodecId === 'string') {
632
+ encoderCodec = Codec.findEncoderByName(targetCodecId);
633
+ }
634
+ else {
635
+ encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
636
+ }
637
+ if (!encoderCodec) {
638
+ throw new Error(`No encoder found for video codec ${String(targetCodecId)}`);
639
+ }
640
+ let encoderOptions = {};
641
+ if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
642
+ encoderOptions.preset = 'ultrafast';
643
+ encoderOptions.tune = 'zerolatency';
644
+ // Make a requested keyframe (frame.pictType = I) an actual IDR, so a
645
+ // receiver that missed the stream start can resync (PLI handling).
646
+ encoderOptions['forced-idr'] = true;
647
+ }
648
+ encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
649
+ let fps = this.options.video.fps ?? 20;
650
+ if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
651
+ fps = 20;
652
+ }
653
+ // No decoder/filter: the encoder adopts width/height/pixelFormat/timeBase
654
+ // from the first composited frame. Set the framerate explicitly, otherwise
655
+ // the encoder infers it from the frame timebase (often a fine-grained tick
656
+ // like 1/12800), which makes x264 pick an absurd level (huge MB rate) that
657
+ // browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
658
+ // that join (or lose) mid-stream must get a keyframe quickly - the x264
659
+ // default of 250 frames leaves them waiting for many seconds.
660
+ const bitrate = this.options.video.bitrate;
661
+ this.videoEncoder = await Encoder.create(encoderCodec, {
662
+ maxBFrames: 0,
663
+ options: encoderOptions,
664
+ configure: (ctx) => {
665
+ ctx.framerate = new Rational(Math.round(fps), 1);
666
+ ctx.gopSize = Math.max(1, Math.round(fps * 2));
667
+ this.applyBitrate(ctx, bitrate);
668
+ },
669
+ });
670
+ this.videoOutput = await this.createVideoOutput();
671
+ this.throwIfStopRequested();
672
+ controls.push(pipeline(this.wrapVideoSource(source.video), this.videoEncoder, this.videoOutput, opts));
673
+ }
674
+ // Audio
675
+ if (source.audio) {
676
+ const targetCodecId = this.options.supportedAudioCodecs[0] ?? FF_ENCODER_LIBOPUS;
677
+ let encoderCodec = null;
678
+ if (typeof targetCodecId === 'string') {
679
+ encoderCodec = Codec.findEncoderByName(targetCodecId);
680
+ }
681
+ else {
682
+ encoderCodec = Codec.findEncoder(targetCodecId);
683
+ }
684
+ if (!encoderCodec) {
685
+ throw new Error(`No encoder found for audio codec ${String(targetCodecId)}`);
686
+ }
687
+ // There is no input stream to derive formats from - resample to the desired
688
+ // (or codec-preferred) sample format/rate/layout via an aformat filter.
689
+ const desiredSampleFormat = this.options.audio?.sampleFormat ?? AV_SAMPLE_FMT_FLTP;
690
+ const desiredSampleRate = this.options.audio?.sampleRate ?? 48000;
691
+ const desiredChannels = this.options.audio?.channels ?? 2;
692
+ const targetSampleFormat = this.selectSampleFormat(encoderCodec, desiredSampleFormat);
693
+ const targetSampleRate = this.selectSampleRate(encoderCodec, desiredSampleRate);
694
+ const channelLayoutStr = this.selectChannelLayout(encoderCodec, desiredChannels);
695
+ this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(targetSampleFormat, targetSampleRate, channelLayoutStr).build());
696
+ let encoderOptions = {};
697
+ if (encoderCodec.name === FF_ENCODER_LIBOPUS) {
698
+ encoderOptions.application = 'lowdelay';
699
+ encoderOptions.frame_duration = 20;
700
+ }
701
+ encoderOptions = { ...encoderOptions, ...this.options.audio.encoderOptions };
702
+ this.audioEncoder = await Encoder.create(encoderCodec, {
703
+ filter: this.audioFilter,
704
+ options: encoderOptions,
705
+ });
706
+ this.audioOutput = await this.createAudioOutput();
707
+ this.throwIfStopRequested();
708
+ controls.push(pipeline(source.audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
709
+ }
710
+ this.pipeline = new CombinedPipelineControl(controls);
711
+ this.attachCompletion(this.pipeline.completion);
712
+ }
713
+ /**
714
+ * Create the RTP video output muxer.
715
+ *
716
+ * Rewrites RTP sequence numbers (continuous) and rebases the PTS-derived
717
+ * timestamps (90 kHz) onto a random initial value in the write callback, then
718
+ * forwards each packet to `onVideoPacket`. Uses `this.input` when present
719
+ * (demuxer path) and no input for the frame-source path.
720
+ *
721
+ * @returns The configured video muxer
722
+ *
723
+ * @internal
724
+ */
725
+ async createVideoOutput() {
726
+ let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
727
+ const videoTimestampBase = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
728
+ let videoTimestampOffset;
729
+ const videoMuxerOptions = {};
730
+ if (this.options.video.ssrc !== undefined) {
731
+ videoMuxerOptions.ssrc = this.options.video.ssrc;
732
+ }
733
+ if (this.options.video.payloadType !== undefined) {
734
+ videoMuxerOptions.payload_type = this.options.video.payloadType;
735
+ }
736
+ return await Muxer.open({
737
+ write: (buffer) => {
738
+ if (isRtcp(buffer)) {
739
+ // Ignore RTCP packets
740
+ return buffer.length;
741
+ }
742
+ const rtpPacket = RtpPacket.deSerialize(buffer);
743
+ // Fix sequence number - ensure continuous sequence
744
+ rtpPacket.header.sequenceNumber = videoSequenceNumber;
745
+ videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
746
+ // Rebase the timestamp onto our own random initial value. FFmpeg's RTP
747
+ // muxer derives it from the packet PTS in the 90 kHz clock (plus its own
748
+ // random base), so the deltas carry the real presentation times -
749
+ // including B-frame reordering and VFR cadence. Only the base is swapped;
750
+ // modular uint32 arithmetic keeps wrap-around intact, and packets of the
751
+ // same frame keep sharing a timestamp.
752
+ videoTimestampOffset ??= (videoTimestampBase - rtpPacket.header.timestamp) >>> 0;
753
+ rtpPacket.header.timestamp = (rtpPacket.header.timestamp + videoTimestampOffset) >>> 0;
754
+ this.options.onVideoPacket(rtpPacket);
755
+ return buffer.length;
756
+ },
757
+ }, {
758
+ input: this.input,
759
+ copyInitialNonkeyframes: true,
760
+ format: 'rtp',
761
+ maxPacketSize: this.options.video.mtu,
762
+ options: videoMuxerOptions,
763
+ });
764
+ }
765
+ /**
766
+ * Create the RTP audio output muxer.
767
+ *
768
+ * Rewrites RTP sequence numbers (continuous) in the write callback and forwards
769
+ * each packet to `onAudioPacket`. Uses `this.input` when present (demuxer path)
770
+ * and no input for the frame-source path.
771
+ *
772
+ * @returns The configured audio muxer
773
+ *
774
+ * @internal
775
+ */
776
+ async createAudioOutput() {
777
+ let audioSequenceNumber = Math.floor(Math.random() * 0xffff);
778
+ const audioMuxerOptions = {};
779
+ if (this.options.audio.ssrc !== undefined) {
780
+ audioMuxerOptions.ssrc = this.options.audio.ssrc;
781
+ }
782
+ if (this.options.audio.payloadType !== undefined) {
783
+ audioMuxerOptions.payload_type = this.options.audio.payloadType;
784
+ }
785
+ return await Muxer.open({
786
+ write: (buffer) => {
787
+ if (isRtcp(buffer)) {
788
+ // Ignore RTCP packets
789
+ return buffer.length;
790
+ }
791
+ const rtpPacket = RtpPacket.deSerialize(buffer);
792
+ // Fix sequence number - ensure continuous sequence
793
+ rtpPacket.header.sequenceNumber = audioSequenceNumber;
794
+ audioSequenceNumber = (audioSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
795
+ this.options.onAudioPacket(rtpPacket);
796
+ return buffer.length;
797
+ },
798
+ }, {
799
+ input: this.input,
800
+ copyInitialNonkeyframes: true,
801
+ format: 'rtp',
802
+ maxPacketSize: this.options.audio.mtu,
803
+ options: audioMuxerOptions,
804
+ });
805
+ }
448
806
  /**
449
807
  * Run the streaming pipeline until completion or stopped.
450
808
  *
@@ -501,33 +859,83 @@ export class RTPStream {
501
859
  * ```
502
860
  */
503
861
  async stop() {
504
- // Stop pipeline if running and wait for completion
505
- if (this.pipeline && !this.pipeline.isStopped()) {
506
- this.pipeline.stop();
507
- await this.pipeline.completion;
508
- this.pipeline = undefined;
509
- }
510
- // Close all resources
511
- await this.videoOutput?.close();
512
- this.videoOutput = undefined;
513
- await this.audioOutput?.close();
514
- this.audioOutput = undefined;
515
- this.videoEncoder?.close();
516
- this.videoEncoder = undefined;
517
- this.videoFilter?.close();
518
- this.videoFilter = undefined;
519
- this.videoDecoder?.close();
520
- this.videoDecoder = undefined;
521
- this.audioEncoder?.close();
522
- this.audioEncoder = undefined;
523
- this.audioFilter?.close();
524
- this.audioFilter = undefined;
525
- this.audioDecoder?.close();
526
- this.audioDecoder = undefined;
527
- this.hardwareContext?.dispose();
528
- this.hardwareContext = undefined;
529
- await this.input?.close();
530
- this.input = undefined;
862
+ // Memoized: concurrent stop() calls share one teardown instead of
863
+ // double-closing native resources.
864
+ this.stopPromise ??= this.doStop().finally(() => {
865
+ this.stopPromise = undefined;
866
+ });
867
+ await this.stopPromise;
868
+ }
869
+ /**
870
+ * Perform the actual teardown (see {@link stop}).
871
+ *
872
+ * @internal
873
+ */
874
+ async doStop() {
875
+ // An in-flight start() must unwind before teardown - otherwise it would
876
+ // keep building the pipeline on top of freed resources and leave an
877
+ // orphaned transcode running forever. The flag makes doStart() bail at its
878
+ // next checkpoint, the abort unblocks a startup stuck in the input open.
879
+ this.stopRequested = true;
880
+ this.startAbort?.abort();
881
+ this.startAbort = undefined;
882
+ const inflightStart = this.startPromise;
883
+ // Allow a fresh start() after stopping.
884
+ this.startPromise = undefined;
885
+ if (inflightStart) {
886
+ try {
887
+ await inflightStart;
888
+ }
889
+ catch {
890
+ // Aborted or failed startup - resources are torn down below either way.
891
+ }
892
+ }
893
+ try {
894
+ // Detach the abort listener so it cannot fire for a torn-down session and
895
+ // does not accumulate across start()/stop() cycles.
896
+ if (this.abortHandler) {
897
+ this.signal?.removeEventListener('abort', this.abortHandler);
898
+ this.abortHandler = undefined;
899
+ }
900
+ // Stop pipeline if running and wait for completion. pipeline.stop() interrupts
901
+ // the source demuxer's read (frame-source pipelines unwind via iterator.return
902
+ // instead). Swallow a rejected completion here - the error is already surfaced
903
+ // to the caller via the onClose(error) path; we just need cleanup to proceed.
904
+ if (this.pipeline && !this.pipeline.isStopped()) {
905
+ this.pipeline.stop();
906
+ try {
907
+ await this.pipeline.completion;
908
+ }
909
+ catch {
910
+ // Pipeline errored - proceed with teardown regardless.
911
+ }
912
+ this.pipeline = undefined;
913
+ }
914
+ // Close all resources
915
+ await this.videoOutput?.close();
916
+ this.videoOutput = undefined;
917
+ await this.audioOutput?.close();
918
+ this.audioOutput = undefined;
919
+ this.videoEncoder?.close();
920
+ this.videoEncoder = undefined;
921
+ this.videoFilter?.close();
922
+ this.videoFilter = undefined;
923
+ this.videoDecoder?.close();
924
+ this.videoDecoder = undefined;
925
+ this.audioEncoder?.close();
926
+ this.audioEncoder = undefined;
927
+ this.audioFilter?.close();
928
+ this.audioFilter = undefined;
929
+ this.audioDecoder?.close();
930
+ this.audioDecoder = undefined;
931
+ this.hardwareContext?.dispose();
932
+ this.hardwareContext = undefined;
933
+ await this.input?.close();
934
+ this.input = undefined;
935
+ }
936
+ finally {
937
+ this.stopRequested = false;
938
+ }
531
939
  }
532
940
  /**
533
941
  * Check if the given audio codec is supported.