node-av 6.2.0-beta.2 → 6.2.0-beta.20

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 (89) hide show
  1. package/README.md +13 -8
  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 +13 -6
  10. package/dist/api/decoder.js.map +1 -1
  11. package/dist/api/demuxer.d.ts +15 -1
  12. package/dist/api/demuxer.js +165 -54
  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 +19 -0
  18. package/dist/api/encoder.js +112 -45
  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 +167 -3
  23. package/dist/api/fmp4-stream.js +423 -90
  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/io-stream.d.ts +3 -2
  29. package/dist/api/io-stream.js +61 -5
  30. package/dist/api/io-stream.js.map +1 -1
  31. package/dist/api/muxer.d.ts +14 -0
  32. package/dist/api/muxer.js +131 -51
  33. package/dist/api/muxer.js.map +1 -1
  34. package/dist/api/pipeline.d.ts +24 -0
  35. package/dist/api/pipeline.js +76 -5
  36. package/dist/api/pipeline.js.map +1 -1
  37. package/dist/api/rtp-stream.d.ts +56 -6
  38. package/dist/api/rtp-stream.js +252 -80
  39. package/dist/api/rtp-stream.js.map +1 -1
  40. package/dist/api/scaler.d.ts +105 -17
  41. package/dist/api/scaler.js +326 -108
  42. package/dist/api/scaler.js.map +1 -1
  43. package/dist/api/utilities/async-queue.d.ts +3 -0
  44. package/dist/api/utilities/async-queue.js +8 -0
  45. package/dist/api/utilities/async-queue.js.map +1 -1
  46. package/dist/api/webrtc-stream.d.ts +2 -0
  47. package/dist/api/webrtc-stream.js +46 -12
  48. package/dist/api/webrtc-stream.js.map +1 -1
  49. package/dist/api/whisper.d.ts +2 -0
  50. package/dist/api/whisper.js +63 -23
  51. package/dist/api/whisper.js.map +1 -1
  52. package/dist/ffmpeg/install.js +115 -29
  53. package/dist/ffmpeg/install.js.map +1 -1
  54. package/dist/ffmpeg/utils.js +7 -4
  55. package/dist/ffmpeg/utils.js.map +1 -1
  56. package/dist/lib/binding.d.ts +1 -0
  57. package/dist/lib/binding.js +12 -16
  58. package/dist/lib/binding.js.map +1 -1
  59. package/dist/lib/codec-context.d.ts +0 -2
  60. package/dist/lib/codec-context.js +4 -22
  61. package/dist/lib/codec-context.js.map +1 -1
  62. package/dist/lib/filter-context.d.ts +0 -1
  63. package/dist/lib/filter-context.js +2 -11
  64. package/dist/lib/filter-context.js.map +1 -1
  65. package/dist/lib/format-context.d.ts +63 -7
  66. package/dist/lib/format-context.js +70 -21
  67. package/dist/lib/format-context.js.map +1 -1
  68. package/dist/lib/frame.d.ts +11 -11
  69. package/dist/lib/frame.js +13 -20
  70. package/dist/lib/frame.js.map +1 -1
  71. package/dist/lib/hardware-frames-context.d.ts +0 -1
  72. package/dist/lib/hardware-frames-context.js +2 -8
  73. package/dist/lib/hardware-frames-context.js.map +1 -1
  74. package/dist/lib/io-context.d.ts +4 -2
  75. package/dist/lib/io-context.js +4 -2
  76. package/dist/lib/io-context.js.map +1 -1
  77. package/dist/lib/native-types.d.ts +4 -4
  78. package/dist/lib/option.d.ts +26 -0
  79. package/dist/lib/option.js +42 -18
  80. package/dist/lib/option.js.map +1 -1
  81. package/dist/lib/packet.d.ts +5 -0
  82. package/dist/lib/packet.js +5 -0
  83. package/dist/lib/packet.js.map +1 -1
  84. package/dist/lib/stream.d.ts +3 -4
  85. package/dist/lib/stream.js +17 -24
  86. package/dist/lib/stream.js.map +1 -1
  87. package/install/check.js +40 -79
  88. package/package.json +43 -32
  89. package/build_mac_local.sh +0 -69
