node-av 6.2.0-beta.8 → 6.2.0

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 (103) hide show
  1. package/README.md +7 -19
  2. package/dist/api/audio-frame-buffer.d.ts +378 -19
  3. package/dist/api/audio-frame-buffer.js +715 -101
  4. package/dist/api/audio-frame-buffer.js.map +1 -1
  5. package/dist/api/data/test_av1.ivf +0 -0
  6. package/dist/api/data/test_hevc.h265 +0 -0
  7. package/dist/api/data/test_vp9.ivf +0 -0
  8. package/dist/api/decoder.d.ts +117 -2
  9. package/dist/api/decoder.js +290 -23
  10. package/dist/api/decoder.js.map +1 -1
  11. package/dist/api/demuxer.d.ts +19 -3
  12. package/dist/api/demuxer.js +95 -29
  13. package/dist/api/demuxer.js.map +1 -1
  14. package/dist/api/encoder-pool.js +9 -1
  15. package/dist/api/encoder-pool.js.map +1 -1
  16. package/dist/api/encoder.d.ts +100 -6
  17. package/dist/api/encoder.js +249 -108
  18. package/dist/api/encoder.js.map +1 -1
  19. package/dist/api/filter-presets.d.ts +25 -1
  20. package/dist/api/filter-presets.js +63 -3
  21. package/dist/api/filter-presets.js.map +1 -1
  22. package/dist/api/filter.d.ts +62 -3
  23. package/dist/api/filter.js +118 -36
  24. package/dist/api/filter.js.map +1 -1
  25. package/dist/api/fmp4-stream.d.ts +179 -15
  26. package/dist/api/fmp4-stream.js +341 -120
  27. package/dist/api/fmp4-stream.js.map +1 -1
  28. package/dist/api/hardware.d.ts +2 -0
  29. package/dist/api/hardware.js +33 -10
  30. package/dist/api/hardware.js.map +1 -1
  31. package/dist/api/index.d.ts +2 -2
  32. package/dist/api/index.js.map +1 -1
  33. package/dist/api/io-stream.d.ts +3 -2
  34. package/dist/api/io-stream.js +23 -7
  35. package/dist/api/io-stream.js.map +1 -1
  36. package/dist/api/muxer.d.ts +276 -7
  37. package/dist/api/muxer.js +649 -116
  38. package/dist/api/muxer.js.map +1 -1
  39. package/dist/api/pipeline.d.ts +22 -0
  40. package/dist/api/pipeline.js +53 -0
  41. package/dist/api/pipeline.js.map +1 -1
  42. package/dist/api/rtp-stream.d.ts +58 -20
  43. package/dist/api/rtp-stream.js +237 -129
  44. package/dist/api/rtp-stream.js.map +1 -1
  45. package/dist/api/scaler.d.ts +39 -6
  46. package/dist/api/scaler.js +72 -18
  47. package/dist/api/scaler.js.map +1 -1
  48. package/dist/api/utilities/live-decoding.d.ts +15 -0
  49. package/dist/api/utilities/live-decoding.js +32 -0
  50. package/dist/api/utilities/live-decoding.js.map +1 -0
  51. package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
  52. package/dist/api/utilities/packet-age-watchdog.js +594 -0
  53. package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
  54. package/dist/api/utilities/scale-size.d.ts +51 -0
  55. package/dist/api/utilities/scale-size.js +55 -0
  56. package/dist/api/utilities/scale-size.js.map +1 -0
  57. package/dist/api/utilities/video-transcode.d.ts +218 -0
  58. package/dist/api/utilities/video-transcode.js +624 -0
  59. package/dist/api/utilities/video-transcode.js.map +1 -0
  60. package/dist/constants/constants.d.ts +1 -0
  61. package/dist/constants/constants.js +1 -0
  62. package/dist/constants/constants.js.map +1 -1
  63. package/dist/constants/filter-options.d.ts +2 -0
  64. package/dist/ffmpeg/utils.js +7 -4
  65. package/dist/ffmpeg/utils.js.map +1 -1
  66. package/dist/lib/binding.d.ts +10 -1
  67. package/dist/lib/binding.js +25 -16
  68. package/dist/lib/binding.js.map +1 -1
  69. package/dist/lib/bitstream-filter-context.d.ts +2 -0
  70. package/dist/lib/bitstream-filter-context.js +2 -0
  71. package/dist/lib/bitstream-filter-context.js.map +1 -1
  72. package/dist/lib/filter-context.d.ts +3 -0
  73. package/dist/lib/filter-context.js +3 -0
  74. package/dist/lib/filter-context.js.map +1 -1
  75. package/dist/lib/filter-graph-segment.d.ts +3 -2
  76. package/dist/lib/filter-graph-segment.js +3 -2
  77. package/dist/lib/filter-graph-segment.js.map +1 -1
  78. package/dist/lib/filter-graph.d.ts +4 -4
  79. package/dist/lib/filter-graph.js +4 -4
  80. package/dist/lib/filter-inout.d.ts +4 -0
  81. package/dist/lib/filter-inout.js +4 -0
  82. package/dist/lib/filter-inout.js.map +1 -1
  83. package/dist/lib/format-context.d.ts +27 -3
  84. package/dist/lib/format-context.js +30 -6
  85. package/dist/lib/format-context.js.map +1 -1
  86. package/dist/lib/frame.d.ts +6 -2
  87. package/dist/lib/frame.js +11 -3
  88. package/dist/lib/frame.js.map +1 -1
  89. package/dist/lib/io-context.d.ts +4 -2
  90. package/dist/lib/io-context.js +4 -2
  91. package/dist/lib/io-context.js.map +1 -1
  92. package/dist/lib/native-types.d.ts +7 -3
  93. package/dist/lib/output-format.d.ts +42 -0
  94. package/dist/lib/output-format.js +46 -0
  95. package/dist/lib/output-format.js.map +1 -1
  96. package/dist/lib/stream.d.ts +5 -0
  97. package/dist/lib/stream.js +5 -0
  98. package/dist/lib/stream.js.map +1 -1
  99. package/dist/lib/utilities.d.ts +26 -0
  100. package/dist/lib/utilities.js +28 -0
  101. package/dist/lib/utilities.js.map +1 -1
  102. package/install/check.js +14 -16
  103. package/package.json +27 -44
