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,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';
2
- import { FF_ENCODER_AAC, FF_ENCODER_LIBX264 } from '../constants/encoders.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_FLT, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
2
+ import { FF_ENCODER_AAC, FF_ENCODER_LIBOPUS, 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, avGetMediaTypeString, 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,9 @@ 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';
15
+ import { PacketAgeWatchdog } from './utilities/packet-age-watchdog.js';
14
16
  /**
15
17
  * Target codec strings for fMP4 streaming.
16
18
  */
@@ -22,11 +24,18 @@ export const FMP4_CODECS = {
22
24
  FLAC: 'flac',
23
25
  OPUS: 'opus',
24
26
  };
27
+ /**
28
+ * Shortest fragDuration in seconds the transcoded video GOP follows.
29
+ *
30
+ * @internal
31
+ */
32
+ const MIN_FRAGMENT_GOP = 0.5;
25
33
  /**
26
34
  * High-level fMP4 streaming with automatic codec detection and transcoding.
27
35
  *
28
36
  * Provides fragmented MP4 streaming for clients.
29
- * Automatically transcodes video to H.264 and audio to AAC if not supported by client.
37
+ * Automatically transcodes video to H.264 and audio to AAC (Opus when the client
38
+ * lists Opus but not AAC) if not supported by client.
30
39
  * Client sends supported codecs, server transcodes accordingly.
31
40
  * Essential component for building adaptive streaming servers.
32
41
  *
@@ -83,9 +92,19 @@ export class FMP4Stream {
83
92
  signal;
84
93
  supportedCodecs;
85
94
  incompleteBoxBuffer = null;
95
+ pendingFragment = [];
96
+ abortHandler;
97
+ stopRequested = false;
98
+ discardOutput = false;
99
+ stopPromise;
100
+ startAbort;
101
+ stopSignal = Promise.withResolvers();
86
102
  fragmentQueue = null;
103
+ packetAgeWatchdog;
104
+ _droppedFragments = 0;
87
105
  _initSegment = null;
88
106
  _initSegmentResolve = null;
107
+ _initSegmentReject = null;
89
108
  _initSegmentPromise = null;
90
109
  _ftypData = null;
91
110
  _moovData = null;
@@ -96,9 +115,25 @@ export class FMP4Stream {
96
115
  *
97
116
  * Use {@link create} factory method
98
117
  *
118
+ * @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
119
+ *
99
120
  * @internal
100
121
  */
101
122
  constructor(input, options) {
123
+ const isLiveInput = typeof input === 'string' && /^(rtsps?|rtmp(?:e|s|t|te|ts)?|s?rtp|udp|srt|tcp|tls|sctp):/i.test(input);
124
+ const dtsBackwardThreshold = options.dtsBackwardThreshold ?? 10;
125
+ // Only a source read in real time pairs a gap with the matching wait.
126
+ const dtsForwardThreshold = options.dtsForwardThreshold ?? (isLiveInput ? 10 : 0);
127
+ const maxPacketAge = options.maxPacketAge ?? 60;
128
+ for (const [name, value] of [
129
+ ['dtsBackwardThreshold', dtsBackwardThreshold],
130
+ ['dtsForwardThreshold', dtsForwardThreshold],
131
+ ['maxPacketAge', maxPacketAge],
132
+ ]) {
133
+ if (!Number.isFinite(value) || value < 0) {
134
+ throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${value}`);
135
+ }
136
+ }
102
137
  if (typeof input === 'string') {
103
138
  this.inputUrl = input;
104
139
  }
@@ -114,10 +149,7 @@ export class FMP4Stream {
114
149
  this.inputOptions = {
115
150
  ...options.inputOptions,
116
151
  options: {
117
- flags: 'low_delay',
118
- fflags: 'nobuffer',
119
- // analyzeduration: 0,
120
- // probesize: 32,
152
+ ...(isLiveInput ? { flags: 'low_delay', fflags: 'nobuffer' } : {}),
121
153
  timeout: 10000000,
122
154
  rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
123
155
  ...options.inputOptions?.options,
@@ -134,6 +166,8 @@ export class FMP4Stream {
134
166
  fps: options.video?.fps,
135
167
  width: options.video?.width,
136
168
  height: options.video?.height,
169
+ bitrate: options.video?.bitrate,
170
+ tag: options.video?.tag,
137
171
  encoderOptions: options.video?.encoderOptions ?? {},
138
172
  },
139
173
  audio: {
@@ -141,7 +175,11 @@ export class FMP4Stream {
141
175
  },
142
176
  bufferSize: options.bufferSize ?? 2 * 1024 * 1024,
143
177
  boxMode: options.boxMode ?? false,
178
+ maxQueuedFragments: options.maxQueuedFragments ?? 16,
144
179
  movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
180
+ dtsBackwardThreshold,
181
+ dtsForwardThreshold,
182
+ maxPacketAge,
145
183
  };
146
184
  this.signal = options.signal;
147
185
  // Parse supported codecs
@@ -162,6 +200,8 @@ export class FMP4Stream {
162
200
  *
163
201
  * @returns Configured fMP4 stream instance
164
202
  *
203
+ * @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
204
+ *
165
205
  * @example
166
206
  * ```typescript
167
207
  * // Stream from file with codec negotiation
@@ -189,6 +229,8 @@ export class FMP4Stream {
189
229
  *
190
230
  * Only available when boxMode is enabled.
191
231
  * Resolves once the first ftyp and moov boxes have been received.
232
+ * Rejects if the stream fails or is stopped before the init segment
233
+ * was produced, so awaiting it never hangs.
192
234
  *
193
235
  * @throws {Error} If boxMode is not enabled
194
236
  */
@@ -196,16 +238,39 @@ export class FMP4Stream {
196
238
  if (!this.options.boxMode) {
197
239
  throw new Error('initSegment is only available in box mode');
198
240
  }
199
- this._initSegmentPromise ??= new Promise((resolve) => {
241
+ this._initSegmentPromise ??= new Promise((resolve, reject) => {
200
242
  if (this._initSegment) {
201
243
  resolve(this._initSegment);
202
244
  }
203
245
  else {
204
246
  this._initSegmentResolve = resolve;
247
+ this._initSegmentReject = reject;
205
248
  }
206
249
  });
207
250
  return this._initSegmentPromise;
208
251
  }
252
+ /**
253
+ * Number of media fragments dropped due to consumer backpressure.
254
+ *
255
+ * Incremented whenever the {@link fragments} backlog exceeds
256
+ * `maxQueuedFragments` and the oldest fragment is discarded.
257
+ * Reset to 0 when the stream is (re)started.
258
+ *
259
+ * @returns Total dropped fragments for the current run
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * for await (const fragment of stream.fragments()) {
264
+ * await sendToClient(fragment.data);
265
+ * if (stream.droppedFragments > 0) {
266
+ * console.warn(`Consumer too slow: ${stream.droppedFragments} fragments dropped`);
267
+ * }
268
+ * }
269
+ * ```
270
+ */
271
+ get droppedFragments() {
272
+ return this._droppedFragments;
273
+ }
209
274
  /**
210
275
  * Get the demuxer instance.
211
276
  *
@@ -255,7 +320,7 @@ export class FMP4Stream {
255
320
  parts.push(FMP4_CODECS.H264);
256
321
  }
257
322
  if (this.source.audio) {
258
- parts.push(FMP4_CODECS.AAC);
323
+ parts.push(this.audioTranscodeTarget());
259
324
  }
260
325
  return parts.join(',');
261
326
  }
@@ -299,8 +364,7 @@ export class FMP4Stream {
299
364
  if (audioCodecId) {
300
365
  const needsAudioTranscode = !this.isAudioCodecSupported(audioCodecId);
301
366
  if (needsAudioTranscode) {
302
- // Transcoding to AAC
303
- audioCodec = FMP4_CODECS.AAC;
367
+ audioCodec = this.audioTranscodeTarget();
304
368
  }
305
369
  else if (audioCodecId === AV_CODEC_ID_AAC) {
306
370
  // AAC - use fixed codec string
@@ -322,13 +386,58 @@ export class FMP4Stream {
322
386
  // Combine video and audio codec strings
323
387
  return [videoCodec, audioCodec].filter(Boolean).join(',');
324
388
  }
389
+ /**
390
+ * Video description of the muxed fMP4 output (output codec, RFC 6381 codec string, resolution, fps).
391
+ *
392
+ * Available after {@link start} has opened the input.
393
+ *
394
+ * @returns The output video info, or `undefined` when there is no video track.
395
+ *
396
+ * @example
397
+ * ```typescript
398
+ * const videoInfo = stream.getVideoInfo();
399
+ * if (videoInfo) {
400
+ * console.log(`Codec: ${videoInfo.codec}, Resolution: ${videoInfo.width}x${videoInfo.height}, FPS: ${videoInfo.fps}`);
401
+ * }
402
+ * ```
403
+ */
404
+ getVideoInfo() {
405
+ if (this.source) {
406
+ if (!this.source.video) {
407
+ return undefined;
408
+ }
409
+ return {
410
+ codec: 'h264',
411
+ codecString: FMP4_CODECS.H264,
412
+ width: this.options.video?.width ?? 0,
413
+ height: this.options.video?.height ?? 0,
414
+ fps: this.options.video?.fps ?? 0,
415
+ };
416
+ }
417
+ const videoStream = this.input?.video();
418
+ if (!videoStream) {
419
+ return undefined;
420
+ }
421
+ const codecId = videoStream.codecpar.codecId;
422
+ const transcode = !this.isVideoCodecSupported(codecId);
423
+ const codecString = transcode ? FMP4_CODECS.H264 : (avGetCodecString(videoStream.codecpar) ?? FMP4_CODECS.H264);
424
+ const codec = transcode || codecId === AV_CODEC_ID_H264 ? 'h264' : codecId === AV_CODEC_ID_HEVC ? 'hevc' : codecId === AV_CODEC_ID_AV1 ? 'av1' : 'unknown';
425
+ const rate = videoStream.avgFrameRate;
426
+ return {
427
+ codec,
428
+ codecString,
429
+ width: transcode && this.options.video?.width ? this.options.video.width : videoStream.codecpar.width,
430
+ height: transcode && this.options.video?.height ? this.options.video.height : videoStream.codecpar.height,
431
+ fps: transcode && this.options.video?.fps ? this.options.video.fps : rate.den ? rate.num / rate.den : 0,
432
+ };
433
+ }
325
434
  /**
326
435
  * Start streaming media to fMP4 chunks.
327
436
  *
328
437
  * Begins the media processing pipeline, reading packets from input,
329
438
  * transcoding based on supported codecs, and generating fMP4 chunks.
330
439
  * Video transcodes to H.264 if H.264/H.265 not supported.
331
- * Audio transcodes to AAC if AAC/FLAC/Opus not supported.
440
+ * Audio transcodes to AAC (Opus when only Opus is listed) if the input codec is not supported.
332
441
  * This method returns immediately after starting the pipeline.
333
442
  *
334
443
  * @returns Promise that resolves when pipeline is started
@@ -352,6 +461,8 @@ export class FMP4Stream {
352
461
  async start() {
353
462
  this.startPromise ??= this.doStart().catch((error) => {
354
463
  this.startPromise = undefined;
464
+ // A pending initSegment consumer must not hang when startup fails.
465
+ this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
355
466
  throw error;
356
467
  });
357
468
  await this.startPromise;
@@ -362,11 +473,29 @@ export class FMP4Stream {
362
473
  * @internal
363
474
  */
364
475
  async doStart() {
476
+ // A start racing an in-flight stop() waits for the teardown to finish,
477
+ // otherwise it would build the pipeline on top of freed resources.
478
+ if (this.stopPromise) {
479
+ await this.stopPromise;
480
+ }
365
481
  if (this.pipeline) {
366
482
  return;
367
483
  }
368
484
  this.signal?.throwIfAborted();
369
- this.signal?.addEventListener('abort', () => this.stop(), { once: true });
485
+ // Store the handler so stop() can remove it - otherwise listeners would
486
+ // accumulate across restarts. Teardown errors are swallowed here: they are
487
+ // already surfaced via onClose(error) by the completion handler.
488
+ this.abortHandler = () => {
489
+ this.stop().catch(() => { });
490
+ };
491
+ this.signal?.addEventListener('abort', this.abortHandler, { once: true });
492
+ this._droppedFragments = 0;
493
+ this.discardOutput = false;
494
+ // Re-armed per start so a restart is not torn down by the previous run.
495
+ this.stopSignal = Promise.withResolvers();
496
+ // Lets stop() interrupt a startup that is blocked in the input open
497
+ // (an RTSP connect can take seconds).
498
+ this.startAbort = new AbortController();
370
499
  // Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
371
500
  if (this.source) {
372
501
  await this.startFromFrames();
@@ -377,8 +506,10 @@ export class FMP4Stream {
377
506
  if (!this.inputUrl) {
378
507
  throw new Error('No input URL or Demuxer provided');
379
508
  }
380
- this.input = await Demuxer.open(this.inputUrl, this.inputOptions);
509
+ const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
510
+ this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
381
511
  }
512
+ this.throwIfStopRequested();
382
513
  const videoStream = this.input.video();
383
514
  const audioStream = this.input.audio();
384
515
  // Check if video needs transcoding
@@ -396,6 +527,7 @@ export class FMP4Stream {
396
527
  hardware: this.hardwareContext,
397
528
  exitOnError: false,
398
529
  });
530
+ this.throwIfStopRequested();
399
531
  // Determine if we need filters by comparing with current stream properties
400
532
  const currentWidth = videoStream.codecpar.width;
401
533
  const currentHeight = videoStream.codecpar.height;
@@ -403,6 +535,7 @@ export class FMP4Stream {
403
535
  const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
404
536
  (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
405
537
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
538
+ const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
406
539
  // Create filter chain only if needed
407
540
  if (needsScale || needsFps) {
408
541
  const filterChain = FilterPreset.chain(this.hardwareContext);
@@ -416,13 +549,28 @@ export class FMP4Stream {
416
549
  if (needsFps) {
417
550
  filterChain.filter('fps', { fps: this.options.video.fps });
418
551
  }
552
+ // Software scaling leaves the graph output format unconstrained, so swscale keeps the
553
+ // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
554
+ // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
555
+ // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
556
+ // one format every browser/player can decode via MSE (unlike e.g. the 4:2:2 High profile
557
+ // that a least-loss pick would choose for a yuyv422 source). Fall back to a codec-supported
558
+ // format only if the encoder genuinely can't take yuv420p. Hardware chains negotiate their
559
+ // format through the frames context instead.
560
+ if (needsScale && !this.hardwareContext) {
561
+ const formats = encoderCodec.pixelFormats;
562
+ const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
563
+ const targetFmtName = avGetPixFmtName(targetFmt);
564
+ if (targetFmtName) {
565
+ filterChain.filter('format', { pix_fmts: targetFmtName });
566
+ }
567
+ }
419
568
  this.videoFilter = FilterAPI.create(filterChain.build(), {
420
569
  hardware: this.hardwareContext,
421
570
  });
422
571
  }
423
- const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
424
572
  let encoderOptions = {};
425
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX264) {
573
+ if (encoderCodec.name === FF_ENCODER_LIBX264) {
426
574
  encoderOptions.preset = 'ultrafast';
427
575
  encoderOptions.tune = 'zerolatency';
428
576
  }
@@ -430,36 +578,100 @@ export class FMP4Stream {
430
578
  ...encoderOptions,
431
579
  ...this.options.video.encoderOptions,
432
580
  };
581
+ const effectiveFps = this.options.video.fps ?? currentFps;
582
+ const fpsForGop = isFinite(effectiveFps) && effectiveFps > 0 ? effectiveFps : 30;
583
+ const bitrate = this.options.video.bitrate;
433
584
  this.videoEncoder = await Encoder.create(encoderCodec, {
434
585
  decoder: this.videoDecoder,
435
586
  options: encoderOptions,
587
+ configure: (ctx) => {
588
+ ctx.gopSize = this.gopSize(fpsForGop);
589
+ this.applyBitrate(ctx, bitrate);
590
+ },
436
591
  });
592
+ this.throwIfStopRequested();
437
593
  }
438
594
  // Check if audio needs transcoding
439
595
  const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
440
596
  if (needsAudioTranscode) {
441
- // Transcode to AAC
442
597
  this.audioDecoder = await Decoder.create(audioStream, {
443
598
  exitOnError: false,
444
599
  });
445
- const targetSampleRate = 44100;
446
- const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, targetSampleRate, 'stereo').build();
447
- this.audioFilter = FilterAPI.create(filterChain);
448
- this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
449
- decoder: this.audioDecoder,
450
- filter: this.audioFilter,
451
- options: this.options.audio.encoderOptions,
452
- });
600
+ await this.createAudioEncoder(this.audioDecoder, audioStream.codecpar.channels);
601
+ this.throwIfStopRequested();
453
602
  }
454
603
  // Setup output with callback
455
604
  this.output = await this.createOutput();
605
+ this.throwIfStopRequested();
456
606
  this.attachCompletion(this.runPipeline());
457
607
  }
608
+ /**
609
+ * Abort an in-flight startup once stop() was requested.
610
+ *
611
+ * Checked after every await in the startup path: the resources created so
612
+ * far are all assigned to instance fields, so the stop() that is waiting on
613
+ * this startup tears them down the moment it unwinds.
614
+ *
615
+ * @throws {Error} If stop() was called while starting
616
+ *
617
+ * @internal
618
+ */
619
+ throwIfStopRequested() {
620
+ if (this.stopRequested) {
621
+ throw new Error('FMP4Stream stopped during start');
622
+ }
623
+ }
624
+ /**
625
+ * Cap the video bitrate on the encoder context.
626
+ *
627
+ * Applies a VBV rate cap (maxrate + a 1s buffer) WITHOUT setting a target
628
+ * bitrate, so the encoder keeps its default constant-quality mode but can
629
+ * never exceed the negotiated ceiling. Setting the bitrate as an ABR target
630
+ * instead would push a normally-lighter stream UP to fill it, overshooting on
631
+ * bursts - the opposite of what strict rate-capped consumers want.
632
+ * No-op when no bitrate was requested.
633
+ *
634
+ * @param ctx - Encoder codec context
635
+ *
636
+ * @param bitrate - Maximum bitrate in bits per second, or undefined
637
+ *
638
+ * @internal
639
+ */
640
+ applyBitrate(ctx, bitrate) {
641
+ if (!bitrate || bitrate <= 0) {
642
+ return;
643
+ }
644
+ ctx.rcMaxRate = BigInt(Math.round(bitrate));
645
+ // 1s buffer keeps per-fragment size close to the cap instead of allowing
646
+ // multi-second bursts over it.
647
+ ctx.rcBufferSize = Math.round(bitrate);
648
+ }
649
+ /**
650
+ * GOP size for the video encoder that starts every fragment with a keyframe.
651
+ *
652
+ * frag_duration flushes a fragment once it is that long, mid-GOP on a
653
+ * P-frame unless the GOP has the same length, and strict consumers reject
654
+ * such fragments. Shorter fragments (the 1 µs default flushes after every
655
+ * frame) are not matched: a GOP of a few frames multiplies the bitrate, so
656
+ * below MIN_FRAGMENT_GOP the GOP is 2 s, as with a fragDuration of 0.
657
+ *
658
+ * @param fps - Output frame rate
659
+ *
660
+ * @returns GOP size in frames
661
+ *
662
+ * @internal
663
+ */
664
+ gopSize(fps) {
665
+ const fragSeconds = this.options.fragDuration / 1_000_000;
666
+ const seconds = fragSeconds >= MIN_FRAGMENT_GOP ? fragSeconds : 2;
667
+ return Math.max(1, Math.round(fps * seconds));
668
+ }
458
669
  /**
459
670
  * Create the fMP4 output muxer with the fragment-emitting write callback.
460
671
  *
461
672
  * Uses `this.input` when present (demuxer path) and no input for the
462
- * frame-source path.
673
+ * frame-source path. Attaches a fresh packet age watchdog unless
674
+ * `maxPacketAge` is 0.
463
675
  *
464
676
  * @returns The configured muxer
465
677
  *
@@ -468,12 +680,17 @@ export class FMP4Stream {
468
680
  async createOutput() {
469
681
  const cb = {
470
682
  write: (buffer) => {
683
+ // Swallows what the teardown of a failed session flushes (see attachCompletion).
684
+ if (this.discardOutput) {
685
+ return buffer.length;
686
+ }
471
687
  if (this.options.boxMode) {
472
688
  // Box mode: buffer until we have complete boxes
473
689
  this.processBoxMode(buffer);
474
690
  }
475
691
  else {
476
692
  // Chunk mode: send raw data immediately
693
+ this.packetAgeWatchdog?.consumeChunk(buffer);
477
694
  const info = { isComplete: false, boxes: [] };
478
695
  this.options.onData(buffer, info);
479
696
  this.pushFragment(buffer, info);
@@ -481,39 +698,126 @@ export class FMP4Stream {
481
698
  return buffer.length;
482
699
  },
483
700
  };
484
- return await Muxer.open(cb, {
701
+ const output = await Muxer.open(cb, {
485
702
  input: this.input,
486
703
  format: 'mp4',
487
704
  bufferSize: this.options.bufferSize,
488
705
  exitOnError: false,
706
+ dtsBackwardThreshold: this.options.dtsBackwardThreshold,
707
+ dtsForwardThreshold: this.options.dtsForwardThreshold,
708
+ configure: (fmt) => {
709
+ const tag = this.options.video?.tag;
710
+ if (!tag) {
711
+ return;
712
+ }
713
+ for (const stream of fmt.streams) {
714
+ if (stream.codecpar.codecId === AV_CODEC_ID_HEVC) {
715
+ stream.codecpar.codecTag = tag;
716
+ }
717
+ }
718
+ },
489
719
  options: {
490
720
  movflags: this.options.movFlags,
491
721
  frag_duration: this.options.fragDuration,
492
722
  },
493
723
  });
724
+ // Per session: a restart must not inherit the previous session's backlog.
725
+ if (this.options.maxPacketAge > 0) {
726
+ this.packetAgeWatchdog = new PacketAgeWatchdog(this.options.maxPacketAge, (index) => {
727
+ const codecType = output.getStream(index)?.codecpar.codecType;
728
+ return codecType === undefined ? '' : (avGetMediaTypeString(codecType) ?? '');
729
+ });
730
+ output.setPacketObserver(this.packetAgeWatchdog);
731
+ }
732
+ return output;
494
733
  }
495
734
  /**
496
735
  * Wire the pipeline completion to the fragment/close callbacks.
497
736
  *
498
737
  * On success ends the fragment iterator and invokes `onClose()`; on error
499
- * stops the stream and invokes `onClose(error)`. Runs in the background.
738
+ * stops the stream, discarding the output the muxer still holds, and invokes
739
+ * `onClose(error)`. Either way `onClose` runs exactly once and the detached
740
+ * chain never rejects. Runs in the background.
500
741
  *
501
742
  * @param completion - The pipeline completion promise
502
743
  *
503
744
  * @internal
504
745
  */
505
746
  attachCompletion(completion) {
506
- completion
507
- .then(() => {
747
+ // One then() with both handlers: a throwing onClose() in the success path
748
+ // must not fall through into the error path and fire onClose a second time.
749
+ completion.then(() => {
508
750
  this.endFragments();
509
- this.options.onClose?.();
510
- })
511
- .catch(async (error) => {
751
+ // No-op in the normal case (init segment resolved long ago) - only
752
+ // fires if the stream ended without ever emitting ftyp+moov.
753
+ this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
754
+ this.emitClose();
755
+ }, async (reason) => {
756
+ const error = reason instanceof Error ? reason : new Error(String(reason));
512
757
  this.endFragments();
513
- await this.stop();
514
- this.options.onClose?.(error);
758
+ // Reject with the pipeline error so an initSegment consumer sees the
759
+ // actual failure instead of the generic "stopped" error from stop().
760
+ this.rejectInitSegment(error);
761
+ // A stop() in flight or an aborted signal means the owner is tearing
762
+ // the stream down - the pipeline unwinding with an AbortError is the
763
+ // expected shutdown path, not a failure. Decided before the stop()
764
+ // below, which would make every failure look deliberate.
765
+ const deliberate = this.isDeliberateStop(reason);
766
+ // Closing the output flushes the muxer's backlog and the trailer. After
767
+ // a failure that backlog is suspect: when a backward jump ends the
768
+ // session, the packets the timeline had jumped ahead to are still
769
+ // queued and would leave as a fragment placed that far ahead; after a
770
+ // stall it holds up to maxPacketAge of retained samples. Nothing is
771
+ // emitted past the failure, through onData or fragments(). A
772
+ // deliberate stop keeps its tail.
773
+ if (!deliberate) {
774
+ this.discardOutput = true;
775
+ }
776
+ // Release the input (e.g. the RTSP connection) before reporting, so an
777
+ // owner that restarts from onClose never overlaps a live session. A
778
+ // teardown failure must not swallow the onClose call: without it the
779
+ // owner never learns the stream is gone.
780
+ try {
781
+ await this.stop();
782
+ }
783
+ catch {
784
+ // doStop() already released whatever it could.
785
+ }
786
+ this.emitClose(deliberate ? undefined : error);
515
787
  });
516
788
  }
789
+ /**
790
+ * Invoke the owner's onClose callback.
791
+ *
792
+ * The completion chain runs detached, so an exception from the callback
793
+ * could only surface as an unhandled rejection - it is logged instead.
794
+ *
795
+ * @param error - Pipeline error, or undefined for a clean end
796
+ *
797
+ * @internal
798
+ */
799
+ emitClose(error) {
800
+ try {
801
+ this.options.onClose(error);
802
+ }
803
+ catch (callbackError) {
804
+ // The stream is already closed; logged so a broken restart path in the
805
+ // owner does not vanish silently.
806
+ console.error('[FMP4Stream] onClose callback threw:', callbackError);
807
+ }
808
+ }
809
+ /**
810
+ * Whether a pipeline rejection was caused by an intentional shutdown.
811
+ *
812
+ * @param error - The pipeline rejection reason
813
+ *
814
+ * @returns True when stop() is in flight or the owner's signal aborted
815
+ *
816
+ * @internal
817
+ */
818
+ isDeliberateStop(error) {
819
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
820
+ }
517
821
  /**
518
822
  * Set up encoders and the fMP4 muxer for a pre-composited frame source and
519
823
  * start encoding. No demuxer or decoder is involved - the provided frames are
@@ -552,37 +856,39 @@ export class FMP4Stream {
552
856
  fps = 30;
553
857
  }
554
858
  // 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.
859
+ // it from the frame timebase and pick an absurd level.
860
+ const bitrate = this.options.video.bitrate;
557
861
  this.videoEncoder = await Encoder.create(encoderCodec, {
558
862
  options: encoderOptions,
559
863
  configure: (ctx) => {
560
864
  ctx.framerate = new Rational(Math.round(fps), 1);
561
- ctx.gopSize = Math.max(1, Math.round(fps * 2));
865
+ ctx.gopSize = this.gopSize(fps);
866
+ this.applyBitrate(ctx, bitrate);
562
867
  },
563
868
  });
564
869
  }
565
- // Audio encoder (AAC), resampling via aformat like the demuxer path.
566
870
  if (source.audio) {
567
- const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build();
568
- this.audioFilter = FilterAPI.create(filterChain);
569
- this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
570
- filter: this.audioFilter,
571
- options: this.options.audio.encoderOptions,
572
- });
871
+ await this.createAudioEncoder();
573
872
  }
873
+ this.throwIfStopRequested();
574
874
  this.output = await this.createOutput();
875
+ this.throwIfStopRequested();
575
876
  // eslint-disable-next-line prefer-const
576
877
  let control;
577
878
  const shouldStop = () => control?.isStopped() ?? false;
578
879
  const tasks = [];
880
+ // A frame source has no demuxer for stop() to interrupt, so wrap it to make
881
+ // a silent source releasable instead of blocking teardown forever.
882
+ const stopped = this.stopSignal.promise;
579
883
  if (source.video && this.videoEncoder) {
580
884
  const streamIndex = this.output.addStream(this.videoEncoder);
581
- tasks.push(consumeStreamInParallel(this.videoEncoder.packets(source.video), this.output, streamIndex, shouldStop));
885
+ const video = interruptibleFrameSource(source.video, stopped);
886
+ tasks.push(consumeStreamInParallel(this.videoEncoder.packets(video), this.output, streamIndex, shouldStop));
582
887
  }
583
888
  if (source.audio && this.audioFilter && this.audioEncoder) {
584
889
  const streamIndex = this.output.addStream(this.audioEncoder);
585
- tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(source.audio)), this.output, streamIndex, shouldStop));
890
+ const audio = interruptibleFrameSource(source.audio, stopped);
891
+ tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
586
892
  }
587
893
  // Close the shared muxer only after ALL streams are done (flushes the trailer).
588
894
  const run = Promise.all(tasks).then(async () => {
@@ -597,7 +903,8 @@ export class FMP4Stream {
597
903
  *
598
904
  * Stops the pipeline, closes output, and releases all FFmpeg resources.
599
905
  * Safe to call multiple times. After stopping, you can call start() again
600
- * to restart the stream.
906
+ * to restart the stream. Resolves even if the pipeline had failed; that
907
+ * error is reported through `onClose(error)`.
601
908
  *
602
909
  * @example
603
910
  * ```typescript
@@ -611,46 +918,117 @@ export class FMP4Stream {
611
918
  * ```
612
919
  */
613
920
  async stop() {
614
- this.endFragments();
921
+ // Memoized: concurrent stop() calls share one teardown instead of
922
+ // double-closing native resources.
923
+ this.stopPromise ??= this.doStop().finally(() => {
924
+ this.stopPromise = undefined;
925
+ });
926
+ await this.stopPromise;
927
+ }
928
+ /**
929
+ * Perform the actual teardown (see {@link stop}).
930
+ *
931
+ * @internal
932
+ */
933
+ async doStop() {
934
+ // An in-flight start() must unwind before teardown - otherwise it would
935
+ // keep building the pipeline on top of freed resources and leave an
936
+ // orphaned transcode running forever. The flag makes doStart() bail at its
937
+ // next checkpoint, the abort unblocks a startup stuck in the input open.
938
+ this.stopRequested = true;
939
+ this.startAbort?.abort();
940
+ this.startAbort = undefined;
941
+ // Unblocks a frame source that went silent, so the completion barrier below
942
+ // cannot wait on a pull that will never settle.
943
+ this.stopSignal.resolve();
944
+ const inflightStart = this.startPromise;
615
945
  // Allow a fresh start() after stopping.
616
946
  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();
947
+ if (inflightStart) {
622
948
  try {
623
- await this.pipeline.completion;
949
+ await inflightStart;
624
950
  }
625
951
  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;
952
+ // Aborted or failed startup - resources are torn down below either way.
953
+ }
954
+ }
955
+ try {
956
+ this.endFragments();
957
+ // A consumer awaiting the init segment must not hang forever.
958
+ this.rejectInitSegment(new Error('FMP4Stream stopped before init segment was produced'));
959
+ // Remove the abort listener so it does not accumulate across restarts.
960
+ if (this.abortHandler) {
961
+ this.signal?.removeEventListener('abort', this.abortHandler);
962
+ this.abortHandler = undefined;
963
+ }
964
+ // Stop pipeline if running and wait for completion. Swallow a rejected
965
+ // completion - the error is already surfaced via the onClose(error) path;
966
+ // we just need cleanup to proceed.
967
+ const pipeline = this.pipeline;
968
+ if (pipeline) {
969
+ if (!pipeline.isStopped()) {
970
+ pipeline.stop();
971
+ }
972
+ try {
973
+ await pipeline.completion;
974
+ }
975
+ catch {
976
+ // Pipeline errored - proceed with teardown regardless.
977
+ }
978
+ if (this.pipeline === pipeline) {
979
+ this.pipeline = undefined;
980
+ }
981
+ }
982
+ // Close all resources. The output goes FIRST: its async write worker may
983
+ // still be draining (header init reads the input's stream parameters),
984
+ // so freeing the input before the muxer has fully settled is a
985
+ // use-after-free on the worker thread.
986
+ // After a failure nothing the output emits is used (see attachCompletion),
987
+ // so the trailer is skipped instead of flushed into the void.
988
+ if (this.discardOutput) {
989
+ this.output?.discardOnClose();
990
+ }
991
+ // close() rethrows a failed background write after it freed the muxer.
992
+ // That error already ended the pipeline and reaches the owner through
993
+ // onClose(error), so it must not abort the teardown here and leak the
994
+ // codecs and the input (an RTSP connection) below.
995
+ try {
996
+ await this.output?.close();
997
+ }
998
+ catch {
999
+ // Surfaced through the pipeline completion.
1000
+ }
1001
+ this.output = undefined;
1002
+ this.packetAgeWatchdog = undefined;
1003
+ this.videoDecoder?.close();
1004
+ this.videoDecoder = undefined;
1005
+ this.videoFilter?.close();
1006
+ this.videoFilter = undefined;
1007
+ this.videoEncoder?.close();
1008
+ this.videoEncoder = undefined;
1009
+ this.audioDecoder?.close();
1010
+ this.audioDecoder = undefined;
1011
+ this.audioFilter?.close();
1012
+ this.audioFilter = undefined;
1013
+ this.audioEncoder?.close();
1014
+ this.audioEncoder = undefined;
1015
+ this.hardwareContext?.dispose();
1016
+ this.hardwareContext = undefined;
1017
+ await this.input?.close();
1018
+ this.input = undefined;
1019
+ // Reset parser and init segment state so a restart parses a clean stream.
1020
+ this.incompleteBoxBuffer = null;
1021
+ this.pendingFragment = [];
1022
+ this._initSegment = null;
1023
+ this._initSegmentResolve = null;
1024
+ this._initSegmentReject = null;
1025
+ this._initSegmentPromise = null;
1026
+ this._ftypData = null;
1027
+ this._moovData = null;
1028
+ }
1029
+ finally {
1030
+ this.stopRequested = false;
1031
+ }
654
1032
  }
655
1033
  /**
656
1034
  * Async generator that yields media fragments (moof+mdat chunks).
@@ -663,8 +1041,16 @@ export class FMP4Stream {
663
1041
  *
664
1042
  * The generator completes when the stream stops or the pipeline ends.
665
1043
  *
1044
+ * Only a single consumer is supported: a second concurrent fragments() call
1045
+ * would silently strand the first consumer, so it throws instead. The
1046
+ * backlog is bounded by `maxQueuedFragments` - if the consumer falls behind
1047
+ * a live source, the oldest fragments are dropped (see
1048
+ * {@link droppedFragments}).
1049
+ *
666
1050
  * @yields {FMP4Fragment} Media fragment with data buffer and box info
667
1051
  *
1052
+ * @throws {Error} If another fragments() iterator is already active
1053
+ *
668
1054
  * @example
669
1055
  * ```typescript
670
1056
  * const stream = FMP4Stream.create('rtsp://camera/stream', {
@@ -681,6 +1067,9 @@ export class FMP4Stream {
681
1067
  * ```
682
1068
  */
683
1069
  async *fragments() {
1070
+ if (this.fragmentQueue) {
1071
+ throw new Error('fragments() supports only a single consumer; the previous iterator is still active');
1072
+ }
684
1073
  this.fragmentQueue = { queue: [], resolve: null, done: false };
685
1074
  try {
686
1075
  while (true) {
@@ -759,6 +1148,47 @@ export class FMP4Stream {
759
1148
  }
760
1149
  return false;
761
1150
  }
1151
+ /**
1152
+ * Codec that unsupported audio is transcoded to.
1153
+ *
1154
+ * @returns Opus when the client lists Opus but not AAC, otherwise AAC
1155
+ *
1156
+ * @internal
1157
+ */
1158
+ audioTranscodeTarget() {
1159
+ return this.supportedCodecs.has(FMP4_CODECS.OPUS) && !this.supportedCodecs.has(FMP4_CODECS.AAC) ? FMP4_CODECS.OPUS : FMP4_CODECS.AAC;
1160
+ }
1161
+ /**
1162
+ * Create the audio filter and encoder for the transcode target.
1163
+ *
1164
+ * AAC is resampled to 44.1 kHz stereo. Opus is resampled to 48 kHz, the only
1165
+ * rate it transmits, keeps mono input mono and uses 20 ms frames.
1166
+ *
1167
+ * @param decoder - Input audio decoder, absent for a frame source
1168
+ *
1169
+ * @param channels - Input channel count, 0 when unknown
1170
+ *
1171
+ * @internal
1172
+ */
1173
+ async createAudioEncoder(decoder, channels = 0) {
1174
+ if (this.audioTranscodeTarget() === FMP4_CODECS.OPUS) {
1175
+ this.audioFilter = FilterAPI.create(FilterPreset.chain()
1176
+ .aformat(AV_SAMPLE_FMT_FLT, 48000, channels === 1 ? 'mono' : 'stereo')
1177
+ .build());
1178
+ this.audioEncoder = await Encoder.create(FF_ENCODER_LIBOPUS, {
1179
+ decoder,
1180
+ filter: this.audioFilter,
1181
+ options: { frame_duration: 20, ...this.options.audio.encoderOptions },
1182
+ });
1183
+ return;
1184
+ }
1185
+ this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build());
1186
+ this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
1187
+ decoder,
1188
+ filter: this.audioFilter,
1189
+ options: this.options.audio.encoderOptions,
1190
+ });
1191
+ }
762
1192
  /**
763
1193
  * Check if audio codec is supported.
764
1194
  *
@@ -783,6 +1213,18 @@ export class FMP4Stream {
783
1213
  /**
784
1214
  * Process buffer in box mode - buffers until complete boxes are available.
785
1215
  *
1216
+ * The muxer splits writes at arbitrary positions, so a chunk may end
1217
+ * anywhere - including inside a box header. Any trailing partial box
1218
+ * (header or body) is buffered for the next chunk; the parser never
1219
+ * loses bytes regardless of where the split falls.
1220
+ *
1221
+ * Emission is fragment-aligned: a `moof` is held back until its `mdat` has
1222
+ * arrived, then both are emitted as ONE onData call. The avio buffer flushes
1223
+ * at arbitrary points (a fragment larger than the buffer always splits), so
1224
+ * without this a consumer would see a bare `moof` followed by a detached
1225
+ * `mdat` - unplayable framing for fMP4 consumers.
1226
+ * Non-fragment boxes (ftyp, moov, mfra, ...) are emitted as they complete.
1227
+ *
786
1228
  * @param chunk - Incoming data chunk from FFmpeg
787
1229
  *
788
1230
  * @internal
@@ -794,60 +1236,132 @@ export class FMP4Stream {
794
1236
  this.incompleteBoxBuffer = null;
795
1237
  }
796
1238
  let offset = 0;
797
- const boxes = [];
798
- while (offset + 8 <= chunk.length) {
1239
+ let immediate = [];
1240
+ const flushImmediate = () => {
1241
+ if (immediate.length > 0) {
1242
+ this.emitBoxGroup(immediate);
1243
+ immediate = [];
1244
+ }
1245
+ };
1246
+ while (offset < chunk.length) {
1247
+ const remaining = chunk.length - offset;
1248
+ // Partial 32-bit header - buffer the tail for the next chunk. Dropping
1249
+ // it would desynchronize the parser for the rest of the stream.
1250
+ if (remaining < 8) {
1251
+ this.incompleteBoxBuffer = chunk.subarray(offset);
1252
+ break;
1253
+ }
799
1254
  // Read box header
800
- const boxSize = chunk.readUInt32BE(offset);
801
- const boxType = chunk.toString('ascii', offset + 4, offset + 8);
1255
+ const size32 = chunk.readUInt32BE(offset);
1256
+ let headerSize = 8;
1257
+ let boxSize = size32;
1258
+ if (size32 === 1) {
1259
+ // 64-bit box: the actual size follows the type field as a 64-bit
1260
+ // largesize (16-byte header total). movenc emits this for mdat > 4 GB.
1261
+ if (remaining < 16) {
1262
+ this.incompleteBoxBuffer = chunk.subarray(offset);
1263
+ break;
1264
+ }
1265
+ headerSize = 16;
1266
+ boxSize = Number(chunk.readBigUInt64BE(offset + 8));
1267
+ }
1268
+ // Safety check: invalid box size. size==0 ("box extends to end of file")
1269
+ // cannot be framed in a live stream and any size below the header size
1270
+ // is corrupt - recovery is impossible, so drop the rest of this chunk
1271
+ // rather than emit garbage framing downstream.
1272
+ if (boxSize < headerSize) {
1273
+ // Emitted samples can no longer be counted - stop judging packet ages
1274
+ // instead of mistaking the lost framing for a stalled muxer.
1275
+ this.packetAgeWatchdog?.onFramingLost();
1276
+ break;
1277
+ }
802
1278
  // Check if we have the complete box
803
1279
  if (offset + boxSize > chunk.length) {
804
- // Box is incomplete - save for next chunk
1280
+ // Box body is incomplete - save the whole box for the next chunk
805
1281
  this.incompleteBoxBuffer = chunk.subarray(offset);
806
1282
  break;
807
1283
  }
808
- // We have the complete box - parse it
809
- const box = {
810
- type: boxType,
1284
+ const parsed = {
1285
+ type: chunk.toString('ascii', offset + 4, offset + 8),
811
1286
  size: boxSize,
812
- data: chunk.subarray(offset + 8, offset + boxSize),
813
- offset: offset,
1287
+ headerSize,
1288
+ raw: chunk.subarray(offset, offset + boxSize),
814
1289
  };
815
- boxes.push(box);
1290
+ if (parsed.type === 'moof' || parsed.type === 'moov') {
1291
+ this.packetAgeWatchdog?.consumeBox(parsed.raw);
1292
+ }
1293
+ if (parsed.type === 'moof') {
1294
+ flushImmediate();
1295
+ if (this.pendingFragment.length > 0) {
1296
+ // a moof without a following mdat never happens with movenc, emit
1297
+ // the stale fragment as-is rather than silently dropping bytes
1298
+ this.emitBoxGroup(this.pendingFragment);
1299
+ this.pendingFragment = [];
1300
+ }
1301
+ this.pendingFragment.push(parsed);
1302
+ }
1303
+ else if (this.pendingFragment.length > 0) {
1304
+ this.pendingFragment.push(parsed);
1305
+ if (parsed.type === 'mdat') {
1306
+ this.emitBoxGroup(this.pendingFragment);
1307
+ this.pendingFragment = [];
1308
+ }
1309
+ }
1310
+ else {
1311
+ immediate.push(parsed);
1312
+ }
816
1313
  // Move to next box
817
1314
  offset += boxSize;
818
- // Safety check: invalid box size
819
- if (boxSize < 8) {
820
- break;
821
- }
822
1315
  }
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
- }
1316
+ flushImmediate();
1317
+ }
1318
+ /**
1319
+ * Emit a group of complete boxes as one onData call.
1320
+ *
1321
+ * Concatenates the raw box bytes, rebuilds the box list with offsets
1322
+ * relative to the emitted buffer, tracks the init segment, and feeds the
1323
+ * fragments() queue when the group is a media fragment.
1324
+ *
1325
+ * @param entries - Complete boxes in stream order
1326
+ *
1327
+ * @internal
1328
+ */
1329
+ emitBoxGroup(entries) {
1330
+ const data = entries.length === 1 ? entries[0].raw : Buffer.concat(entries.map((e) => e.raw));
1331
+ let position = 0;
1332
+ const boxes = entries.map((entry) => {
1333
+ const box = {
1334
+ type: entry.type,
1335
+ size: entry.size,
1336
+ data: data.subarray(position + entry.headerSize, position + entry.size),
1337
+ offset: position,
1338
+ };
1339
+ position += entry.size;
1340
+ return box;
1341
+ });
1342
+ const info = { isComplete: true, boxes };
1343
+ this.options.onData(data, info);
1344
+ // Init segment tracking: collect ftyp and moov data
1345
+ if (!this._initSegment) {
1346
+ for (const box of boxes) {
1347
+ if (box.type === 'ftyp' && !this._ftypData) {
1348
+ this._ftypData = data.subarray(box.offset, box.offset + box.size);
838
1349
  }
839
- if (this._ftypData && this._moovData) {
840
- this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
841
- this._initSegmentResolve?.(this._initSegment);
842
- this._initSegmentResolve = null;
1350
+ if (box.type === 'moov' && !this._moovData) {
1351
+ this._moovData = data.subarray(box.offset, box.offset + box.size);
843
1352
  }
844
1353
  }
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);
1354
+ if (this._ftypData && this._moovData) {
1355
+ this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
1356
+ this._initSegmentResolve?.(this._initSegment);
1357
+ this._initSegmentResolve = null;
1358
+ this._initSegmentReject = null;
849
1359
  }
850
1360
  }
1361
+ // fragments() generator gets only media fragments (moof+mdat)
1362
+ if (boxes.some((b) => b.type === 'moof')) {
1363
+ this.pushFragment(data, info);
1364
+ }
851
1365
  }
852
1366
  /**
853
1367
  * Push a fragment to the fragment queue for the async generator.
@@ -868,8 +1382,34 @@ export class FMP4Stream {
868
1382
  }
869
1383
  else {
870
1384
  this.fragmentQueue.queue.push(fragment);
1385
+ // Live sources keep producing regardless of consumer speed - bound the
1386
+ // backlog by dropping the oldest media fragments. The init segment never
1387
+ // enters this queue in box mode (kept separately via initSegment).
1388
+ while (this.fragmentQueue.queue.length > this.options.maxQueuedFragments) {
1389
+ this.fragmentQueue.queue.shift();
1390
+ this._droppedFragments++;
1391
+ }
871
1392
  }
872
1393
  }
1394
+ /**
1395
+ * Reject a pending initSegment promise so consumers never hang.
1396
+ *
1397
+ * No-op when the promise was never requested or has already settled. The
1398
+ * promise is marked as handled to avoid an unhandled rejection when no
1399
+ * consumer is currently awaiting it.
1400
+ *
1401
+ * @param error - Rejection reason (pipeline error or stop-before-init)
1402
+ *
1403
+ * @internal
1404
+ */
1405
+ rejectInitSegment(error) {
1406
+ if (!this._initSegmentReject)
1407
+ return;
1408
+ this._initSegmentPromise?.catch(() => { });
1409
+ this._initSegmentReject(error);
1410
+ this._initSegmentReject = null;
1411
+ this._initSegmentResolve = null;
1412
+ }
873
1413
  /**
874
1414
  * Signal the fragment queue that no more fragments will arrive.
875
1415
  *