node-av 6.2.0-beta.3 → 6.2.0-beta.31

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 (109) hide show
  1. package/README.md +13 -8
  2. package/dist/api/audio-frame-buffer.d.ts +438 -7
  3. package/dist/api/audio-frame-buffer.js +792 -52
  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 +13 -6
  10. package/dist/api/decoder.js.map +1 -1
  11. package/dist/api/demuxer.d.ts +29 -4
  12. package/dist/api/demuxer.js +209 -60
  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 +121 -9
  16. package/dist/api/encoder-pool.js.map +1 -1
  17. package/dist/api/encoder.d.ts +58 -2
  18. package/dist/api/encoder.js +173 -57
  19. package/dist/api/encoder.js.map +1 -1
  20. package/dist/api/filter.js +15 -6
  21. package/dist/api/filter.js.map +1 -1
  22. package/dist/api/fmp4-stream.d.ts +327 -8
  23. package/dist/api/fmp4-stream.js +663 -123
  24. package/dist/api/fmp4-stream.js.map +1 -1
  25. package/dist/api/hardware.d.ts +2 -0
  26. package/dist/api/hardware.js +25 -4
  27. package/dist/api/hardware.js.map +1 -1
  28. package/dist/api/index.d.ts +2 -2
  29. package/dist/api/index.js.map +1 -1
  30. package/dist/api/io-stream.d.ts +3 -2
  31. package/dist/api/io-stream.js +77 -5
  32. package/dist/api/io-stream.js.map +1 -1
  33. package/dist/api/muxer.d.ts +289 -7
  34. package/dist/api/muxer.js +696 -109
  35. package/dist/api/muxer.js.map +1 -1
  36. package/dist/api/pipeline.d.ts +24 -0
  37. package/dist/api/pipeline.js +76 -5
  38. package/dist/api/pipeline.js.map +1 -1
  39. package/dist/api/rtp-stream.d.ts +77 -8
  40. package/dist/api/rtp-stream.js +316 -88
  41. package/dist/api/rtp-stream.js.map +1 -1
  42. package/dist/api/scaler.d.ts +124 -21
  43. package/dist/api/scaler.js +347 -114
  44. package/dist/api/scaler.js.map +1 -1
  45. package/dist/api/utilities/async-queue.d.ts +3 -0
  46. package/dist/api/utilities/async-queue.js +8 -0
  47. package/dist/api/utilities/async-queue.js.map +1 -1
  48. package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
  49. package/dist/api/utilities/packet-age-watchdog.js +594 -0
  50. package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
  51. package/dist/api/webrtc-stream.d.ts +2 -0
  52. package/dist/api/webrtc-stream.js +46 -12
  53. package/dist/api/webrtc-stream.js.map +1 -1
  54. package/dist/api/whisper.d.ts +2 -0
  55. package/dist/api/whisper.js +63 -23
  56. package/dist/api/whisper.js.map +1 -1
  57. package/dist/constants/constants.d.ts +1 -0
  58. package/dist/constants/constants.js +1 -0
  59. package/dist/constants/constants.js.map +1 -1
  60. package/dist/constants/filter-options.d.ts +2 -0
  61. package/dist/ffmpeg/install.js +104 -26
  62. package/dist/ffmpeg/install.js.map +1 -1
  63. package/dist/ffmpeg/utils.js +7 -4
  64. package/dist/ffmpeg/utils.js.map +1 -1
  65. package/dist/lib/binding.d.ts +10 -1
  66. package/dist/lib/binding.js +25 -16
  67. package/dist/lib/binding.js.map +1 -1
  68. package/dist/lib/bitstream-filter-context.d.ts +2 -0
  69. package/dist/lib/bitstream-filter-context.js +2 -0
  70. package/dist/lib/bitstream-filter-context.js.map +1 -1
  71. package/dist/lib/codec-context.d.ts +0 -2
  72. package/dist/lib/codec-context.js +4 -22
  73. package/dist/lib/codec-context.js.map +1 -1
  74. package/dist/lib/filter-context.d.ts +3 -1
  75. package/dist/lib/filter-context.js +5 -11
  76. package/dist/lib/filter-context.js.map +1 -1
  77. package/dist/lib/filter-graph-segment.d.ts +3 -2
  78. package/dist/lib/filter-graph-segment.js +3 -2
  79. package/dist/lib/filter-graph-segment.js.map +1 -1
  80. package/dist/lib/filter-graph.d.ts +4 -4
  81. package/dist/lib/filter-graph.js +4 -4
  82. package/dist/lib/filter-inout.d.ts +4 -0
  83. package/dist/lib/filter-inout.js +4 -0
  84. package/dist/lib/filter-inout.js.map +1 -1
  85. package/dist/lib/format-context.d.ts +72 -7
  86. package/dist/lib/format-context.js +80 -22
  87. package/dist/lib/format-context.js.map +1 -1
  88. package/dist/lib/frame.d.ts +17 -13
  89. package/dist/lib/frame.js +24 -23
  90. package/dist/lib/frame.js.map +1 -1
  91. package/dist/lib/hardware-frames-context.d.ts +0 -1
  92. package/dist/lib/hardware-frames-context.js +2 -8
  93. package/dist/lib/hardware-frames-context.js.map +1 -1
  94. package/dist/lib/io-context.d.ts +4 -2
  95. package/dist/lib/io-context.js +4 -2
  96. package/dist/lib/io-context.js.map +1 -1
  97. package/dist/lib/native-types.d.ts +7 -5
  98. package/dist/lib/option.d.ts +26 -0
  99. package/dist/lib/option.js +42 -18
  100. package/dist/lib/option.js.map +1 -1
  101. package/dist/lib/packet.d.ts +5 -0
  102. package/dist/lib/packet.js +5 -0
  103. package/dist/lib/packet.js.map +1 -1
  104. package/dist/lib/stream.d.ts +8 -4
  105. package/dist/lib/stream.js +22 -24
  106. package/dist/lib/stream.js.map +1 -1
  107. package/install/check.js +40 -79
  108. package/package.json +45 -34
  109. package/build_mac_local.sh +0 -69