@@ -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_PIX_FMT_YUV420P, 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_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, avGetPixFmtName } from '../lib/utilities.js';
5
+ import { avGetCodecString, avGetMediaTypeString } 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,8 +10,11 @@ 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';
14
- import { pickSupportedPixelFormat } from './utilities/codec-format.js';
13
+ import { consumeStreamInParallel, interruptibleFrameSource, pipeline, PipelineControlImpl } from './pipeline.js';
14
+ import { configureLiveDecoderThreading } from './utilities/live-decoding.js';
15
+ import { PacketAgeWatchdog } from './utilities/packet-age-watchdog.js';
16
+ import { transcodeScale } from './utilities/scale-size.js';
17
+ import { applyVideoBitrate, createVideoTranscodeFilter, findUsableHardwareEncoder, openTranscodeDecoder } from './utilities/video-transcode.js';
15
18
  /**
16
19
  * Target codec strings for fMP4 streaming.
17
20
  */
@@ -23,11 +26,18 @@ export const FMP4_CODECS = {
23
26
  FLAC: 'flac',
24
27
  OPUS: 'opus',
25
28
  };
29
+ /**
30
+ * Shortest fragDuration in seconds the transcoded video GOP follows.
31
+ *
32
+ * @internal
33
+ */
34
+ const MIN_FRAGMENT_GOP = 0.5;
26
35
  /**
27
36
  * High-level fMP4 streaming with automatic codec detection and transcoding.
28
37
  *
29
38
  * Provides fragmented MP4 streaming for clients.
30
- * Automatically transcodes video to H.264 and audio to AAC if not supported by client.
39
+ * Automatically transcodes video to H.264 and audio to AAC (Opus when the client
40
+ * lists Opus but not AAC) if not supported by client.
31
41
  * Client sends supported codecs, server transcodes accordingly.
32
42
  * Essential component for building adaptive streaming servers.
33
43
  *
@@ -69,6 +79,7 @@ export class FMP4Stream {
69
79
  options;
70
80
  inputUrl;
71
81
  inputOptions;
82
+ liveInput;
72
83
  input;
73
84
  source;
74
85
  output;
@@ -87,9 +98,12 @@ export class FMP4Stream {
87
98
  pendingFragment = [];
88
99
  abortHandler;
89
100
  stopRequested = false;
101
+ discardOutput = false;
90
102
  stopPromise;
91
103
  startAbort;
104
+ stopSignal = Promise.withResolvers();
92
105
  fragmentQueue = null;
106
+ packetAgeWatchdog;
93
107
  _droppedFragments = 0;
94
108
  _initSegment = null;
95
109
  _initSegmentResolve = null;
@@ -104,9 +118,26 @@ export class FMP4Stream {
104
118
  *
105
119
  * Use {@link create} factory method
106
120
  *
121
+ * @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
122
+ *
107
123
  * @internal
108
124
  */
