node-av 6.2.0-beta.9 → 7.0.0-beta.1

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 (116) 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 +67 -5
  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 +54 -33
  43. package/dist/api/rtp-stream.js +232 -153
  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/bsf-options.d.ts +32 -8
  61. package/dist/constants/constants.d.ts +36 -114
  62. package/dist/constants/constants.js +32 -95
  63. package/dist/constants/constants.js.map +1 -1
  64. package/dist/constants/decoders.d.ts +74 -74
  65. package/dist/constants/decoders.js +79 -77
  66. package/dist/constants/decoders.js.map +1 -1
  67. package/dist/constants/encoders.d.ts +20 -21
  68. package/dist/constants/encoders.js +25 -24
  69. package/dist/constants/encoders.js.map +1 -1
  70. package/dist/constants/filter-options.d.ts +1378 -194
  71. package/dist/constants/format-options.d.ts +1342 -83
  72. package/dist/constants/formats.d.ts +2 -2
  73. package/dist/constants/options.d.ts +2261 -125
  74. package/dist/ffmpeg/utils.js +7 -4
  75. package/dist/ffmpeg/utils.js.map +1 -1
  76. package/dist/ffmpeg/version.d.ts +1 -1
  77. package/dist/ffmpeg/version.js +1 -1
  78. package/dist/ffmpeg/version.js.map +1 -1
  79. package/dist/lib/binding.d.ts +10 -1
  80. package/dist/lib/binding.js +25 -16
  81. package/dist/lib/binding.js.map +1 -1
  82. package/dist/lib/bitstream-filter-context.d.ts +2 -0
  83. package/dist/lib/bitstream-filter-context.js +2 -0
  84. package/dist/lib/bitstream-filter-context.js.map +1 -1
  85. package/dist/lib/filter-context.d.ts +3 -0
  86. package/dist/lib/filter-context.js +3 -0
  87. package/dist/lib/filter-context.js.map +1 -1
  88. package/dist/lib/filter-graph-segment.d.ts +3 -2
  89. package/dist/lib/filter-graph-segment.js +3 -2
  90. package/dist/lib/filter-graph-segment.js.map +1 -1
  91. package/dist/lib/filter-graph.d.ts +4 -4
  92. package/dist/lib/filter-graph.js +4 -4
  93. package/dist/lib/filter-inout.d.ts +4 -0
  94. package/dist/lib/filter-inout.js +4 -0
  95. package/dist/lib/filter-inout.js.map +1 -1
  96. package/dist/lib/format-context.d.ts +27 -3
  97. package/dist/lib/format-context.js +30 -6
  98. package/dist/lib/format-context.js.map +1 -1
  99. package/dist/lib/frame.d.ts +6 -2
  100. package/dist/lib/frame.js +11 -3
  101. package/dist/lib/frame.js.map +1 -1
  102. package/dist/lib/io-context.d.ts +4 -2
  103. package/dist/lib/io-context.js +4 -2
  104. package/dist/lib/io-context.js.map +1 -1
  105. package/dist/lib/native-types.d.ts +7 -3
  106. package/dist/lib/output-format.d.ts +42 -0
  107. package/dist/lib/output-format.js +46 -0
  108. package/dist/lib/output-format.js.map +1 -1
  109. package/dist/lib/stream.d.ts +5 -0
  110. package/dist/lib/stream.js +5 -0
  111. package/dist/lib/stream.js.map +1 -1
  112. package/dist/lib/utilities.d.ts +26 -0
  113. package/dist/lib/utilities.js +28 -0
  114. package/dist/lib/utilities.js.map +1 -1
  115. package/install/check.js +14 -16
  116. package/package.json +28 -44
@@ -1,9 +1,10 @@
1
1
  import { isRtcp, RtpPacket } from 'werift';
