node-av 6.2.0-beta.1 → 6.2.0-beta.11

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