@@ -1,11 +1,11 @@
1
1
  import { isRtcp, RtpPacket } from 'werift';
2
- import { AV_HWDEVICE_TYPE_NONE, AV_PICTURE_TYPE_I, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
2
+ import { AV_CODEC_ID_HEVC, 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
5
  import { Rational } from '../lib/rational.js';
6
- import { avChannelLayoutDefault } from '../lib/utilities.js';
6
+ import { avChannelLayoutDefault, avGetPixFmtName } from '../lib/utilities.js';
7
+ import { BitStreamFilterAPI } from './bitstream-filter.js';
7
8
  import { MAX_PACKET_SIZE } from './constants.js';
8
- import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
9
9
  import { Decoder } from './decoder.js';
10
10
  import { Demuxer } from './demuxer.js';
11
11
  import { Encoder } from './encoder.js';
@@ -13,7 +13,8 @@ import { FilterPreset } from './filter-presets.js';
13
13
  import { FilterAPI } from './filter.js';
14
14
  import { HardwareContext } from './hardware.js';
15
15
  import { Muxer } from './muxer.js';
16
- import { pipeline } from './pipeline.js';
16
+ import { interruptibleFrameSource, pipeline } from './pipeline.js';
17
+ import { pickSupportedLayout, pickSupportedPixelFormat, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
17
18
  /**
18
19
  * Check whether a streaming-class input is a frame source (not a URL or Demuxer).
19
20
  *
@@ -100,6 +101,7 @@ export class RTPStream {
100
101
  videoDecoder;
101
102
  videoFilter;
102
103
  videoEncoder;
104
+ videoBitstreamFilter;
103
105
  audioDecoder;
104
106
  audioFilter;
105
107
  audioEncoder;
@@ -109,6 +111,11 @@ export class RTPStream {
109
111
  supportedVideoCodecs;
110
112
  supportedAudioCodecs;
111
113
  videoKeyframeRequested = false;
114
+ abortHandler;
115
+ stopRequested = false;
116
+ stopPromise;
117
+ startAbort;
118
+ stopSignal = Promise.withResolvers();
112
119
  /**
113
120
  * @param input - Media input URL or pre-opened Demuxer
114
121
  *
@@ -161,9 +168,11 @@ export class RTPStream {
161
168
  ssrc: options.video?.ssrc,
162
169
  payloadType: options.video?.payloadType,
163
170
  mtu: options.video?.mtu ?? MAX_PACKET_SIZE,
164
- fps: options.video?.fps ?? 20,
171
+ fps: options.video?.fps,
165
172
  width: options.video?.width,
166
173
  height: options.video?.height,
174
+ bitrate: options.video?.bitrate,
175
+ dumpExtra: options.video?.dumpExtra,
167
176
  encoderOptions: options.video?.encoderOptions ?? {},
168
177
  },
169
178
  audio: {
@@ -223,10 +232,14 @@ export class RTPStream {
223
232
  /**
224
233
  * Check if the stream is active.
225
234
  *
235
+ * Stays true while a requested stop is still unwinding: the pipeline reports
236
+ * itself stopped as soon as stop() is called, but its packets keep flowing
237
+ * and its native resources stay open until completion settles.
238
+ *
226
239
  * @returns True if the stream is active, false otherwise
227
240
  */
228
241
  get isStreamActive() {
229
- return this.pipeline !== undefined && !this.pipeline.isStopped();
242
+ return this.pipeline !== undefined;
230
243
  }
231
244
  /**
232
245
  * Get the demuxer instance.
@@ -282,7 +295,9 @@ export class RTPStream {
282
295
  * @internal
283
296
  */
284
297
  async *wrapVideoSource(src) {
285
- for await (const frame of src) {
298
+ // Pull through the interruptible wrapper: a frame source has no demuxer for
299
+ // stop() to interrupt, so without it a silent source blocks teardown.
300
+ for await (const frame of interruptibleFrameSource(src, this.stopSignal.promise)) {
286
301
  if (frame && this.videoKeyframeRequested) {
287
302
  this.videoKeyframeRequested = false;
288
303
  frame.pictType = AV_PICTURE_TYPE_I;
@@ -332,11 +347,30 @@ export class RTPStream {
332
347
  * @internal
333
348
  */
334
349
  async doStart() {
350
+ // A start racing an in-flight stop() waits for the teardown to finish,
351
+ // otherwise it would build the pipeline on top of freed resources.
352
+ if (this.stopPromise) {
353
+ await this.stopPromise;
354
+ }
335
355
  if (this.pipeline) {
336
356
  return;
337
357
  }
338
358
  this.signal?.throwIfAborted();
339
- this.signal?.addEventListener('abort', () => this.stop(), { once: true });
359
+ if (this.signal && !this.abortHandler) {
360
+ // Stop on abort. Keep a reference so stop() can detach the listener again,
361
+ // otherwise restarts would stack one listener per start(). Errors from the
362
+ // async stop() are swallowed here; they already surface via onClose(error).
363
+ this.abortHandler = () => {
364
+ this.abortHandler = undefined;
365
+ this.stop().catch(() => { });
366
+ };
367
+ this.signal.addEventListener('abort', this.abortHandler, { once: true });
368
+ }
369
+ // Re-armed per start so a restart is not torn down by the previous run.
370
+ this.stopSignal = Promise.withResolvers();
371
+ // Lets stop() interrupt a startup that is blocked in the input open
372
+ // (an RTSP connect can take seconds).
373
+ this.startAbort = new AbortController();
340
374
  // Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
341
375
  if (this.source) {
342
376
  await this.startFromFrames();
@@ -346,10 +380,13 @@ export class RTPStream {
346
380
  if (!this.inputUrl) {
347
381
  throw new Error('No input URL or Demuxer provided');
348
382
  }
349
- this.input = await Demuxer.open(this.inputUrl, this.inputOptions);
383
+ const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
384
+ this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
350
385
  }
386
+ this.throwIfStopRequested();
351
387
  const videoStream = this.input.video();
352
388
  const audioStream = this.input.audio();
389
+ let videoOutputIsHevc = videoStream?.codecpar.codecId === AV_CODEC_ID_HEVC;
353
390
  // Setup video transcoding if needed
354
391
  if (videoStream && !this.isVideoCodecSupported(videoStream.codecpar.codecId)) {
355
392
  // Check if we need hardware acceleration
@@ -370,6 +407,21 @@ export class RTPStream {
370
407
  const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
371
408
  (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
372
409
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
410
+ // Get first supported codec
411
+ const targetCodecId = this.options.supportedVideoCodecs[0];
412
+ if (!targetCodecId) {
413
+ throw new Error('No supported video codec specified for transcoding');
414
+ }
415
+ let encoderCodec = null;
416
+ if (typeof targetCodecId === 'string') {
417
+ encoderCodec = Codec.findEncoderByName(targetCodecId);
418
+ }
419
+ else {
420
+ encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
421
+ }
422
+ if (!encoderCodec) {
423
+ throw new Error(`No encoder found for codec ID ${targetCodecId}`);
424
+ }
373
425
  // Create filter chain only if needed
374
426
  if (needsScale || needsFps) {
375
427
  const filterChain = FilterPreset.chain(this.hardwareContext);
@@ -383,25 +435,26 @@ export class RTPStream {
383
435
  if (needsFps) {
384
436
  filterChain.filter('fps', { fps: this.options.video.fps });
385
437
  }
438
+ // Software scaling leaves the graph output format unconstrained, so swscale keeps the
439
+ // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
440
+ // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
441
+ // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
442
+ // one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
443
+ // profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
444
+ // codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
445
+ // negotiate their format through the frames context instead.
446
+ if (needsScale && !this.hardwareContext) {
447
+ const formats = encoderCodec.pixelFormats;
448
+ const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
449
+ const targetFmtName = avGetPixFmtName(targetFmt);
450
+ if (targetFmtName) {
451
+ filterChain.filter('format', { pix_fmts: targetFmtName });
452
+ }
453
+ }
386
454
  this.videoFilter = FilterAPI.create(filterChain.build(), {
387
455
  hardware: this.hardwareContext,
388
456
  });
389
457
  }
390
- // Get first supported codec
391
- const targetCodecId = this.options.supportedVideoCodecs[0];
392
- if (!targetCodecId) {
393
- throw new Error('No supported video codec specified for transcoding');
394
- }
395
- let encoderCodec = null;
396
- if (typeof targetCodecId === 'string') {
397
- encoderCodec = Codec.findEncoderByName(targetCodecId);
398
- }
399
- else {
400
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
401
- }
402
- if (!encoderCodec) {
403
- throw new Error(`No encoder found for codec ID ${targetCodecId}`);
404
- }
405
458
  let encoderOptions = {};
406
459
  if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
407
460
  encoderOptions.preset = 'ultrafast';
@@ -411,20 +464,25 @@ export class RTPStream {
411
464
  ...encoderOptions,
412
465
  ...this.options.video.encoderOptions,
413
466
  };
467
+ const bitrate = this.options.video.bitrate;
414
468
  this.videoEncoder = await Encoder.create(encoderCodec, {
415
469
  decoder: this.videoDecoder,
470
+ filter: this.videoFilter,
416
471
  maxBFrames: 0,
417
472
  options: encoderOptions,
473
+ configure: (ctx) => {
474
+ this.applyBitrate(ctx, bitrate);
475
+ },
418
476
  });
477
+ this.throwIfStopRequested();
478
+ videoOutputIsHevc = encoderCodec.id === AV_CODEC_ID_HEVC;
419
479
  }
420
- // Calculate video timestamp increment from the input stream fps (or option override)
421
- const videoStreamFps = videoStream ? videoStream.avgFrameRate.num / videoStream.avgFrameRate.den : 20;
422
- let fps = this.options.video.fps ?? videoStreamFps;
423
- if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
424
- fps = 20; // Default to 20 FPS if invalid
480
+ if (videoStream && videoOutputIsHevc && this.options.video?.dumpExtra) {
481
+ this.videoBitstreamFilter = BitStreamFilterAPI.create('dump_extra', this.videoEncoder ?? videoStream, { options: { freq: 'k' } });
425
482
  }
426
483
  // Setup video output
427
- this.videoOutput = await this.createVideoOutput(fps);
484
+ this.videoOutput = await this.createVideoOutput();
485
+ this.throwIfStopRequested();
428
486
  // Setup audio if available and needs transcoding
429
487
  if (audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId)) {
430
488
  this.audioDecoder = await Decoder.create(audioStream, {
@@ -470,37 +528,139 @@ export class RTPStream {
470
528
  decoder: this.audioDecoder,
471
529
  filter: this.audioFilter,
472
530
  options: encoderOptions,
531
+ // Silence for an input gap would only go out once the input resumes, as a burst of
532
+ // late packets; receivers conceal a timestamp gap like packet loss instead
533
+ audioFrameBuffer: { maxGapFill: 0 },
473
534
  });
535
+ this.throwIfStopRequested();
474
536
  }
475
537
  // Setup audio output if available
476
538
  if (audioStream) {
477
539
  this.audioOutput = await this.createAudioOutput();
540
+ this.throwIfStopRequested();
478
541
  }
479
542
  // Start pipeline in background (don't await)
480
543
  this.attachCompletion(this.runPipeline());
481
544
  }
545
+ /**
546
+ * Abort an in-flight startup once stop() was requested.
547
+ *
548
+ * Checked after every await in the startup path: the resources created so
549
+ * far are all assigned to instance fields, so the stop() that is waiting on
550
+ * this startup tears them down the moment it unwinds.
551
+ *
552
+ * @throws {Error} If stop() was called while starting
553
+ *
554
+ * @internal
555
+ */
556
+ throwIfStopRequested() {
557
+ if (this.stopRequested) {
558
+ throw new Error('RTPStream stopped during start');
559
+ }
560
+ }
561
+ /**
562
+ * Cap the video bitrate on the encoder context.
563
+ *
564
+ * Sets the target bitrate together with a VBV cap (maxrate + a 1s buffer).
565
+ * Encoder.create() always seeds a default target bitrate (1 Mbps for video),
566
+ * so the target MUST be lowered along with the cap.
567
+ *
568
+ * @param ctx - Encoder codec context
569
+ *
570
+ * @param bitrate - Maximum bitrate in bits per second, or undefined
571
+ *
572
+ * @internal
573
+ */
574
+ applyBitrate(ctx, bitrate) {
575
+ if (!bitrate || bitrate <= 0) {
576
+ return;
577
+ }
578
+ const rate = Math.round(bitrate);
579
+ ctx.bitRate = BigInt(rate);
580
+ ctx.rcMaxRate = BigInt(rate);
581
+ ctx.rcBufferSize = rate;
582
+ }
482
583
  /**
483
584
  * Wire the pipeline completion to the onClose/error callbacks.
484
585
  *
485
586
  * On success invokes `onClose()`; on error stops the stream and invokes
486
- * `onClose(error)`. Runs in the background (not awaited).
587
+ * `onClose(error)`. Either way `onClose` runs exactly once and the detached
588
+ * chain never rejects. Runs in the background (not awaited).
487
589
  *
488
590
  * @param completion - The pipeline completion promise
489
591
  *
490
592
  * @internal
491
593
  */
492
594
  attachCompletion(completion) {
595
+ // Captured so a restart that installed a newer pipeline keeps it.
596
+ const control = this.pipeline;
597
+ // One then() with both handlers: a throwing onClose() in the success path
598
+ // must not fall through into the error path and fire onClose a second time.
493
599
  completion
494
600
  .then(() => {
495
601
  // Pipeline completed successfully
496
- this.options.onClose?.();
602
+ this.emitClose();
603
+ }, async (reason) => {
604
+ const error = reason instanceof Error ? reason : new Error(String(reason));
605
+ // A stop() in flight or an aborted signal means the owner is tearing
606
+ // the stream down - the pipeline unwinding with an AbortError (or an
607
+ // interrupted FFmpeg call) is the expected shutdown path, not a failure.
608
+ // Decided before the stop() below, which would make every failure look deliberate.
609
+ const deliberate = this.isDeliberateStop(reason);
610
+ if (!deliberate) {
611
+ console.error('[RTPStream] Pipeline error:', error);
612
+ }
613
+ // A teardown failure must not swallow the onClose call: without it
614
+ // the owner never learns the stream is gone.
615
+ try {
616
+ await this.stop();
617
+ }
618
+ catch {
619
+ // doStop() already released whatever it could.
620
+ }
621
+ this.emitClose(deliberate ? undefined : error);
497
622
  })
498
- .catch(async (error) => {
499
- console.error('[RTPStream] Pipeline error:', error);
500
- await this.stop();
501
- this.options.onClose?.(error);
623
+ .finally(() => {
624
+ // Drop the finished pipeline so isStreamActive reports inactive. The
625
+ // demuxer path already clears it in runPipeline(); a frame source has
626
+ // no such step, so without this it would look active forever.
627
+ if (this.pipeline === control) {
628
+ this.pipeline = undefined;
629
+ }
502
630
  });
503
631
  }
632
+ /**
633
+ * Invoke the owner's onClose callback.
634
+ *
635
+ * The completion chain runs detached, so an exception from the callback
636
+ * could only surface as an unhandled rejection - it is logged instead.
637
+ *
638
+ * @param error - Pipeline error, or undefined for a clean end
639
+ *
640
+ * @internal
641
+ */
642
+ emitClose(error) {
643
+ try {
644
+ this.options.onClose(error);
645
+ }
646
+ catch (callbackError) {
647
+ // The stream is already closed; logged so a broken restart path in the
648
+ // owner does not vanish silently.
649
+ console.error('[RTPStream] onClose callback threw:', callbackError);
650
+ }
651
+ }
652
+ /**
653
+ * Whether a pipeline rejection was caused by an intentional shutdown.
654
+ *
655
+ * @param error - The pipeline rejection reason
656
+ *
657
+ * @returns True when stop() is in flight or the owner's signal aborted
658
+ *
659
+ * @internal
660
+ */
661
+ isDeliberateStop(error) {
662
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
663
+ }
504
664
  /**
505
665
  * Set up encoders and RTP outputs for a pre-composited frame source and start
506
666
  * encoding. No demuxer or decoder is involved - the provided frames are encoded
@@ -557,15 +717,18 @@ export class RTPStream {
557
717
  // browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
558
718
  // that join (or lose) mid-stream must get a keyframe quickly - the x264
559
719
  // default of 250 frames leaves them waiting for many seconds.
720
+ const bitrate = this.options.video.bitrate;
560
721
  this.videoEncoder = await Encoder.create(encoderCodec, {
561
722
  maxBFrames: 0,
562
723
  options: encoderOptions,
563
724
  configure: (ctx) => {
564
725
  ctx.framerate = new Rational(Math.round(fps), 1);
565
726
  ctx.gopSize = Math.max(1, Math.round(fps * 2));
727
+ this.applyBitrate(ctx, bitrate);
566
728
  },
567
729
  });
568
- this.videoOutput = await this.createVideoOutput(fps);
730
+ this.videoOutput = await this.createVideoOutput();
731
+ this.throwIfStopRequested();
569
732
  controls.push(pipeline(this.wrapVideoSource(source.video), this.videoEncoder, this.videoOutput, opts));
570
733
  }
571
734
  // Audio
@@ -599,9 +762,12 @@ export class RTPStream {
599
762
  this.audioEncoder = await Encoder.create(encoderCodec, {
600
763
  filter: this.audioFilter,
601
764
  options: encoderOptions,
765
+ audioFrameBuffer: { maxGapFill: 0 },
602
766
  });
603
767
  this.audioOutput = await this.createAudioOutput();
604
- controls.push(pipeline(source.audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
768
+ this.throwIfStopRequested();
769
+ const audio = interruptibleFrameSource(source.audio, this.stopSignal.promise);
770
+ controls.push(pipeline(audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
605
771
  }
606
772
  this.pipeline = new CombinedPipelineControl(controls);
607
773
  this.attachCompletion(this.pipeline.completion);
@@ -609,21 +775,19 @@ export class RTPStream {
609
775
  /**
610
776
  * Create the RTP video output muxer.
611
777
  *
612
- * Rewrites RTP sequence numbers (continuous) and timestamps (90 kHz, advanced
613
- * per frame at `fps`) in the write callback, then forwards each packet to
614
- * `onVideoPacket`. Uses `this.input` when present (demuxer path) and no input
615
- * for the frame-source path.
616
- *
617
- * @param fps - Frame rate used for the RTP timestamp increment
778
+ * Rewrites RTP sequence numbers (continuous) and rebases the PTS-derived
779
+ * timestamps (90 kHz) onto a random initial value in the write callback, then
780
+ * forwards each packet to `onVideoPacket`. Uses `this.input` when present
781
+ * (demuxer path) and no input for the frame-source path.
618
782
  *
619
783
  * @returns The configured video muxer
620
784
  *
621
785
  * @internal
622
786
  */
623
- async createVideoOutput(fps) {
787
+ async createVideoOutput() {
624
788
  let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
625
- let videoTimestamp = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
626
- const videoTimestampIncrement = 90000 / fps;
789
+ const videoTimestampBase = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
790
+ let videoTimestampOffset;
627
791
  const videoMuxerOptions = {};
628
792
  if (this.options.video.ssrc !== undefined) {
629
793
  videoMuxerOptions.ssrc = this.options.video.ssrc;
@@ -641,12 +805,14 @@ export class RTPStream {
641
805
  // Fix sequence number - ensure continuous sequence
642
806
  rtpPacket.header.sequenceNumber = videoSequenceNumber;
643
807
  videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
644
- // Fix timestamp - calculate based on FPS. All packets in the same frame
645
- // share a timestamp (marker=false); advance only when the frame ends.
646
- rtpPacket.header.timestamp = videoTimestamp;
647
- if (rtpPacket.header.marker) {
648
- videoTimestamp = (videoTimestamp + videoTimestampIncrement) >>> 0; // Unsigned 32-bit wrap
649
- }
808
+ // Rebase the timestamp onto our own random initial value. FFmpeg's RTP
809
+ // muxer derives it from the packet PTS in the 90 kHz clock (plus its own
810
+ // random base), so the deltas carry the real presentation times -
811
+ // including B-frame reordering and VFR cadence. Only the base is swapped;
812
+ // modular uint32 arithmetic keeps wrap-around intact, and packets of the
813
+ // same frame keep sharing a timestamp.
814
+ videoTimestampOffset ??= (videoTimestampBase - rtpPacket.header.timestamp) >>> 0;
815
+ rtpPacket.header.timestamp = (rtpPacket.header.timestamp + videoTimestampOffset) >>> 0;
650
816
  this.options.onVideoPacket(rtpPacket);
651
817
  return buffer.length;
652
818
  },
@@ -713,7 +879,7 @@ export class RTPStream {
713
879
  const opts = this.signal ? { signal: this.signal } : undefined;
714
880
  if (hasAudio && hasVideo) {
715
881
  this.pipeline = pipeline(this.input, {
716
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
882
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
717
883
  audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
718
884
  }, {
719
885
  video: this.videoOutput,
@@ -722,7 +888,7 @@ export class RTPStream {
722
888
  }
723
889
  else if (hasVideo) {
724
890
  this.pipeline = pipeline(this.input, {
725
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
891
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
726
892
  }, this.videoOutput, opts);
727
893
  }
728
894
  else if (hasAudio) {
@@ -741,7 +907,8 @@ export class RTPStream {
741
907
  *
742
908
  * Stops the pipeline, closes output, and releases all FFmpeg resources.
743
909
  * Safe to call multiple times. After stopping, you can call start() again
744
- * to restart the stream.
910
+ * to restart the stream. Resolves even if the pipeline had failed; that
911
+ * error is reported through `onClose(error)`.
745
912
  *
746
913
  * @example
747
914
  * ```typescript
@@ -755,43 +922,104 @@ export class RTPStream {
755
922
  * ```
756
923
  */
757
924
  async stop() {
925
+ // Memoized: concurrent stop() calls share one teardown instead of
926
+ // double-closing native resources.
927
+ this.stopPromise ??= this.doStop().finally(() => {
928
+ this.stopPromise = undefined;
929
+ });
930
+ await this.stopPromise;
931
+ }
932
+ /**
933
+ * Perform the actual teardown (see {@link stop}).
934
+ *
935
+ * @internal
936
+ */
937
+ async doStop() {
938
+ // An in-flight start() must unwind before teardown - otherwise it would
939
+ // keep building the pipeline on top of freed resources and leave an
940
+ // orphaned transcode running forever. The flag makes doStart() bail at its
941
+ // next checkpoint, the abort unblocks a startup stuck in the input open.
942
+ this.stopRequested = true;
943
+ this.startAbort?.abort();
944
+ this.startAbort = undefined;
945
+ // Unblocks a frame source that went silent, so the completion barrier below
946
+ // cannot wait on a pull that will never settle.
947
+ this.stopSignal.resolve();
948
+ const inflightStart = this.startPromise;
758
949
  // Allow a fresh start() after stopping.
759
950
  this.startPromise = undefined;
760
- // Stop pipeline if running and wait for completion. pipeline.stop() interrupts
761
- // the source demuxer's read (frame-source pipelines unwind via iterator.return
762
- // instead). Swallow a rejected completion here - the error is already surfaced
763
- // to the caller via the onClose(error) path; we just need cleanup to proceed.
764
- if (this.pipeline && !this.pipeline.isStopped()) {
765
- this.pipeline.stop();
951
+ if (inflightStart) {
952
+ try {
953
+ await inflightStart;
954
+ }
955
+ catch {
956
+ // Aborted or failed startup - resources are torn down below either way.
957
+ }
958
+ }
959
+ try {
960
+ // Detach the abort listener so it cannot fire for a torn-down session and
961
+ // does not accumulate across start()/stop() cycles.
962
+ if (this.abortHandler) {
963
+ this.signal?.removeEventListener('abort', this.abortHandler);
964
+ this.abortHandler = undefined;
965
+ }
966
+ // Stop pipeline if running and wait for completion. pipeline.stop() interrupts
967
+ // the source demuxer's read (frame-source pipelines unwind via iterator.return
968
+ // instead). Swallow a rejected completion here - the error is already surfaced
969
+ // to the caller via the onClose(error) path; we just need cleanup to proceed.
970
+ const pipeline = this.pipeline;
971
+ if (pipeline) {
972
+ if (!pipeline.isStopped()) {
973
+ pipeline.stop();
974
+ }
975
+ try {
976
+ await pipeline.completion;
977
+ }
978
+ catch {
979
+ // Pipeline errored - proceed with teardown regardless.
980
+ }
981
+ if (this.pipeline === pipeline) {
982
+ this.pipeline = undefined;
983
+ }
984
+ }
985
+ // Close all resources. close() rethrows a failed background write after it
986
+ // freed the muxer. That error already ended the pipeline and reaches the
987
+ // owner through onClose(error), so it must not abort the teardown here and
988
+ // leak the other output, the codecs and the input (an RTSP connection).
766
989
  try {
767
- await this.pipeline.completion;
990
+ await this.videoOutput?.close();
768
991
  }
769
992
  catch {
770
- // Pipeline errored - proceed with teardown regardless.
771
- }
772
- this.pipeline = undefined;
773
- }
774
- // Close all resources
775
- await this.videoOutput?.close();
776
- this.videoOutput = undefined;
777
- await this.audioOutput?.close();
778
- this.audioOutput = undefined;
779
- this.videoEncoder?.close();
780
- this.videoEncoder = undefined;
781
- this.videoFilter?.close();
782
- this.videoFilter = undefined;
783
- this.videoDecoder?.close();
784
- this.videoDecoder = undefined;
785
- this.audioEncoder?.close();
786
- this.audioEncoder = undefined;
787
- this.audioFilter?.close();
788
- this.audioFilter = undefined;
789
- this.audioDecoder?.close();
790
- this.audioDecoder = undefined;
791
- this.hardwareContext?.dispose();
792
- this.hardwareContext = undefined;
793
- await this.input?.close();
794
- this.input = undefined;
993
+ // Surfaced through the pipeline completion.
994
+ }
995
+ this.videoOutput = undefined;
996
+ try {
997
+ await this.audioOutput?.close();
998
+ }
999
+ catch {
1000
+ // Surfaced through the pipeline completion.
1001
+ }
1002
+ this.audioOutput = undefined;
1003
+ this.videoEncoder?.close();
1004
+ this.videoEncoder = undefined;
1005
+ this.videoFilter?.close();
1006
+ this.videoFilter = undefined;
1007
+ this.videoDecoder?.close();
1008
+ this.videoDecoder = undefined;
1009
+ this.audioEncoder?.close();
1010
+ this.audioEncoder = undefined;
1011
+ this.audioFilter?.close();
1012
+ this.audioFilter = undefined;
1013
+ this.audioDecoder?.close();
1014
+ this.audioDecoder = undefined;
1015
+ this.hardwareContext?.dispose();
1016
+ this.hardwareContext = undefined;
1017
+ await this.input?.close();
1018
+ this.input = undefined;
1019
+ }
1020
+ finally {
1021
+ this.stopRequested = false;
1022
+ }
795
1023
  }
796
1024
  /**
797
1025
  * Check if the given audio codec is supported.