109
125
  constructor(input, options) {
126
+ const isLiveInput = typeof input === 'string' && /^(rtsps?|rtmp(?:e|s|t|te|ts)?|s?rtp|udp|srt|tcp|tls|sctp):/i.test(input);
127
+ this.liveInput = isLiveInput;
128
+ const dtsBackwardThreshold = options.dtsBackwardThreshold ?? 10;
129
+ // Only a source read in real time pairs a gap with the matching wait.
130
+ const dtsForwardThreshold = options.dtsForwardThreshold ?? (isLiveInput ? 10 : 0);
131
+ const maxPacketAge = options.maxPacketAge ?? 60;
132
+ for (const [name, value] of [
133
+ ['dtsBackwardThreshold', dtsBackwardThreshold],
134
+ ['dtsForwardThreshold', dtsForwardThreshold],
135
+ ['maxPacketAge', maxPacketAge],
136
+ ]) {
137
+ if (!Number.isFinite(value) || value < 0) {
138
+ throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${value}`);
139
+ }
140
+ }
110
141
  if (typeof input === 'string') {
111
142
  this.inputUrl = input;
112
143
  }
@@ -119,7 +150,6 @@ export class FMP4Stream {
119
150
  this.source = input;
120
151
  }
121
152
  const inputUrl = this.inputUrl ?? '';
122
- const isLiveInput = /^(rtsp|rtmp|rtp|udp|srt|tcp|sctp):/i.test(inputUrl);
123
153
  this.inputOptions = {
124
154
  ...options.inputOptions,
125
155
  options: {
@@ -141,6 +171,8 @@ export class FMP4Stream {
141
171
  width: options.video?.width,
142
172
  height: options.video?.height,
143
173
  bitrate: options.video?.bitrate,
174
+ transcode: options.video?.transcode,
175
+ tag: options.video?.tag,
144
176
  encoderOptions: options.video?.encoderOptions ?? {},
145
177
  },
146
178
  audio: {
@@ -150,6 +182,9 @@ export class FMP4Stream {
150
182
  boxMode: options.boxMode ?? false,
151
183
  maxQueuedFragments: options.maxQueuedFragments ?? 16,
152
184
  movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
185
+ dtsBackwardThreshold,
186
+ dtsForwardThreshold,
187
+ maxPacketAge,
153
188
  };
154
189
  this.signal = options.signal;
155
190
  // Parse supported codecs
@@ -170,6 +205,8 @@ export class FMP4Stream {
170
205
  *
171
206
  * @returns Configured fMP4 stream instance
172
207
  *
208
+ * @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
209
+ *
173
210
  * @example
174
211
  * ```typescript
175
212
  * // Stream from file with codec negotiation
@@ -288,7 +325,7 @@ export class FMP4Stream {
288
325
  parts.push(FMP4_CODECS.H264);
289
326
  }
290
327
  if (this.source.audio) {
291
- parts.push(FMP4_CODECS.AAC);
328
+ parts.push(this.audioTranscodeTarget());
292
329
  }
293
330
  return parts.join(',');
294
331
  }
@@ -302,7 +339,7 @@ export class FMP4Stream {
302
339
  // Determine video codec string
303
340
  let videoCodec = null;
304
341
  if (videoCodecId) {
305
- const needsVideoTranscode = !this.isVideoCodecSupported(videoCodecId);
342
+ const needsVideoTranscode = this.transcodesVideo(videoCodecId);
306
343
  if (needsVideoTranscode) {
307
344
  // Transcoding to H.264
308
345
  videoCodec = FMP4_CODECS.H264;
@@ -332,8 +369,7 @@ export class FMP4Stream {
332
369
  if (audioCodecId) {
333
370
  const needsAudioTranscode = !this.isAudioCodecSupported(audioCodecId);
334
371
  if (needsAudioTranscode) {
335
- // Transcoding to AAC
336
- audioCodec = FMP4_CODECS.AAC;
372
+ audioCodec = this.audioTranscodeTarget();
337
373
  }
338
374
  else if (audioCodecId === AV_CODEC_ID_AAC) {
339
375
  // AAC - use fixed codec string
@@ -355,13 +391,60 @@ export class FMP4Stream {
355
391
  // Combine video and audio codec strings
356
392
  return [videoCodec, audioCodec].filter(Boolean).join(',');
357
393
  }
394
+ /**
395
+ * Video description of the muxed fMP4 output (output codec, RFC 6381 codec string, resolution, fps).
396
+ *
397
+ * Available after {@link start} has opened the input.
398
+ *
399
+ * @returns The output video info, or `undefined` when there is no video track.
400
+ *
401
+ * @example
402
+ * ```typescript
403
+ * const videoInfo = stream.getVideoInfo();
404
+ * if (videoInfo) {
405
+ * console.log(`Codec: ${videoInfo.codec}, Resolution: ${videoInfo.width}x${videoInfo.height}, FPS: ${videoInfo.fps}`);
406
+ * }
407
+ * ```
408
+ */
409
+ getVideoInfo() {
410
+ if (this.source) {
411
+ if (!this.source.video) {
412
+ return undefined;
413
+ }
414
+ return {
415
+ codec: 'h264',
416
+ codecString: FMP4_CODECS.H264,
417
+ width: this.options.video?.width ?? 0,
418
+ height: this.options.video?.height ?? 0,
419
+ fps: this.options.video?.fps ?? 0,
420
+ };
421
+ }
422
+ const videoStream = this.input?.video();
423
+ if (!videoStream) {
424
+ return undefined;
425
+ }
426
+ const { codecpar } = videoStream;
427
+ const codecId = codecpar.codecId;
428
+ const transcode = this.transcodesVideo(codecId);
429
+ const codecString = transcode ? FMP4_CODECS.H264 : (avGetCodecString(codecpar) ?? FMP4_CODECS.H264);
430
+ const codec = transcode || codecId === AV_CODEC_ID_H264 ? 'h264' : codecId === AV_CODEC_ID_HEVC ? 'hevc' : codecId === AV_CODEC_ID_AV1 ? 'av1' : 'unknown';
431
+ const size = transcode ? transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height).output : undefined;
432
+ const rate = videoStream.avgFrameRate;
433
+ return {
434
+ codec,
435
+ codecString,
436
+ width: size?.width ?? codecpar.width,
437
+ height: size?.height ?? codecpar.height,
438
+ fps: transcode && this.options.video?.fps ? this.options.video.fps : rate.den ? rate.num / rate.den : 0,
439
+ };
440
+ }
358
441
  /**
359
442
  * Start streaming media to fMP4 chunks.
360
443
  *
361
444
  * Begins the media processing pipeline, reading packets from input,
362
445
  * transcoding based on supported codecs, and generating fMP4 chunks.
363
446
  * Video transcodes to H.264 if H.264/H.265 not supported.
364
- * Audio transcodes to AAC if AAC/FLAC/Opus not supported.
447
+ * Audio transcodes to AAC (Opus when only Opus is listed) if the input codec is not supported.
365
448
  * This method returns immediately after starting the pipeline.
366
449
  *
367
450
  * @returns Promise that resolves when pipeline is started
@@ -414,6 +497,9 @@ export class FMP4Stream {
414
497
  };
415
498
  this.signal?.addEventListener('abort', this.abortHandler, { once: true });
416
499
  this._droppedFragments = 0;
500
+ this.discardOutput = false;
501
+ // Re-armed per start so a restart is not torn down by the previous run.
502
+ this.stopSignal = Promise.withResolvers();
417
503
  // Lets stop() interrupt a startup that is blocked in the input open
418
504
  // (an RTSP connect can take seconds).
419
505
  this.startAbort = new AbortController();
@@ -434,7 +520,7 @@ export class FMP4Stream {
434
520
  const videoStream = this.input.video();
435
521
  const audioStream = this.input.audio();
436
522
  // Check if video needs transcoding
437
- const needsVideoTranscode = videoStream && !this.isVideoCodecSupported(videoStream.codecpar.codecId);
523
+ const needsVideoTranscode = videoStream && this.transcodesVideo(videoStream.codecpar.codecId);
438
524
  if (needsVideoTranscode) {
439
525
  // Check if we need hardware acceleration
440
526
  if (this.options.hardware === 'auto') {
@@ -444,52 +530,28 @@ export class FMP4Stream {
444
530
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
445
531
  }
446
532
  // Transcode to H.264
447
- this.videoDecoder = await Decoder.create(videoStream, {
448
- hardware: this.hardwareContext,
533
+ const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
534
+ const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
535
+ const { codecpar } = videoStream;
536
+ const scale = transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height);
537
+ // a GPU encodes up to a size of its own, test it with the size it will get
538
+ const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions, scale.output)) ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
539
+ this.throwIfStopRequested();
540
+ this.videoDecoder = await openTranscodeDecoder(videoStream, this.hardwareContext, {
449
541
  exitOnError: false,
542
+ // Frame threading holds back one frame per thread before the first output;
543
+ // files keep the full thread count for throughput
544
+ ...(this.liveInput ? { configure: configureLiveDecoderThreading } : {}),
450
545
  });
451
546
  this.throwIfStopRequested();
452
- // Determine if we need filters by comparing with current stream properties
453
- const currentWidth = videoStream.codecpar.width;
454
- const currentHeight = videoStream.codecpar.height;
455
- const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
456
- const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
457
- (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
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);
460
- // Create filter chain only if needed
461
- if (needsScale || needsFps) {
462
- const filterChain = FilterPreset.chain(this.hardwareContext);
463
- // Add scale filter if dimensions differ
464
- if (needsScale) {
465
- const targetWidth = this.options.video.width ?? -1;
466
- const targetHeight = this.options.video.height ?? -1;
467
- filterChain.scale(targetWidth, targetHeight);
468
- }
469
- // Add fps filter if fps differs
470
- if (needsFps) {
471
- filterChain.filter('fps', { fps: this.options.video.fps });
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
- }
489
- this.videoFilter = FilterAPI.create(filterChain.build(), {
490
- hardware: this.hardwareContext,
491
- });
492
- }
547
+ // The graph follows the frames: whether they come from the device and in
548
+ // which format shows only once they are decoded
549
+ this.videoFilter = createVideoTranscodeFilter({
550
+ ...scale.target,
551
+ fps: needsFps ? this.options.video.fps : undefined,
552
+ encoder: encoderCodec,
553
+ hardware: this.hardwareContext,
554
+ });
493
555
  let encoderOptions = {};
494
556
  if (encoderCodec.name === FF_ENCODER_LIBX264) {
495
557
  encoderOptions.preset = 'ultrafast';
@@ -501,14 +563,17 @@ export class FMP4Stream {
501
563
  };
502
564
  const effectiveFps = this.options.video.fps ?? currentFps;
503
565
  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
566
  const bitrate = this.options.video.bitrate;
506
567
  this.videoEncoder = await Encoder.create(encoderCodec, {
507
568
  decoder: this.videoDecoder,
569
+ filter: this.videoFilter,
570
+ // Hardware encoders default to B-frames, which delay every fragment and
571
+ // fail on some D3D12 drivers that report support for them
572
+ context: { maxBFrames: 0 },
508
573
  options: encoderOptions,
509
574
  configure: (ctx) => {
510
- ctx.gopSize = Math.max(1, Math.round(fpsForGop * fragSeconds));
511
- this.applyBitrate(ctx, bitrate);
575
+ ctx.gopSize = this.gopSize(fpsForGop);
576
+ applyVideoBitrate(ctx, bitrate);
512
577
  },
513
578
  });
514
579
  this.throwIfStopRequested();
@@ -516,18 +581,10 @@ export class FMP4Stream {
516
581
  // Check if audio needs transcoding
517
582
  const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
518
583
  if (needsAudioTranscode) {
519
- // Transcode to AAC
520
584
  this.audioDecoder = await Decoder.create(audioStream, {
521
585
  exitOnError: false,
522
586
  });
523
- const targetSampleRate = 44100;
524
- const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, targetSampleRate, 'stereo').build();
525
- this.audioFilter = FilterAPI.create(filterChain);
526
- this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
527
- decoder: this.audioDecoder,
528
- filter: this.audioFilter,
529
- options: this.options.audio.encoderOptions,
530
- });
587
+ await this.createAudioEncoder(this.audioDecoder, audioStream.codecpar.channels);
531
588
  this.throwIfStopRequested();
532
589
  }
533
590
  // Setup output with callback
@@ -552,35 +609,31 @@ export class FMP4Stream {
552
609
  }
553
610
  }
554
611
  /**
555
- * Cap the video bitrate on the encoder context.
612
+ * GOP size for the video encoder that starts every fragment with a keyframe.
556
613
  *
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.
614
+ * frag_duration flushes a fragment once it is that long, mid-GOP on a
615
+ * P-frame unless the GOP has the same length, and strict consumers reject
616
+ * such fragments. Shorter fragments (the 1 µs default flushes after every
617
+ * frame) are not matched: a GOP of a few frames multiplies the bitrate, so
618
+ * below MIN_FRAGMENT_GOP the GOP is 2 s, as with a fragDuration of 0.
563
619
  *
564
- * @param ctx - Encoder codec context
620
+ * @param fps - Output frame rate
565
621
  *
566
- * @param bitrate - Maximum bitrate in bits per second, or undefined
622
+ * @returns GOP size in frames
567
623
  *
568
624
  * @internal
569
625
  */
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);
626
+ gopSize(fps) {
627
+ const fragSeconds = this.options.fragDuration / 1_000_000;
628
+ const seconds = fragSeconds >= MIN_FRAGMENT_GOP ? fragSeconds : 2;
629
+ return Math.max(1, Math.round(fps * seconds));
578
630
  }
579
631
  /**
580
632
  * Create the fMP4 output muxer with the fragment-emitting write callback.
581
633
  *
582
634
  * Uses `this.input` when present (demuxer path) and no input for the
583
- * frame-source path.
635
+ * frame-source path. Attaches a fresh packet age watchdog unless
636
+ * `maxPacketAge` is 0.
584
637
  *
585
638
  * @returns The configured muxer
586
639
  *
@@ -589,12 +642,17 @@ export class FMP4Stream {
589
642
  async createOutput() {
590
643
  const cb = {
591
644
  write: (buffer) => {
645
+ // Swallows what the teardown of a failed session flushes (see attachCompletion).
646
+ if (this.discardOutput) {
647
+ return buffer.length;
648
+ }
592
649
  if (this.options.boxMode) {
593
650
  // Box mode: buffer until we have complete boxes
594
651
  this.processBoxMode(buffer);
595
652
  }
596
653
  else {
597
654
  // Chunk mode: send raw data immediately
655
+ this.packetAgeWatchdog?.consumeChunk(buffer);
598
656
  const info = { isComplete: false, boxes: [] };
599
657
  this.options.onData(buffer, info);
600
658
  this.pushFragment(buffer, info);
@@ -602,45 +660,126 @@ export class FMP4Stream {
602
660
  return buffer.length;
603
661
  },
604
662
  };
605
- return await Muxer.open(cb, {
663
+ const output = await Muxer.open(cb, {
606
664
  input: this.input,
607
665
  format: 'mp4',
608
666
  bufferSize: this.options.bufferSize,
609
667
  exitOnError: false,
668
+ dtsBackwardThreshold: this.options.dtsBackwardThreshold,
669
+ dtsForwardThreshold: this.options.dtsForwardThreshold,
670
+ configure: (fmt) => {
671
+ const tag = this.options.video?.tag;
672
+ if (!tag) {
673
+ return;
674
+ }
675
+ for (const stream of fmt.streams) {
676
+ if (stream.codecpar.codecId === AV_CODEC_ID_HEVC) {
677
+ stream.codecpar.codecTag = tag;
678
+ }
679
+ }
680
+ },
610
681
  options: {
611
682
  movflags: this.options.movFlags,
612
683
  frag_duration: this.options.fragDuration,
613
684
  },
614
685
  });
686
+ // Per session: a restart must not inherit the previous session's backlog.
687
+ if (this.options.maxPacketAge > 0) {
688
+ this.packetAgeWatchdog = new PacketAgeWatchdog(this.options.maxPacketAge, (index) => {
689
+ const codecType = output.getStream(index)?.codecpar.codecType;
690
+ return codecType === undefined ? '' : (avGetMediaTypeString(codecType) ?? '');
691
+ });
692
+ output.setPacketObserver(this.packetAgeWatchdog);
693
+ }
694
+ return output;
615
695
  }
616
696
  /**
617
697
  * Wire the pipeline completion to the fragment/close callbacks.
618
698
  *
619
699
  * On success ends the fragment iterator and invokes `onClose()`; on error
620
- * stops the stream and invokes `onClose(error)`. Runs in the background.
700
+ * stops the stream, discarding the output the muxer still holds, and invokes
701
+ * `onClose(error)`. Either way `onClose` runs exactly once and the detached
702
+ * chain never rejects. Runs in the background.
621
703
  *
622
704
  * @param completion - The pipeline completion promise
623
705
  *
624
706
  * @internal
625
707
  */
626
708
  attachCompletion(completion) {
627
- completion
628
- .then(() => {
709
+ // One then() with both handlers: a throwing onClose() in the success path
710
+ // must not fall through into the error path and fire onClose a second time.
711
+ completion.then(() => {
629
712
  this.endFragments();
630
713
  // No-op in the normal case (init segment resolved long ago) - only
631
714
  // fires if the stream ended without ever emitting ftyp+moov.
632
715
  this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
633
- this.options.onClose?.();
634
- })
635
- .catch(async (error) => {
716
+ this.emitClose();
717
+ }, async (reason) => {
718
+ const error = reason instanceof Error ? reason : new Error(String(reason));
636
719
  this.endFragments();
637
720
  // Reject with the pipeline error so an initSegment consumer sees the
638
721
  // actual failure instead of the generic "stopped" error from stop().
639
- this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
640
- await this.stop();
641
- this.options.onClose?.(error);
722
+ this.rejectInitSegment(error);
723
+ // A stop() in flight or an aborted signal means the owner is tearing
724
+ // the stream down - the pipeline unwinding with an AbortError is the
725
+ // expected shutdown path, not a failure. Decided before the stop()
726
+ // below, which would make every failure look deliberate.
727
+ const deliberate = this.isDeliberateStop(reason);
728
+ // Closing the output flushes the muxer's backlog and the trailer. After
729
+ // a failure that backlog is suspect: when a backward jump ends the
730
+ // session, the packets the timeline had jumped ahead to are still
731
+ // queued and would leave as a fragment placed that far ahead; after a
732
+ // stall it holds up to maxPacketAge of retained samples. Nothing is
733
+ // emitted past the failure, through onData or fragments(). A
734
+ // deliberate stop keeps its tail.
735
+ if (!deliberate) {
736
+ this.discardOutput = true;
737
+ }
738
+ // Release the input (e.g. the RTSP connection) before reporting, so an
739
+ // owner that restarts from onClose never overlaps a live session. A
740
+ // teardown failure must not swallow the onClose call: without it the
741
+ // owner never learns the stream is gone.
742
+ try {
743
+ await this.stop();
744
+ }
745
+ catch {
746
+ // doStop() already released whatever it could.
747
+ }
748
+ this.emitClose(deliberate ? undefined : error);
642
749
  });
643
750
  }
751
+ /**
752
+ * Invoke the owner's onClose callback.
753
+ *
754
+ * The completion chain runs detached, so an exception from the callback
755
+ * could only surface as an unhandled rejection - it is logged instead.
756
+ *
757
+ * @param error - Pipeline error, or undefined for a clean end
758
+ *
759
+ * @internal
760
+ */
761
+ emitClose(error) {
762
+ try {
763
+ this.options.onClose(error);
764
+ }
765
+ catch (callbackError) {
766
+ // The stream is already closed; logged so a broken restart path in the
767
+ // owner does not vanish silently.
768
+ console.error('[FMP4Stream] onClose callback threw:', callbackError);
769
+ }
770
+ }
771
+ /**
772
+ * Whether a pipeline rejection was caused by an intentional shutdown.
773
+ *
774
+ * @param error - The pipeline rejection reason
775
+ *
776
+ * @returns True when stop() is in flight or the owner's signal aborted
777
+ *
778
+ * @internal
779
+ */
780
+ isDeliberateStop(error) {
781
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
782
+ }
644
783
  /**
645
784
  * Set up encoders and the fMP4 muxer for a pre-composited frame source and
646
785
  * start encoding. No demuxer or decoder is involved - the provided frames are
@@ -667,7 +806,8 @@ export class FMP4Stream {
667
806
  else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
668
807
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
669
808
  }
670
- const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
809
+ const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions)) ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
810
+ this.throwIfStopRequested();
671
811
  let encoderOptions = {};
672
812
  if (encoderCodec.name === FF_ENCODER_LIBX264) {
673
813
  encoderOptions.preset = 'ultrafast';
@@ -679,29 +819,22 @@ export class FMP4Stream {
679
819
  fps = 30;
680
820
  }
681
821
  // Set the framerate explicitly - without a decoder the encoder would infer
682
- // it from the frame timebase and pick an absurd level. Bound the GOP to the
683
- // fragment duration (2s default) so every fMP4 fragment starts with a
684
- // keyframe; otherwise frag_duration flushes fragments mid-GOP on a P-frame
685
- // and strict consumers reject them.
686
- const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
822
+ // it from the frame timebase and pick an absurd level.
687
823
  const bitrate = this.options.video.bitrate;
688
824
  this.videoEncoder = await Encoder.create(encoderCodec, {
825
+ context: { maxBFrames: 0 },
689
826
  options: encoderOptions,
690
827
  configure: (ctx) => {
691
828
  ctx.framerate = new Rational(Math.round(fps), 1);
692
- ctx.gopSize = Math.max(1, Math.round(fps * fragSeconds));
693
- this.applyBitrate(ctx, bitrate);
829
+ ctx.gopSize = this.gopSize(fps);
830
+ applyVideoBitrate(ctx, bitrate);
694
831
  },
695
832
  });
833
+ // Source frames may sit on another device or in a format the encoder rejects
834
+ this.videoFilter = createVideoTranscodeFilter({ width: -1, height: -1, encoder: encoderCodec, hardware: this.hardwareContext });
696
835
  }
697
- // Audio encoder (AAC), resampling via aformat like the demuxer path.
698
836
  if (source.audio) {
699
- const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build();
700
- this.audioFilter = FilterAPI.create(filterChain);
701
- this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
702
- filter: this.audioFilter,
703
- options: this.options.audio.encoderOptions,
704
- });
837
+ await this.createAudioEncoder();
705
838
  }
706
839
  this.throwIfStopRequested();
707
840
  this.output = await this.createOutput();
@@ -710,13 +843,18 @@ export class FMP4Stream {
710
843
  let control;
711
844
  const shouldStop = () => control?.isStopped() ?? false;
712
845
  const tasks = [];
713
- if (source.video && this.videoEncoder) {
846
+ // A frame source has no demuxer for stop() to interrupt, so wrap it to make
847
+ // a silent source releasable instead of blocking teardown forever.
848
+ const stopped = this.stopSignal.promise;
849
+ if (source.video && this.videoFilter && this.videoEncoder) {
714
850
  const streamIndex = this.output.addStream(this.videoEncoder);
715
- tasks.push(consumeStreamInParallel(this.videoEncoder.packets(source.video), this.output, streamIndex, shouldStop));
851
+ const video = interruptibleFrameSource(source.video, stopped);
852
+ tasks.push(consumeStreamInParallel(this.videoEncoder.packets(this.videoFilter.frames(video)), this.output, streamIndex, shouldStop));
716
853
  }
717
854
  if (source.audio && this.audioFilter && this.audioEncoder) {
718
855
  const streamIndex = this.output.addStream(this.audioEncoder);
719
- tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(source.audio)), this.output, streamIndex, shouldStop));
856
+ const audio = interruptibleFrameSource(source.audio, stopped);
857
+ tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
720
858
  }
721
859
  // Close the shared muxer only after ALL streams are done (flushes the trailer).
722
860
  const run = Promise.all(tasks).then(async () => {
@@ -731,7 +869,8 @@ export class FMP4Stream {
731
869
  *
732
870
  * Stops the pipeline, closes output, and releases all FFmpeg resources.
733
871
  * Safe to call multiple times. After stopping, you can call start() again
734
- * to restart the stream.
872
+ * to restart the stream. Resolves even if the pipeline had failed; that
873
+ * error is reported through `onClose(error)`.
735
874
  *
736
875
  * @example
737
876
  * ```typescript
@@ -765,6 +904,9 @@ export class FMP4Stream {
765
904
  this.stopRequested = true;
766
905
  this.startAbort?.abort();
767
906
  this.startAbort = undefined;
907
+ // Unblocks a frame source that went silent, so the completion barrier below
908
+ // cannot wait on a pull that will never settle.
909
+ this.stopSignal.resolve();
768
910
  const inflightStart = this.startPromise;
769
911
  // Allow a fresh start() after stopping.
770
912
  this.startPromise = undefined;
@@ -788,22 +930,42 @@ export class FMP4Stream {
788
930
  // Stop pipeline if running and wait for completion. Swallow a rejected
789
931
  // completion - the error is already surfaced via the onClose(error) path;
790
932
  // we just need cleanup to proceed.
791
- if (this.pipeline && !this.pipeline.isStopped()) {
792
- this.pipeline.stop();
933
+ const pipeline = this.pipeline;
934
+ if (pipeline) {
935
+ if (!pipeline.isStopped()) {
936
+ pipeline.stop();
937
+ }
793
938
  try {
794
- await this.pipeline.completion;
939
+ await pipeline.completion;
795
940
  }
796
941
  catch {
797
942
  // Pipeline errored - proceed with teardown regardless.
798
943
  }
799
- this.pipeline = undefined;
944
+ if (this.pipeline === pipeline) {
945
+ this.pipeline = undefined;
946
+ }
800
947
  }
801
948
  // Close all resources. The output goes FIRST: its async write worker may
802
949
  // still be draining (header init reads the input's stream parameters),
803
950
  // so freeing the input before the muxer has fully settled is a
804
951
  // use-after-free on the worker thread.
805
- await this.output?.close();
952
+ // After a failure nothing the output emits is used (see attachCompletion),
953
+ // so the trailer is skipped instead of flushed into the void.
954
+ if (this.discardOutput) {
955
+ this.output?.discardOnClose();
956
+ }
957
+ // close() rethrows a failed background write after it freed the muxer.
958
+ // That error already ended the pipeline and reaches the owner through
959
+ // onClose(error), so it must not abort the teardown here and leak the
960
+ // codecs and the input (an RTSP connection) below.
961
+ try {
962
+ await this.output?.close();
963
+ }
964
+ catch {
965
+ // Surfaced through the pipeline completion.
966
+ }
806
967
  this.output = undefined;
968
+ this.packetAgeWatchdog = undefined;
807
969
  this.videoDecoder?.close();
808
970
  this.videoDecoder = undefined;
809
971
  this.videoFilter?.close();
@@ -931,6 +1093,18 @@ export class FMP4Stream {
931
1093
  await this.pipeline.completion;
932
1094
  this.pipeline = undefined;
933
1095
  }
1096
+ /**
1097
+ * Check if the video is encoded again instead of passed through.
1098
+ *
1099
+ * @param codecId - Codec ID of the source video
1100
+ *
1101
+ * @returns True if `video.transcode` is set or the codec is not in supported codecs
1102
+ *
1103
+ * @internal
1104
+ */
1105
+ transcodesVideo(codecId) {
1106
+ return this.options.video.transcode === true || !this.isVideoCodecSupported(codecId);
1107
+ }
934
1108
  /**
935
1109
  * Check if video codec is supported.
936
1110
  *
@@ -952,6 +1126,47 @@ export class FMP4Stream {
952
1126
  }
953
1127
  return false;
954
1128
  }
1129
+ /**
1130
+ * Codec that unsupported audio is transcoded to.
1131
+ *
1132
+ * @returns Opus when the client lists Opus but not AAC, otherwise AAC
1133
+ *
1134
+ * @internal
1135
+ */
1136
+ audioTranscodeTarget() {
1137
+ return this.supportedCodecs.has(FMP4_CODECS.OPUS) && !this.supportedCodecs.has(FMP4_CODECS.AAC) ? FMP4_CODECS.OPUS : FMP4_CODECS.AAC;
1138
+ }
1139
+ /**
1140
+ * Create the audio filter and encoder for the transcode target.
1141
+ *
1142
+ * AAC is resampled to 44.1 kHz stereo. Opus is resampled to 48 kHz, the only
1143
+ * rate it transmits, keeps mono input mono and uses 20 ms frames.
1144
+ *
1145
+ * @param decoder - Input audio decoder, absent for a frame source
1146
+ *
1147
+ * @param channels - Input channel count, 0 when unknown
1148
+ *
1149
+ * @internal
1150
+ */
1151
+ async createAudioEncoder(decoder, channels = 0) {
1152
+ if (this.audioTranscodeTarget() === FMP4_CODECS.OPUS) {
1153
+ this.audioFilter = FilterAPI.create(FilterPreset.chain()
1154
+ .aformat(AV_SAMPLE_FMT_FLT, 48000, channels === 1 ? 'mono' : 'stereo')
1155
+ .build());
1156
+ this.audioEncoder = await Encoder.create(FF_ENCODER_LIBOPUS, {
1157
+ decoder,
1158
+ filter: this.audioFilter,
1159
+ options: { frame_duration: 20, ...this.options.audio.encoderOptions },
1160
+ });
1161
+ return;
1162
+ }
1163
+ this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build());
1164
+ this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
1165
+ decoder,
1166
+ filter: this.audioFilter,
1167
+ options: this.options.audio.encoderOptions,
1168
+ });
1169
+ }
955
1170
  /**
956
1171
  * Check if audio codec is supported.
957
1172
  *
@@ -1033,6 +1248,9 @@ export class FMP4Stream {
1033
1248
  // is corrupt - recovery is impossible, so drop the rest of this chunk
1034
1249
  // rather than emit garbage framing downstream.
1035
1250
  if (boxSize < headerSize) {
1251
+ // Emitted samples can no longer be counted - stop judging packet ages
1252
+ // instead of mistaking the lost framing for a stalled muxer.
1253
+ this.packetAgeWatchdog?.onFramingLost();
1036
1254
  break;
1037
1255
  }
1038
1256
  // Check if we have the complete box
@@ -1047,6 +1265,9 @@ export class FMP4Stream {
1047
1265
  headerSize,
1048
1266
  raw: chunk.subarray(offset, offset + boxSize),
1049
1267
  };
1268
+ if (parsed.type === 'moof' || parsed.type === 'moov') {
1269
+ this.packetAgeWatchdog?.consumeBox(parsed.raw);
1270
+ }
1050
1271
  if (parsed.type === 'moof') {
1051
1272
  flushImmediate();
1052
1273
  if (this.pendingFragment.length > 0) {