2
- import { AV_HWDEVICE_TYPE_NONE, AV_PICTURE_TYPE_I, AV_PIX_FMT_YUV420P, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
2
+ import { AV_CODEC_ID_HEVC, AV_HWDEVICE_TYPE_NONE, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
3
3
  import { FF_ENCODER_LIBOPUS, FF_ENCODER_LIBX264, FF_ENCODER_LIBX265 } from '../constants/encoders.js';
4
4
  import { Codec } from '../lib/codec.js';
5
5
  import { Rational } from '../lib/rational.js';
6
- import { avChannelLayoutDefault, avGetPixFmtName } from '../lib/utilities.js';
6
+ import { avChannelLayoutDefault } from '../lib/utilities.js';
7
+ import { BitStreamFilterAPI } from './bitstream-filter.js';
7
8
  import { MAX_PACKET_SIZE } from './constants.js';
8
9
  import { Decoder } from './decoder.js';
9
10
  import { Demuxer } from './demuxer.js';
@@ -12,8 +13,11 @@ import { FilterPreset } from './filter-presets.js';
12
13
  import { FilterAPI } from './filter.js';
13
14
  import { HardwareContext } from './hardware.js';
14
15
  import { Muxer } from './muxer.js';
15
- import { pipeline } from './pipeline.js';
16
- import { pickSupportedLayout, pickSupportedPixelFormat, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
16
+ import { interruptibleFrameSource, pipeline } from './pipeline.js';
17
+ import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
18
+ import { configureLiveDecoderThreading } from './utilities/live-decoding.js';
19
+ import { transcodeScale } from './utilities/scale-size.js';
20
+ import { applyVideoBitrate, createVideoTranscodeFilter, findSoftwareEncoder, findUsableHardwareEncoder, openTranscodeDecoder } from './utilities/video-transcode.js';
17
21
  /**
18
22
  * Check whether a streaming-class input is a frame source (not a URL or Demuxer).
19
23
  *
@@ -100,6 +104,7 @@ export class RTPStream {
100
104
  videoDecoder;
101
105
  videoFilter;
102
106
  videoEncoder;
107
+ videoBitstreamFilter;
103
108
  audioDecoder;
104
109
  audioFilter;
105
110
  audioEncoder;
@@ -108,11 +113,11 @@ export class RTPStream {
108
113
  signal;
109
114
  supportedVideoCodecs;
110
115
  supportedAudioCodecs;
111
- videoKeyframeRequested = false;
112
116
  abortHandler;
113
117
  stopRequested = false;
114
118
  stopPromise;
115
119
  startAbort;
120
+ stopSignal = Promise.withResolvers();
116
121
  /**
117
122
  * @param input - Media input URL or pre-opened Demuxer
118
123
  *
@@ -148,11 +153,9 @@ export class RTPStream {
148
153
  },
149
154
  };
150
155
  this.signal = options.signal;
151
- options.supportedVideoCodecs = options.supportedVideoCodecs?.filter(Boolean);
152
- options.supportedAudioCodecs = options.supportedAudioCodecs?.filter(Boolean);
153
156
  // If no supported codecs specified, empty set means ALL codecs are supported (passthrough only)
154
- this.supportedVideoCodecs = new Set(options.supportedVideoCodecs && options.supportedVideoCodecs.length > 0 ? options.supportedVideoCodecs : []);
155
- this.supportedAudioCodecs = new Set(options.supportedAudioCodecs && options.supportedAudioCodecs.length > 0 ? options.supportedAudioCodecs : []);
157
+ this.supportedVideoCodecs = new Set(options.supportedVideoCodecs?.filter(Boolean));
158
+ this.supportedAudioCodecs = new Set(options.supportedAudioCodecs?.filter(Boolean));
156
159
  this.options = {
157
160
  onVideoPacket: options.onVideoPacket ?? (() => { }),
158
161
  onAudioPacket: options.onAudioPacket ?? (() => { }),
@@ -169,6 +172,8 @@ export class RTPStream {
169
172
  width: options.video?.width,
170
173
  height: options.video?.height,
171
174
  bitrate: options.video?.bitrate,
175
+ dumpExtra: options.video?.dumpExtra,
176
+ transcode: options.video?.transcode,
172
177
  encoderOptions: options.video?.encoderOptions ?? {},
173
178
  },
174
179
  audio: {
@@ -228,10 +233,14 @@ export class RTPStream {
228
233
  /**
229
234
  * Check if the stream is active.
230
235
  *
236
+ * Stays true while a requested stop is still unwinding: the pipeline reports
237
+ * itself stopped as soon as stop() is called, but its packets keep flowing
238
+ * and its native resources stay open until completion settles.
239
+ *
231
240
  * @returns True if the stream is active, false otherwise
232
241
  */
233
242
  get isStreamActive() {
234
- return this.pipeline !== undefined && !this.pipeline.isStopped();
243
+ return this.pipeline !== undefined;
235
244
  }
236
245
  /**
237
246
  * Get the demuxer instance.
@@ -259,9 +268,9 @@ export class RTPStream {
259
268
  *
260
269
  * Used to answer receiver feedback (e.g. a WebRTC PLI) so a receiver that
261
270
  * missed the stream start or lost packets can resync without waiting for the
262
- * next scheduled GOP keyframe. Currently effective for frame-source inputs
263
- * ({@link MediaFrameSource}); for demuxer inputs the encoder's regular GOP
264
- * cadence applies.
271
+ * next scheduled GOP keyframe. Effective whenever the video is encoded, from
272
+ * frame sources and from a transcoded URL or {@link Demuxer} input; a stream
273
+ * passed through keeps the source's keyframes.
265
274
  *
266
275
  * @example
267
276
  * ```typescript
@@ -271,29 +280,7 @@ export class RTPStream {
271
280
  * ```
272
281
  */
273
282
  requestVideoKeyframe() {
274
- this.videoKeyframeRequested = true;
275
- }
276
- /**
277
- * Pass video frames through, forcing a keyframe when one was requested.
278
- *
279
- * Sets `pictType = I` on the next frame after {@link requestVideoKeyframe};
280
- * combined with the encoder's `forced-idr` option this produces an IDR the
281
- * receiver can resync on.
282
- *
283
- * @param src - The user-provided video frame source
284
- *
285
- * @yields {Frame | null} The unchanged frames (with pictType forced when requested)
286
- *
287
- * @internal
288
- */
289
- async *wrapVideoSource(src) {
290
- for await (const frame of src) {
291
- if (frame && this.videoKeyframeRequested) {
292
- this.videoKeyframeRequested = false;
293
- frame.pictType = AV_PICTURE_TYPE_I;
294
- }
295
- yield frame;
296
- }
283
+ this.videoEncoder?.requestKeyframe();
297
284
  }
298
285
  /**
299
286
  * Start streaming media to RTP packets.
@@ -356,6 +343,8 @@ export class RTPStream {
356
343
  };
357
344
  this.signal.addEventListener('abort', this.abortHandler, { once: true });
358
345
  }
346
+ // Re-armed per start so a restart is not torn down by the previous run.
347
+ this.stopSignal = Promise.withResolvers();
359
348
  // Lets stop() interrupt a startup that is blocked in the input open
360
349
  // (an RTSP connect can take seconds).
361
350
  this.startAbort = new AbortController();
@@ -374,8 +363,9 @@ export class RTPStream {
374
363
  this.throwIfStopRequested();
375
364
  const videoStream = this.input.video();
376
365
  const audioStream = this.input.audio();
366
+ let videoOutputIsHevc = videoStream?.codecpar.codecId === AV_CODEC_ID_HEVC;
377
367
  // Setup video transcoding if needed
378
- if (videoStream && !this.isVideoCodecSupported(videoStream.codecpar.codecId)) {
368
+ if (videoStream && (this.options.video.transcode || !this.isVideoCodecSupported(videoStream.codecpar.codecId))) {
379
369
  // Check if we need hardware acceleration
380
370
  if (this.options.hardware === 'auto') {
381
371
  this.hardwareContext = HardwareContext.auto();
@@ -383,84 +373,54 @@ export class RTPStream {
383
373
  else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
384
374
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
385
375
  }
386
- this.videoDecoder = await Decoder.create(videoStream, {
387
- exitOnError: false,
388
- hardware: this.hardwareContext,
389
- });
390
- // Determine if we need filters by comparing with current stream properties
391
- const currentWidth = videoStream.codecpar.width;
392
- const currentHeight = videoStream.codecpar.height;
393
376
  const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
394
- const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
395
- (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
396
377
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
397
378
  // Get first supported codec
398
379
  const targetCodecId = this.options.supportedVideoCodecs[0];
399
380
  if (!targetCodecId) {
400
381
  throw new Error('No supported video codec specified for transcoding');
401
382
  }
402
- let encoderCodec = null;
403
- if (typeof targetCodecId === 'string') {
404
- encoderCodec = Codec.findEncoderByName(targetCodecId);
405
- }
406
- else {
407
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
408
- }
383
+ const { codecpar } = videoStream;
384
+ const scale = transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height);
385
+ // a GPU encodes up to a size of its own, test it with the size it will get
386
+ const encoderCodec = await this.findVideoEncoder(targetCodecId, scale.output);
409
387
  if (!encoderCodec) {
410
388
  throw new Error(`No encoder found for codec ID ${targetCodecId}`);
411
389
  }
412
- // Create filter chain only if needed
413
- if (needsScale || needsFps) {
414
- const filterChain = FilterPreset.chain(this.hardwareContext);
415
- // Add scale filter if dimensions differ
416
- if (needsScale) {
417
- const targetWidth = this.options.video.width ?? -1;
418
- const targetHeight = this.options.video.height ?? -1;
419
- filterChain.scale(targetWidth, targetHeight);
420
- }
421
- // Add fps filter if fps differs
422
- if (needsFps) {
423
- filterChain.filter('fps', { fps: this.options.video.fps });
424
- }
425
- // Software scaling leaves the graph output format unconstrained, so swscale keeps the
426
- // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
427
- // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
428
- // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
429
- // one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
430
- // profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
431
- // codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
432
- // negotiate their format through the frames context instead.
433
- if (needsScale && !this.hardwareContext) {
434
- const formats = encoderCodec.pixelFormats;
435
- const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
436
- const targetFmtName = avGetPixFmtName(targetFmt);
437
- if (targetFmtName) {
438
- filterChain.filter('format', { pix_fmts: targetFmtName });
439
- }
440
- }
441
- this.videoFilter = FilterAPI.create(filterChain.build(), {
442
- hardware: this.hardwareContext,
443
- });
444
- }
445
- let encoderOptions = {};
446
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
447
- encoderOptions.preset = 'ultrafast';
448
- encoderOptions.tune = 'zerolatency';
449
- }
450
- encoderOptions = {
451
- ...encoderOptions,
452
- ...this.options.video.encoderOptions,
453
- };
390
+ this.throwIfStopRequested();
391
+ this.videoDecoder = await openTranscodeDecoder(videoStream, this.hardwareContext, {
392
+ exitOnError: false,
393
+ // Frame threading holds back one frame per thread before the first output
394
+ configure: configureLiveDecoderThreading,
395
+ });
396
+ // The graph follows the frames: whether they come from the device and in
397
+ // which format shows only once they are decoded
398
+ this.videoFilter = createVideoTranscodeFilter({
399
+ ...scale.target,
400
+ fps: needsFps ? this.options.video.fps : undefined,
401
+ encoder: encoderCodec,
402
+ hardware: this.hardwareContext,
403
+ });
404
+ const encoderOptions = liveVideoEncoderOptions(encoderCodec.name, this.options.video.encoderOptions);
454
405
  const bitrate = this.options.video.bitrate;
455
406
  this.videoEncoder = await Encoder.create(encoderCodec, {
456
407
  decoder: this.videoDecoder,
408
+ filter: this.videoFilter,
457
409
  maxBFrames: 0,
458
410
  options: encoderOptions,
459
411
  configure: (ctx) => {
460
- this.applyBitrate(ctx, bitrate);
412
+ // encoder defaults are 120 (VAAPI) or 250 frames (libx264, NVENC), a receiver that lost a packet waits that long;
413
+ // after the short probe the input rate is often unknown, the context has it from the frames
414
+ const fps = ctx.framerate.num > 0 ? ctx.framerate.num / ctx.framerate.den : 30;
415
+ ctx.gopSize = Math.max(1, Math.round(fps * 2));
416
+ applyVideoBitrate(ctx, bitrate);
461
417
  },
462
418
  });
463
419
  this.throwIfStopRequested();
420
+ videoOutputIsHevc = encoderCodec.id === AV_CODEC_ID_HEVC;
421
+ }
422
+ if (videoStream && videoOutputIsHevc && this.options.video?.dumpExtra) {
423
+ this.videoBitstreamFilter = BitStreamFilterAPI.create('dump_extra', this.videoEncoder ?? videoStream, { options: { freq: 'k' } });
464
424
  }
465
425
  // Setup video output
466
426
  this.videoOutput = await this.createVideoOutput();
@@ -510,6 +470,9 @@ export class RTPStream {
510
470
  decoder: this.audioDecoder,
511
471
  filter: this.audioFilter,
512
472
  options: encoderOptions,
473
+ // Silence for an input gap would only go out once the input resumes, as a burst of
474
+ // late packets; receivers conceal a timestamp gap like packet loss instead
475
+ audioFrameBuffer: { maxGapFill: 0 },
513
476
  });
514
477
  this.throwIfStopRequested();
515
478
  }
@@ -537,50 +500,87 @@ export class RTPStream {
537
500
  throw new Error('RTPStream stopped during start');
538
501
  }
539
502
  }
540
- /**
541
- * Cap the video bitrate on the encoder context.
542
- *
543
- * Applies a VBV rate cap (maxrate + a 1s buffer) WITHOUT setting a target
544
- * bitrate, so the encoder keeps its default constant-quality mode but can
545
- * never exceed the negotiated ceiling. Setting the bitrate as an ABR target
546
- * instead would push a normally-lighter stream UP to fill it, overshooting
547
- * on bursts.
548
- *
549
- * @param ctx - Encoder codec context
550
- *
551
- * @param bitrate - Maximum bitrate in bits per second, or undefined
552
- *
553
- * @internal
554
- */
555
- applyBitrate(ctx, bitrate) {
556
- if (!bitrate || bitrate <= 0) {
557
- return;
558
- }
559
- ctx.rcMaxRate = BigInt(Math.round(bitrate));
560
- ctx.rcBufferSize = Math.round(bitrate);
561
- }
562
503
  /**
563
504
  * Wire the pipeline completion to the onClose/error callbacks.
564
505
  *
565
506
  * On success invokes `onClose()`; on error stops the stream and invokes
566
- * `onClose(error)`. Runs in the background (not awaited).
507
+ * `onClose(error)`. Either way `onClose` runs exactly once and the detached
508
+ * chain never rejects. Runs in the background (not awaited).
567
509
  *
568
510
  * @param completion - The pipeline completion promise
569
511
  *
570
512
  * @internal
571
513
  */
572
514
  attachCompletion(completion) {
515
+ // Captured so a restart that installed a newer pipeline keeps it.
516
+ const control = this.pipeline;
517
+ // One then() with both handlers: a throwing onClose() in the success path
518
+ // must not fall through into the error path and fire onClose a second time.
573
519
  completion
574
520
  .then(() => {
575
521
  // Pipeline completed successfully
576
- this.options.onClose?.();
522
+ this.emitClose();
523
+ }, async (reason) => {
524
+ const error = reason instanceof Error ? reason : new Error(String(reason));
525
+ // A stop() in flight or an aborted signal means the owner is tearing
526
+ // the stream down - the pipeline unwinding with an AbortError (or an
527
+ // interrupted FFmpeg call) is the expected shutdown path, not a failure.
528
+ // Decided before the stop() below, which would make every failure look deliberate.
529
+ const deliberate = this.isDeliberateStop(reason);
530
+ if (!deliberate) {
531
+ console.error('[RTPStream] Pipeline error:', error);
532
+ }
533
+ // A teardown failure must not swallow the onClose call: without it
534
+ // the owner never learns the stream is gone.
535
+ try {
536
+ await this.stop();
537
+ }
538
+ catch {
539
+ // doStop() already released whatever it could.
540
+ }
541
+ this.emitClose(deliberate ? undefined : error);
577
542
  })
578
- .catch(async (error) => {
579
- console.error('[RTPStream] Pipeline error:', error);
580
- await this.stop();
581
- this.options.onClose?.(error);
543
+ .finally(() => {
544
+ // Drop the finished pipeline so isStreamActive reports inactive. The
545
+ // demuxer path already clears it in runPipeline(); a frame source has
546
+ // no such step, so without this it would look active forever.
547
+ if (this.pipeline === control) {
548
+ this.pipeline = undefined;
549
+ }
582
550
  });
583
551
  }
552
+ /**
553
+ * Invoke the owner's onClose callback.
554
+ *
555
+ * The completion chain runs detached, so an exception from the callback
556
+ * could only surface as an unhandled rejection - it is logged instead.
557
+ *
558
+ * @param error - Pipeline error, or undefined for a clean end
559
+ *
560
+ * @internal
561
+ */
562
+ emitClose(error) {
563
+ try {
564
+ this.options.onClose(error);
565
+ }
566
+ catch (callbackError) {
567
+ // The stream is already closed; logged so a broken restart path in the
568
+ // owner does not vanish silently.
569
+ console.error('[RTPStream] onClose callback threw:', callbackError);
570
+ }
571
+ }
572
+ /**
573
+ * Whether a pipeline rejection was caused by an intentional shutdown.
574
+ *
575
+ * @param error - The pipeline rejection reason
576
+ *
577
+ * @returns True when stop() is in flight or the owner's signal aborted
578
+ *
579
+ * @internal
580
+ */
581
+ isDeliberateStop(error) {
582
+ return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
583
+ }
584
584
  /**
585
585
  * Set up encoders and RTP outputs for a pre-composited frame source and start
586
586
  * encoding. No demuxer or decoder is involved - the provided frames are encoded
@@ -607,25 +607,12 @@ export class RTPStream {
607
607
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
608
608
  }
609
609
  const targetCodecId = this.options.supportedVideoCodecs[0] ?? FF_ENCODER_LIBX264;
610
- let encoderCodec = null;
611
- if (typeof targetCodecId === 'string') {
612
- encoderCodec = Codec.findEncoderByName(targetCodecId);
613
- }
614
- else {
615
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
616
- }
610
+ const encoderCodec = await this.findVideoEncoder(targetCodecId);
617
611
  if (!encoderCodec) {
618
612
  throw new Error(`No encoder found for video codec ${String(targetCodecId)}`);
619
613
  }
620
- let encoderOptions = {};
621
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
622
- encoderOptions.preset = 'ultrafast';
623
- encoderOptions.tune = 'zerolatency';
624
- // Make a requested keyframe (frame.pictType = I) an actual IDR, so a
625
- // receiver that missed the stream start can resync (PLI handling).
626
- encoderOptions['forced-idr'] = true;
627
- }
628
- encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
614
+ this.throwIfStopRequested();
615
+ const encoderOptions = liveVideoEncoderOptions(encoderCodec.name, this.options.video.encoderOptions);
629
616
  let fps = this.options.video.fps ?? 20;
630
617
  if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
631
618
  fps = 20;
@@ -644,12 +631,16 @@ export class RTPStream {
644
631
  configure: (ctx) => {
645
632
  ctx.framerate = new Rational(Math.round(fps), 1);
646
633
  ctx.gopSize = Math.max(1, Math.round(fps * 2));
647
- this.applyBitrate(ctx, bitrate);
634
+ applyVideoBitrate(ctx, bitrate);
648
635
  },
649
636
  });
637
+ // Source frames may sit on another device or in a format the encoder rejects
638
+ this.videoFilter = createVideoTranscodeFilter({ width: -1, height: -1, encoder: encoderCodec, hardware: this.hardwareContext });
650
639
  this.videoOutput = await this.createVideoOutput();
651
640
  this.throwIfStopRequested();
652
- controls.push(pipeline(this.wrapVideoSource(source.video), this.videoEncoder, this.videoOutput, opts));
641
+ // a frame source has no demuxer for stop() to interrupt, a silent one would block teardown
642
+ const video = interruptibleFrameSource(source.video, this.stopSignal.promise);
643
+ controls.push(pipeline(video, this.videoFilter, this.videoEncoder, this.videoOutput, opts));
653
644
  }
654
645
  // Audio
655
646
  if (source.audio) {
@@ -682,10 +673,12 @@ export class RTPStream {
682
673
  this.audioEncoder = await Encoder.create(encoderCodec, {
683
674
  filter: this.audioFilter,
684
675
  options: encoderOptions,
676
+ audioFrameBuffer: { maxGapFill: 0 },
685
677
  });
686
678
  this.audioOutput = await this.createAudioOutput();
687
679
  this.throwIfStopRequested();
688
- controls.push(pipeline(source.audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
680
+ const audio = interruptibleFrameSource(source.audio, this.stopSignal.promise);
681
+ controls.push(pipeline(audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
689
682
  }
690
683
  this.pipeline = new CombinedPipelineControl(controls);
691
684
  this.attachCompletion(this.pipeline.completion);
@@ -797,7 +790,7 @@ export class RTPStream {
797
790
  const opts = this.signal ? { signal: this.signal } : undefined;
798
791
  if (hasAudio && hasVideo) {
799
792
  this.pipeline = pipeline(this.input, {
800
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
793
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
801
794
  audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
802
795
  }, {
803
796
  video: this.videoOutput,
@@ -806,7 +799,7 @@ export class RTPStream {
806
799
  }
807
800
  else if (hasVideo) {
808
801
  this.pipeline = pipeline(this.input, {
809
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
802
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
810
803
  }, this.videoOutput, opts);
811
804
  }
812
805
  else if (hasAudio) {
@@ -825,7 +818,8 @@ export class RTPStream {
825
818
  *
826
819
  * Stops the pipeline, closes output, and releases all FFmpeg resources.
827
820
  * Safe to call multiple times. After stopping, you can call start() again
828
- * to restart the stream.
821
+ * to restart the stream. Resolves even if the pipeline had failed; that
822
+ * error is reported through `onClose(error)`.
829
823
  *
830
824
  * @example
831
825
  * ```typescript
@@ -859,6 +853,9 @@ export class RTPStream {
859
853
  this.stopRequested = true;
860
854
  this.startAbort?.abort();
861
855
  this.startAbort = undefined;
856
+ // Unblocks a frame source that went silent, so the completion barrier below
857
+ // cannot wait on a pull that will never settle.
858
+ this.stopSignal.resolve();
862
859
  const inflightStart = this.startPromise;
863
860
  // Allow a fresh start() after stopping.
864
861
  this.startPromise = undefined;
@@ -881,20 +878,38 @@ export class RTPStream {
881
878
  // the source demuxer's read (frame-source pipelines unwind via iterator.return
882
879
  // instead). Swallow a rejected completion here - the error is already surfaced
883
880
  // to the caller via the onClose(error) path; we just need cleanup to proceed.
884
- if (this.pipeline && !this.pipeline.isStopped()) {
885
- this.pipeline.stop();
881
+ const pipeline = this.pipeline;
882
+ if (pipeline) {
883
+ if (!pipeline.isStopped()) {
884
+ pipeline.stop();
885
+ }
886
886
  try {
887
- await this.pipeline.completion;
887
+ await pipeline.completion;
888
888
  }
889
889
  catch {
890
890
  // Pipeline errored - proceed with teardown regardless.
891
891
  }
892
- this.pipeline = undefined;
892
+ if (this.pipeline === pipeline) {
893
+ this.pipeline = undefined;
894
+ }
895
+ }
896
+ // Close all resources. close() rethrows a failed background write after it
897
+ // freed the muxer. That error already ended the pipeline and reaches the
898
+ // owner through onClose(error), so it must not abort the teardown here and
899
+ // leak the other output, the codecs and the input (an RTSP connection).
900
+ try {
901
+ await this.videoOutput?.close();
902
+ }
903
+ catch {
904
+ // Surfaced through the pipeline completion.
893
905
  }
894
- // Close all resources
895
- await this.videoOutput?.close();
896
906
  this.videoOutput = undefined;
897
- await this.audioOutput?.close();
907
+ try {
908
+ await this.audioOutput?.close();
909
+ }
910
+ catch {
911
+ // Surfaced through the pipeline completion.
912
+ }
898
913
  this.audioOutput = undefined;
899
914
  this.videoEncoder?.close();
900
915
  this.videoEncoder = undefined;
@@ -948,6 +963,27 @@ export class RTPStream {
948
963
  }
949
964
  return isSupported;
950
965
  }
966
+ /**
967
+ * Find the encoder for a target video codec, on the device when it works there.
968
+ *
969
+ * @param target - Codec ID, or the name of a specific encoder to use as it is
970
+ *
971
+ * @param size - The output size, when known before the first frame
972
+ *
973
+ * @param size.width - Width
974
+ *
975
+ * @param size.height - Height
976
+ *
977
+ * @returns The encoder, or null if none exists
978
+ *
979
+ * @internal
980
+ */
981
+ async findVideoEncoder(target, size) {
982
+ if (typeof target === 'string') {
983
+ return Codec.findEncoderByName(target);
984
+ }
985
+ return (await findUsableHardwareEncoder(this.hardwareContext, target, this.options.video.encoderOptions, size)) ?? findSoftwareEncoder(target);
986
+ }
951
987
  /**
952
988
  * Check if the given video codec is supported.
953
989
  *
@@ -1042,4 +1078,47 @@ export class RTPStream {
1042
1078
  return picked.mask.toString();
1043
1079
  }
1044
1080
  }
1081
+ /**
1082
+ * Encoder options for live video: low-latency presets for x264/x265 and an IDR
1083
+ * for every requested keyframe, so a receiver that lost packets can resync.
1084
+ *
1085
+ * @param name - The encoder's name
1086
+ *
1087
+ * @param overrides - Caller options, applied last
1088
+ *
1089
+ * @returns The encoder options
1090
+ *
1091
+ * @internal
1092
+ */
1093
+ function liveVideoEncoderOptions(name, overrides) {
1094
+ const options = {};
1095
+ if (name === FF_ENCODER_LIBX264 || name === FF_ENCODER_LIBX265) {
1096
+ options.preset = 'ultrafast';
1097
+ options.tune = 'zerolatency';
1098
+ }
1099
+ // VAAPI, D3D12 and VideoToolbox make a forced I frame an IDR without an option
1100
+ const forcedIdr = forcedIdrOption(name);
1101
+ if (forcedIdr) {
1102
+ options[forcedIdr] = true;
1103
+ }
1104
+ return { ...options, ...overrides };
1105
+ }
1106
+ /**
1107
+ * The option that makes an encoder emit a forced I frame (frame.pictType = I) as IDR.
1108
+ *
1109
+ * @param name - The encoder's name
1110
+ *
1111
+ * @returns The option, or null when the encoder needs none or has none
1112
+ *
1113
+ * @internal
1114
+ */
1115
+ function forcedIdrOption(name) {
1116
+ if (name === FF_ENCODER_LIBX264 || name === FF_ENCODER_LIBX265 || name?.endsWith('_nvenc')) {
1117
+ return 'forced-idr';
1118
+ }
1119
+ if (name?.endsWith('_qsv') || name?.endsWith('_amf')) {
1120
+ return 'forced_idr';
1121
+ }
1122
+ return null;
1123
+ }
1045
1124
  //# sourceMappingURL=rtp-stream.js.map