@@ -1,8 +1,8 @@
1
- import { AV_CODEC_ID_AAC, AV_CODEC_ID_AV1, AV_CODEC_ID_FLAC, AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_OPUS, AV_HWDEVICE_TYPE_NONE, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
1
+ import { AV_CODEC_ID_AAC, AV_CODEC_ID_AV1, AV_CODEC_ID_FLAC, AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_OPUS, AV_HWDEVICE_TYPE_NONE, AV_PIX_FMT_YUV420P, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
2
2
  import { FF_ENCODER_AAC, FF_ENCODER_LIBX264 } from '../constants/encoders.js';
3
3
  import { Codec } from '../lib/codec.js';
4
4
  import { Rational } from '../lib/rational.js';
5
- import { avGetCodecString } from '../lib/utilities.js';
5
+ import { avGetCodecString, avGetPixFmtName } from '../lib/utilities.js';
6
6
  import { Decoder } from './decoder.js';
7
7
  import { Demuxer } from './demuxer.js';
8
8
  import { Encoder } from './encoder.js';
@@ -10,7 +10,8 @@ import { FilterPreset } from './filter-presets.js';
10
10
  import { FilterAPI } from './filter.js';
11
11
  import { HardwareContext } from './hardware.js';
12
12
  import { Muxer } from './muxer.js';
13
- import { consumeStreamInParallel, pipeline, PipelineControlImpl } from './pipeline.js';
13
+ import { consumeStreamInParallel, interruptibleFrameSource, pipeline, PipelineControlImpl } from './pipeline.js';
14
+ import { pickSupportedPixelFormat } from './utilities/codec-format.js';
14
15
  /**
15
16
  * Target codec strings for fMP4 streaming.
16
17
  */
@@ -83,9 +84,17 @@ export class FMP4Stream {
83
84
  signal;
84
85
  supportedCodecs;
85
86
  incompleteBoxBuffer = null;
87
+ pendingFragment = [];
88
+ abortHandler;
89
+ stopRequested = false;
90
+ stopPromise;
91
+ startAbort;
92
+ stopSignal = Promise.withResolvers();
86
93
  fragmentQueue = null;
94
+ _droppedFragments = 0;
87
95
  _initSegment = null;
88
96
  _initSegmentResolve = null;
97
+ _initSegmentReject = null;
89
98
  _initSegmentPromise = null;
90
99
  _ftypData = null;
91
100
  _moovData = null;
@@ -111,13 +120,11 @@ export class FMP4Stream {
111
120
  this.source = input;
112
121
  }
113
122
  const inputUrl = this.inputUrl ?? '';
123
+ const isLiveInput = /^(rtsp|rtmp|rtp|udp|srt|tcp|sctp):/i.test(inputUrl);
114
124
  this.inputOptions = {
115
125
  ...options.inputOptions,
116
126
  options: {
117
- flags: 'low_delay',
118
- fflags: 'nobuffer',
119
- // analyzeduration: 0,
120
- // probesize: 32,
127
+ ...(isLiveInput ? { flags: 'low_delay', fflags: 'nobuffer' } : {}),
121
128
  timeout: 10000000,
122
129
  rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
123
130
  ...options.inputOptions?.options,
@@ -134,6 +141,7 @@ export class FMP4Stream {
134
141
  fps: options.video?.fps,
135
142
  width: options.video?.width,
136
143
  height: options.video?.height,
144
+ bitrate: options.video?.bitrate,
137
145
  encoderOptions: options.video?.encoderOptions ?? {},
138
146
  },
139
147
  audio: {
@@ -141,6 +149,7 @@ export class FMP4Stream {
141
149
  },
142
150
  bufferSize: options.bufferSize ?? 2 * 1024 * 1024,
143
151
  boxMode: options.boxMode ?? false,
152
+ maxQueuedFragments: options.maxQueuedFragments ?? 16,
144
153
  movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
145
154
  };
146
155
  this.signal = options.signal;
@@ -189,6 +198,8 @@ export class FMP4Stream {
189
198
  *
190
199
  * Only available when boxMode is enabled.
191
200
  * Resolves once the first ftyp and moov boxes have been received.
201
+ * Rejects if the stream fails or is stopped before the init segment
202
+ * was produced, so awaiting it never hangs.
192
203
  *
193
204
  * @throws {Error} If boxMode is not enabled
194
205
  */
@@ -196,16 +207,39 @@ export class FMP4Stream {
196
207
  if (!this.options.boxMode) {
197
208
  throw new Error('initSegment is only available in box mode');
198
209
  }
199
- this._initSegmentPromise ??= new Promise((resolve) => {
210
+ this._initSegmentPromise ??= new Promise((resolve, reject) => {
200
211
  if (this._initSegment) {
201
212
  resolve(this._initSegment);
202
213
  }
203
214
  else {
204
215
  this._initSegmentResolve = resolve;
216
+ this._initSegmentReject = reject;
205
217
  }
206
218
  });
207
219
  return this._initSegmentPromise;
208
220
  }
221
+ /**
222
+ * Number of media fragments dropped due to consumer backpressure.
223
+ *
224
+ * Incremented whenever the {@link fragments} backlog exceeds
225
+ * `maxQueuedFragments` and the oldest fragment is discarded.
226
+ * Reset to 0 when the stream is (re)started.
227
+ *
228
+ * @returns Total dropped fragments for the current run
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * for await (const fragment of stream.fragments()) {
233
+ * await sendToClient(fragment.data);
234
+ * if (stream.droppedFragments > 0) {
235
+ * console.warn(`Consumer too slow: ${stream.droppedFragments} fragments dropped`);
236
+ * }
237
+ * }
238
+ * ```
239
+ */
240
+ get droppedFragments() {
241
+ return this._droppedFragments;
242
+ }
209
243
  /**
210
244
  * Get the demuxer instance.
211
245
  *
@@ -352,6 +386,8 @@ export class FMP4Stream {
352
386
  async start() {
353
387
  this.startPromise ??= this.doStart().catch((error) => {
354
388
  this.startPromise = undefined;
389
+ // A pending initSegment consumer must not hang when startup fails.
390
+ this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
355
391
  throw error;
356
392
  });
357
393
  await this.startPromise;
@@ -362,11 +398,28 @@ export class FMP4Stream {
362
398
  * @internal
363
399
  */
364
400
  async doStart() {
401
+ // A start racing an in-flight stop() waits for the teardown to finish,
402
+ // otherwise it would build the pipeline on top of freed resources.
403
+ if (this.stopPromise) {
404
+ await this.stopPromise;
405
+ }
365
406
  if (this.pipeline) {
366
407
  return;
367
408
  }
368
409
  this.signal?.throwIfAborted();
369
- this.signal?.addEventListener('abort', () => this.stop(), { once: true });
410
+ // Store the handler so stop() can remove it - otherwise listeners would
411
+ // accumulate across restarts. Teardown errors are swallowed here: they are
412
+ // already surfaced via onClose(error) by the completion handler.
413
+ this.abortHandler = () => {
414
+ this.stop().catch(() => { });
415
+ };
416
+ this.signal?.addEventListener('abort', this.abortHandler, { once: true });
417
+ this._droppedFragments = 0;
418
+ // Re-armed per start so a restart is not torn down by the previous run.
419
+ this.stopSignal = Promise.withResolvers();
420
+ // Lets stop() interrupt a startup that is blocked in the input open
421
+ // (an RTSP connect can take seconds).
422
+ this.startAbort = new AbortController();
370
423
  // Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
371
424
  if (this.source) {
372
425
  await this.startFromFrames();
@@ -377,8 +430,10 @@ export class FMP4Stream {
377
430
  if (!this.inputUrl) {
378
431
  throw new Error('No input URL or Demuxer provided');
379
432
  }
380
- this.input = await Demuxer.open(this.inputUrl, this.inputOptions);
433
+ const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
434
+ this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
381
435
  }
436
+ this.throwIfStopRequested();
382
437
  const videoStream = this.input.video();
383
438
  const audioStream = this.input.audio();
384
439
  // Check if video needs transcoding
@@ -396,6 +451,7 @@ export class FMP4Stream {
396
451
  hardware: this.hardwareContext,
397
452
  exitOnError: false,
398
453
  });
454
+ this.throwIfStopRequested();
399
455
  // Determine if we need filters by comparing with current stream properties
400
456
  const currentWidth = videoStream.codecpar.width;
401
457
  const currentHeight = videoStream.codecpar.height;
@@ -403,6 +459,7 @@ export class FMP4Stream {
403
459
  const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
404
460
  (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
405
461
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
462
+ const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
406
463
  // Create filter chain only if needed
407
464
  if (needsScale || needsFps) {
408
465
  const filterChain = FilterPreset.chain(this.hardwareContext);
@@ -416,13 +473,28 @@ export class FMP4Stream {
416
473
  if (needsFps) {
417
474
  filterChain.filter('fps', { fps: this.options.video.fps });
418
475
  }
476
+ // Software scaling leaves the graph output format unconstrained, so swscale keeps the
477
+ // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
478
+ // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
479
+ // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
480
+ // one format every browser/player can decode via MSE (unlike e.g. the 4:2:2 High profile
481
+ // that a least-loss pick would choose for a yuyv422 source). Fall back to a codec-supported
482
+ // format only if the encoder genuinely can't take yuv420p. Hardware chains negotiate their
483
+ // format through the frames context instead.
484
+ if (needsScale && !this.hardwareContext) {
485
+ const formats = encoderCodec.pixelFormats;
486
+ const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
487
+ const targetFmtName = avGetPixFmtName(targetFmt);
488
+ if (targetFmtName) {
489
+ filterChain.filter('format', { pix_fmts: targetFmtName });
490
+ }
491
+ }
419
492
  this.videoFilter = FilterAPI.create(filterChain.build(), {
420
493
  hardware: this.hardwareContext,
421
494
  });
422
495
  }
423
- const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
424
496
  let encoderOptions = {};
425
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX264) {
497
+ if (encoderCodec.name === FF_ENCODER_LIBX264) {
426
498
  encoderOptions.preset = 'ultrafast';
427
499
  encoderOptions.tune = 'zerolatency';
428
500
  }
@@ -430,10 +502,19 @@ export class FMP4Stream {
430
502
  ...encoderOptions,
431
503
  ...this.options.video.encoderOptions,
432
504
  };
505
+ const effectiveFps = this.options.video.fps ?? currentFps;
506
+ const fpsForGop = isFinite(effectiveFps) && effectiveFps > 0 ? effectiveFps : 30;
507
+ const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
508
+ const bitrate = this.options.video.bitrate;
433
509
  this.videoEncoder = await Encoder.create(encoderCodec, {
434
510
  decoder: this.videoDecoder,
435
511
  options: encoderOptions,
512
+ configure: (ctx) => {
513
+ ctx.gopSize = Math.max(1, Math.round(fpsForGop * fragSeconds));
514
+ this.applyBitrate(ctx, bitrate);
515
+ },
436
516
  });
517
+ this.throwIfStopRequested();
437
518
  }
438
519
  // Check if audio needs transcoding
439
520
  const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
@@ -450,11 +531,54 @@ export class FMP4Stream {
450
531
  filter: this.audioFilter,
451
532
  options: this.options.audio.encoderOptions,
452
533
  });
534
+ this.throwIfStopRequested();
453
535
  }
454
536
  // Setup output with callback
455
537
  this.output = await this.createOutput();
538
+ this.throwIfStopRequested();
456
539
  this.attachCompletion(this.runPipeline());
457
540
  }
541
+ /**
542
+ * Abort an in-flight startup once stop() was requested.
543
+ *
544
+ * Checked after every await in the startup path: the resources created so
545
+ * far are all assigned to instance fields, so the stop() that is waiting on
546
+ * this startup tears them down the moment it unwinds.
547
+ *
548
+ * @throws {Error} If stop() was called while starting
549
+ *
550
+ * @internal
551
+ */
552
+ throwIfStopRequested() {
553
+ if (this.stopRequested) {
554
+ throw new Error('FMP4Stream stopped during start');
555
+ }
556
+ }
557
+ /**
558
+ * Cap the video bitrate on the encoder context.
559
+ *
560
+ * Applies a VBV rate cap (maxrate + a 1s buffer) WITHOUT setting a target
561
+ * bitrate, so the encoder keeps its default constant-quality mode but can
562
+ * never exceed the negotiated ceiling. Setting the bitrate as an ABR target
563
+ * instead would push a normally-lighter stream UP to fill it, overshooting on
564
+ * bursts - the opposite of what strict rate-capped consumers want.
565
+ * No-op when no bitrate was requested.
566
+ *
567
+ * @param ctx - Encoder codec context
568
+ *
569
+ * @param bitrate - Maximum bitrate in bits per second, or undefined
570
+ *
571
+ * @internal
572
+ */
573
+ applyBitrate(ctx, bitrate) {
574
+ if (!bitrate || bitrate <= 0) {
575
+ return;
576
+ }
577
+ ctx.rcMaxRate = BigInt(Math.round(bitrate));
578
+ // 1s buffer keeps per-fragment size close to the cap instead of allowing
579
+ // multi-second bursts over it.
580
+ ctx.rcBufferSize = Math.round(bitrate);
581
+ }
458
582
  /**
459
583
  * Create the fMP4 output muxer with the fragment-emitting write callback.
460
584
  *
@@ -506,14 +630,40 @@ export class FMP4Stream {
506
630
  completion
507
631
  .then(() => {
508
632
  this.endFragments();
633
+ // No-op in the normal case (init segment resolved long ago) - only
634
+ // fires if the stream ended without ever emitting ftyp+moov.
635
+ this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
509
636
  this.options.onClose?.();
510
637
  })
511
638
  .catch(async (error) => {
512
639
  this.endFragments();
640
+ // Reject with the pipeline error so an initSegment consumer sees the
641
+ // actual failure instead of the generic "stopped" error from stop().
642
+ this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
643
+ // A stop() in flight or an aborted signal means the owner is tearing
644
+ // the stream down - the pipeline unwinding with an AbortError is the
645
+ // expected shutdown path, not a failure.
646
+ if (this.isDeliberateStop(error)) {
647
+ await this.stop().catch(() => { });
648
+ this.options.onClose?.();
649
+ return;
650
+ }
513
651
  await this.stop();
514
652
  this.options.onClose?.(error);
515
653
  });
516
654
  }
655
+ /**
656
+ * Whether a pipeline rejection was caused by an intentional shutdown.
657
+ *
658
+ * @param error - The pipeline rejection reason
659
+ *
660
+ * @returns True when stop() is in flight or the owner's signal aborted
661
+ *
662
+ * @internal
663
+ */
664
+ isDeliberateStop(error) {
665
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
666
+ }
517
667
  /**
518
668
  * Set up encoders and the fMP4 muxer for a pre-composited frame source and
519
669
  * start encoding. No demuxer or decoder is involved - the provided frames are
@@ -552,13 +702,18 @@ export class FMP4Stream {
552
702
  fps = 30;
553
703
  }
554
704
  // Set the framerate explicitly - without a decoder the encoder would infer
555
- // it from the frame timebase and pick an absurd level. Bound the GOP so
556
- // fragments get keyframes at a sensible cadence.
705
+ // it from the frame timebase and pick an absurd level. Bound the GOP to the
706
+ // fragment duration (2s default) so every fMP4 fragment starts with a
707
+ // keyframe; otherwise frag_duration flushes fragments mid-GOP on a P-frame
708
+ // and strict consumers reject them.
709
+ const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
710
+ const bitrate = this.options.video.bitrate;
557
711
  this.videoEncoder = await Encoder.create(encoderCodec, {
558
712
  options: encoderOptions,
559
713
  configure: (ctx) => {
560
714
  ctx.framerate = new Rational(Math.round(fps), 1);
561
- ctx.gopSize = Math.max(1, Math.round(fps * 2));
715
+ ctx.gopSize = Math.max(1, Math.round(fps * fragSeconds));
716
+ this.applyBitrate(ctx, bitrate);
562
717
  },
563
718
  });
564
719
  }
@@ -571,18 +726,25 @@ export class FMP4Stream {
571
726
  options: this.options.audio.encoderOptions,
572
727
  });
573
728
  }
729
+ this.throwIfStopRequested();
574
730
  this.output = await this.createOutput();
731
+ this.throwIfStopRequested();
575
732
  // eslint-disable-next-line prefer-const
576
733
  let control;
577
734
  const shouldStop = () => control?.isStopped() ?? false;
578
735
  const tasks = [];
736
+ // A frame source has no demuxer for stop() to interrupt, so wrap it to make
737
+ // a silent source releasable instead of blocking teardown forever.
738
+ const stopped = this.stopSignal.promise;
579
739
  if (source.video && this.videoEncoder) {
580
740
  const streamIndex = this.output.addStream(this.videoEncoder);
581
- tasks.push(consumeStreamInParallel(this.videoEncoder.packets(source.video), this.output, streamIndex, shouldStop));
741
+ const video = interruptibleFrameSource(source.video, stopped);
742
+ tasks.push(consumeStreamInParallel(this.videoEncoder.packets(video), this.output, streamIndex, shouldStop));
582
743
  }
583
744
  if (source.audio && this.audioFilter && this.audioEncoder) {
584
745
  const streamIndex = this.output.addStream(this.audioEncoder);
585
- tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(source.audio)), this.output, streamIndex, shouldStop));
746
+ const audio = interruptibleFrameSource(source.audio, stopped);
747
+ tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
586
748
  }
587
749
  // Close the shared muxer only after ALL streams are done (flushes the trailer).
588
750
  const run = Promise.all(tasks).then(async () => {
@@ -611,46 +773,102 @@ export class FMP4Stream {
611
773
  * ```
612
774
  */
613
775
  async stop() {
614
- this.endFragments();
776
+ // Memoized: concurrent stop() calls share one teardown instead of
777
+ // double-closing native resources.
778
+ this.stopPromise ??= this.doStop().finally(() => {
779
+ this.stopPromise = undefined;
780
+ });
781
+ await this.stopPromise;
782
+ }
783
+ /**
784
+ * Perform the actual teardown (see {@link stop}).
785
+ *
786
+ * @internal
787
+ */
788
+ async doStop() {
789
+ // An in-flight start() must unwind before teardown - otherwise it would
790
+ // keep building the pipeline on top of freed resources and leave an
791
+ // orphaned transcode running forever. The flag makes doStart() bail at its
792
+ // next checkpoint, the abort unblocks a startup stuck in the input open.
793
+ this.stopRequested = true;
794
+ this.startAbort?.abort();
795
+ this.startAbort = undefined;
796
+ // Unblocks a frame source that went silent, so the completion barrier below
797
+ // cannot wait on a pull that will never settle.
798
+ this.stopSignal.resolve();
799
+ const inflightStart = this.startPromise;
615
800
  // Allow a fresh start() after stopping.
616
801
  this.startPromise = undefined;
617
- // Stop pipeline if running and wait for completion. Swallow a rejected
618
- // completion - the error is already surfaced via the onClose(error) path;
619
- // we just need cleanup to proceed.
620
- if (this.pipeline && !this.pipeline.isStopped()) {
621
- this.pipeline.stop();
802
+ if (inflightStart) {
622
803
  try {
623
- await this.pipeline.completion;
804
+ await inflightStart;
624
805
  }
625
806
  catch {
626
- // Pipeline errored - proceed with teardown regardless.
627
- }
628
- this.pipeline = undefined;
629
- }
630
- // Close all resources
631
- await this.input?.close();
632
- this.input = undefined;
633
- this.videoDecoder?.close();
634
- this.videoDecoder = undefined;
635
- this.videoFilter?.close();
636
- this.videoFilter = undefined;
637
- this.videoEncoder?.close();
638
- this.videoEncoder = undefined;
639
- this.audioDecoder?.close();
640
- this.audioDecoder = undefined;
641
- this.audioFilter?.close();
642
- this.audioFilter = undefined;
643
- this.audioEncoder?.close();
644
- this.audioEncoder = undefined;
645
- this.hardwareContext?.dispose();
646
- this.hardwareContext = undefined;
647
- await this.output?.close();
648
- this.output = undefined;
649
- this._initSegment = null;
650
- this._initSegmentResolve = null;
651
- this._initSegmentPromise = null;
652
- this._ftypData = null;
653
- this._moovData = null;
807
+ // Aborted or failed startup - resources are torn down below either way.
808
+ }
809
+ }
810
+ try {
811
+ this.endFragments();
812
+ // A consumer awaiting the init segment must not hang forever.
813
+ this.rejectInitSegment(new Error('FMP4Stream stopped before init segment was produced'));
814
+ // Remove the abort listener so it does not accumulate across restarts.
815
+ if (this.abortHandler) {
816
+ this.signal?.removeEventListener('abort', this.abortHandler);
817
+ this.abortHandler = undefined;
818
+ }
819
+ // Stop pipeline if running and wait for completion. Swallow a rejected
820
+ // completion - the error is already surfaced via the onClose(error) path;
821
+ // we just need cleanup to proceed.
822
+ const pipeline = this.pipeline;
823
+ if (pipeline) {
824
+ if (!pipeline.isStopped()) {
825
+ pipeline.stop();
826
+ }
827
+ try {
828
+ await pipeline.completion;
829
+ }
830
+ catch {
831
+ // Pipeline errored - proceed with teardown regardless.
832
+ }
833
+ if (this.pipeline === pipeline) {
834
+ this.pipeline = undefined;
835
+ }
836
+ }
837
+ // Close all resources. The output goes FIRST: its async write worker may
838
+ // still be draining (header init reads the input's stream parameters),
839
+ // so freeing the input before the muxer has fully settled is a
840
+ // use-after-free on the worker thread.
841
+ await this.output?.close();
842
+ this.output = undefined;
843
+ this.videoDecoder?.close();
844
+ this.videoDecoder = undefined;
845
+ this.videoFilter?.close();
846
+ this.videoFilter = undefined;
847
+ this.videoEncoder?.close();
848
+ this.videoEncoder = undefined;
849
+ this.audioDecoder?.close();
850
+ this.audioDecoder = undefined;
851
+ this.audioFilter?.close();
852
+ this.audioFilter = undefined;
853
+ this.audioEncoder?.close();
854
+ this.audioEncoder = undefined;
855
+ this.hardwareContext?.dispose();
856
+ this.hardwareContext = undefined;
857
+ await this.input?.close();
858
+ this.input = undefined;
859
+ // Reset parser and init segment state so a restart parses a clean stream.
860
+ this.incompleteBoxBuffer = null;
861
+ this.pendingFragment = [];
862
+ this._initSegment = null;
863
+ this._initSegmentResolve = null;
864
+ this._initSegmentReject = null;
865
+ this._initSegmentPromise = null;
866
+ this._ftypData = null;
867
+ this._moovData = null;
868
+ }
869
+ finally {
870
+ this.stopRequested = false;
871
+ }
654
872
  }
655
873
  /**
656
874
  * Async generator that yields media fragments (moof+mdat chunks).
@@ -663,8 +881,16 @@ export class FMP4Stream {
663
881
  *
664
882
  * The generator completes when the stream stops or the pipeline ends.
665
883
  *
884
+ * Only a single consumer is supported: a second concurrent fragments() call
885
+ * would silently strand the first consumer, so it throws instead. The
886
+ * backlog is bounded by `maxQueuedFragments` - if the consumer falls behind
887
+ * a live source, the oldest fragments are dropped (see
888
+ * {@link droppedFragments}).
889
+ *
666
890
  * @yields {FMP4Fragment} Media fragment with data buffer and box info
667
891
  *
892
+ * @throws {Error} If another fragments() iterator is already active
893
+ *
668
894
  * @example
669
895
  * ```typescript
670
896
  * const stream = FMP4Stream.create('rtsp://camera/stream', {
@@ -681,6 +907,9 @@ export class FMP4Stream {
681
907
  * ```
682
908
  */
683
909
  async *fragments() {
910
+ if (this.fragmentQueue) {
911
+ throw new Error('fragments() supports only a single consumer; the previous iterator is still active');
912
+ }
684
913
  this.fragmentQueue = { queue: [], resolve: null, done: false };
685
914
  try {
686
915
  while (true) {
@@ -783,6 +1012,18 @@ export class FMP4Stream {
783
1012
  /**
784
1013
  * Process buffer in box mode - buffers until complete boxes are available.
785
1014
  *
1015
+ * The muxer splits writes at arbitrary positions, so a chunk may end
1016
+ * anywhere - including inside a box header. Any trailing partial box
1017
+ * (header or body) is buffered for the next chunk; the parser never
1018
+ * loses bytes regardless of where the split falls.
1019
+ *
1020
+ * Emission is fragment-aligned: a `moof` is held back until its `mdat` has
1021
+ * arrived, then both are emitted as ONE onData call. The avio buffer flushes
1022
+ * at arbitrary points (a fragment larger than the buffer always splits), so
1023
+ * without this a consumer would see a bare `moof` followed by a detached
1024
+ * `mdat` - unplayable framing for fMP4 consumers.
1025
+ * Non-fragment boxes (ftyp, moov, mfra, ...) are emitted as they complete.
1026
+ *
786
1027
  * @param chunk - Incoming data chunk from FFmpeg
787
1028
  *
788
1029
  * @internal
@@ -794,60 +1035,126 @@ export class FMP4Stream {
794
1035
  this.incompleteBoxBuffer = null;
795
1036
  }
796
1037
  let offset = 0;
797
- const boxes = [];
798
- while (offset + 8 <= chunk.length) {
1038
+ let immediate = [];
1039
+ const flushImmediate = () => {
1040
+ if (immediate.length > 0) {
1041
+ this.emitBoxGroup(immediate);
1042
+ immediate = [];
1043
+ }
1044
+ };
1045
+ while (offset < chunk.length) {
1046
+ const remaining = chunk.length - offset;
1047
+ // Partial 32-bit header - buffer the tail for the next chunk. Dropping
1048
+ // it would desynchronize the parser for the rest of the stream.
1049
+ if (remaining < 8) {
1050
+ this.incompleteBoxBuffer = chunk.subarray(offset);
1051
+ break;
1052
+ }
799
1053
  // Read box header
800
- const boxSize = chunk.readUInt32BE(offset);
801
- const boxType = chunk.toString('ascii', offset + 4, offset + 8);
1054
+ const size32 = chunk.readUInt32BE(offset);
1055
+ let headerSize = 8;
1056
+ let boxSize = size32;
1057
+ if (size32 === 1) {
1058
+ // 64-bit box: the actual size follows the type field as a 64-bit
1059
+ // largesize (16-byte header total). movenc emits this for mdat > 4 GB.
1060
+ if (remaining < 16) {
1061
+ this.incompleteBoxBuffer = chunk.subarray(offset);
1062
+ break;
1063
+ }
1064
+ headerSize = 16;
1065
+ boxSize = Number(chunk.readBigUInt64BE(offset + 8));
1066
+ }
1067
+ // Safety check: invalid box size. size==0 ("box extends to end of file")
1068
+ // cannot be framed in a live stream and any size below the header size
1069
+ // is corrupt - recovery is impossible, so drop the rest of this chunk
1070
+ // rather than emit garbage framing downstream.
1071
+ if (boxSize < headerSize) {
1072
+ break;
1073
+ }
802
1074
  // Check if we have the complete box
803
1075
  if (offset + boxSize > chunk.length) {
804
- // Box is incomplete - save for next chunk
1076
+ // Box body is incomplete - save the whole box for the next chunk
805
1077
  this.incompleteBoxBuffer = chunk.subarray(offset);
806
1078
  break;
807
1079
  }
808
- // We have the complete box - parse it
809
- const box = {
810
- type: boxType,
1080
+ const parsed = {
1081
+ type: chunk.toString('ascii', offset + 4, offset + 8),
811
1082
  size: boxSize,
812
- data: chunk.subarray(offset + 8, offset + boxSize),
813
- offset: offset,
1083
+ headerSize,
1084
+ raw: chunk.subarray(offset, offset + boxSize),
814
1085
  };
815
- boxes.push(box);
1086
+ if (parsed.type === 'moof') {
1087
+ flushImmediate();
1088
+ if (this.pendingFragment.length > 0) {
1089
+ // a moof without a following mdat never happens with movenc, emit
1090
+ // the stale fragment as-is rather than silently dropping bytes
1091
+ this.emitBoxGroup(this.pendingFragment);
1092
+ this.pendingFragment = [];
1093
+ }
1094
+ this.pendingFragment.push(parsed);
1095
+ }
1096
+ else if (this.pendingFragment.length > 0) {
1097
+ this.pendingFragment.push(parsed);
1098
+ if (parsed.type === 'mdat') {
1099
+ this.emitBoxGroup(this.pendingFragment);
1100
+ this.pendingFragment = [];
1101
+ }
1102
+ }
1103
+ else {
1104
+ immediate.push(parsed);
1105
+ }
816
1106
  // Move to next box
817
1107
  offset += boxSize;
818
- // Safety check: invalid box size
819
- if (boxSize < 8) {
820
- break;
821
- }
822
1108
  }
823
- // If we have complete boxes, send them to the callback
824
- if (boxes.length > 0) {
825
- const boxData = chunk.subarray(0, offset);
826
- const info = { isComplete: true, boxes };
827
- // onData callback gets everything
828
- this.options.onData(boxData, info);
829
- // Init segment tracking: collect ftyp and moov data
830
- if (!this._initSegment) {
831
- for (const box of boxes) {
832
- if (box.type === 'ftyp' && !this._ftypData) {
833
- this._ftypData = chunk.subarray(box.offset, box.offset + box.size);
834
- }
835
- if (box.type === 'moov' && !this._moovData) {
836
- this._moovData = chunk.subarray(box.offset, box.offset + box.size);
837
- }
1109
+ flushImmediate();
1110
+ }
1111
+ /**
1112
+ * Emit a group of complete boxes as one onData call.
1113
+ *
1114
+ * Concatenates the raw box bytes, rebuilds the box list with offsets
1115
+ * relative to the emitted buffer, tracks the init segment, and feeds the
1116
+ * fragments() queue when the group is a media fragment.
1117
+ *
1118
+ * @param entries - Complete boxes in stream order
1119
+ *
1120
+ * @internal
1121
+ */
1122
+ emitBoxGroup(entries) {
1123
+ const data = entries.length === 1 ? entries[0].raw : Buffer.concat(entries.map((e) => e.raw));
1124
+ let position = 0;
1125
+ const boxes = entries.map((entry) => {
1126
+ const box = {
1127
+ type: entry.type,
1128
+ size: entry.size,
1129
+ data: data.subarray(position + entry.headerSize, position + entry.size),
1130
+ offset: position,
1131
+ };
1132
+ position += entry.size;
1133
+ return box;
1134
+ });
1135
+ const info = { isComplete: true, boxes };
1136
+ this.options.onData(data, info);
1137
+ // Init segment tracking: collect ftyp and moov data
1138
+ if (!this._initSegment) {
1139
+ for (const box of boxes) {
1140
+ if (box.type === 'ftyp' && !this._ftypData) {
1141
+ this._ftypData = data.subarray(box.offset, box.offset + box.size);
838
1142
  }
839
- if (this._ftypData && this._moovData) {
840
- this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
841
- this._initSegmentResolve?.(this._initSegment);
842
- this._initSegmentResolve = null;
1143
+ if (box.type === 'moov' && !this._moovData) {
1144
+ this._moovData = data.subarray(box.offset, box.offset + box.size);
843
1145
  }
844
1146
  }
845
- // fragments() generator gets only media fragments (moof+mdat)
846
- const isMediaFragment = boxes.some((b) => b.type === 'moof');
847
- if (isMediaFragment) {
848
- this.pushFragment(boxData, info);
1147
+ if (this._ftypData && this._moovData) {
1148
+ this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
1149
+ this._initSegmentResolve?.(this._initSegment);
1150
+ this._initSegmentResolve = null;
1151
+ this._initSegmentReject = null;
849
1152
  }
850
1153
  }
1154
+ // fragments() generator gets only media fragments (moof+mdat)
1155
+ if (boxes.some((b) => b.type === 'moof')) {
1156
+ this.pushFragment(data, info);
1157
+ }
851
1158
  }
852
1159
  /**
853
1160
  * Push a fragment to the fragment queue for the async generator.
@@ -868,8 +1175,34 @@ export class FMP4Stream {
868
1175
  }
869
1176
  else {
870
1177
  this.fragmentQueue.queue.push(fragment);
1178
+ // Live sources keep producing regardless of consumer speed - bound the
1179
+ // backlog by dropping the oldest media fragments. The init segment never
1180
+ // enters this queue in box mode (kept separately via initSegment).
1181
+ while (this.fragmentQueue.queue.length > this.options.maxQueuedFragments) {
1182
+ this.fragmentQueue.queue.shift();
1183
+ this._droppedFragments++;
1184
+ }
871
1185
  }
872
1186
  }
1187
+ /**
1188
+ * Reject a pending initSegment promise so consumers never hang.
1189
+ *
1190
+ * No-op when the promise was never requested or has already settled. The
1191
+ * promise is marked as handled to avoid an unhandled rejection when no
1192
+ * consumer is currently awaiting it.
1193
+ *
1194
+ * @param error - Rejection reason (pipeline error or stop-before-init)
1195
+ *
1196
+ * @internal
1197
+ */
1198
+ rejectInitSegment(error) {
1199
+ if (!this._initSegmentReject)
1200
+ return;
1201
+ this._initSegmentPromise?.catch(() => { });
1202
+ this._initSegmentReject(error);
1203
+ this._initSegmentReject = null;
1204
+ this._initSegmentResolve = null;
1205
+ }
873
1206
  /**
874
1207
  * Signal the fragment queue that no more fragments will arrive.
875
1208
  *