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,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 ?? (() => { }),
@@ -168,6 +171,9 @@ export class RTPStream {
168
171
  fps: options.video?.fps,
169
172
  width: options.video?.width,
170
173
  height: options.video?.height,
174
+ bitrate: options.video?.bitrate,
175
+ dumpExtra: options.video?.dumpExtra,
176
+ transcode: options.video?.transcode,
171
177
  encoderOptions: options.video?.encoderOptions ?? {},
172
178
  },
173
179
  audio: {
@@ -227,10 +233,14 @@ export class RTPStream {
227
233
  /**
228
234
  * Check if the stream is active.
229
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
+ *
230
240
  * @returns True if the stream is active, false otherwise
231
241
  */
232
242
  get isStreamActive() {
233
- return this.pipeline !== undefined && !this.pipeline.isStopped();
243
+ return this.pipeline !== undefined;
234
244
  }
235
245
  /**
236
246
  * Get the demuxer instance.
@@ -258,9 +268,9 @@ export class RTPStream {
258
268
  *
259
269
  * Used to answer receiver feedback (e.g. a WebRTC PLI) so a receiver that
260
270
  * missed the stream start or lost packets can resync without waiting for the
261
- * next scheduled GOP keyframe. Currently effective for frame-source inputs
262
- * ({@link MediaFrameSource}); for demuxer inputs the encoder's regular GOP
263
- * 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.
264
274
  *
265
275
  * @example
266
276
  * ```typescript
@@ -270,29 +280,7 @@ export class RTPStream {
270
280
  * ```
271
281
  */
272
282
  requestVideoKeyframe() {
273
- this.videoKeyframeRequested = true;
274
- }
275
- /**
276
- * Pass video frames through, forcing a keyframe when one was requested.
277
- *
278
- * Sets `pictType = I` on the next frame after {@link requestVideoKeyframe};
279
- * combined with the encoder's `forced-idr` option this produces an IDR the
280
- * receiver can resync on.
281
- *
282
- * @param src - The user-provided video frame source
283
- *
284
- * @yields {Frame | null} The unchanged frames (with pictType forced when requested)
285
- *
286
- * @internal
287
- */
288
- async *wrapVideoSource(src) {
289
- for await (const frame of src) {
290
- if (frame && this.videoKeyframeRequested) {
291
- this.videoKeyframeRequested = false;
292
- frame.pictType = AV_PICTURE_TYPE_I;
293
- }
294
- yield frame;
295
- }
283
+ this.videoEncoder?.requestKeyframe();
296
284
  }
297
285
  /**
298
286
  * Start streaming media to RTP packets.
@@ -355,6 +343,8 @@ export class RTPStream {
355
343
  };
356
344
  this.signal.addEventListener('abort', this.abortHandler, { once: true });
357
345
  }
346
+ // Re-armed per start so a restart is not torn down by the previous run.
347
+ this.stopSignal = Promise.withResolvers();
358
348
  // Lets stop() interrupt a startup that is blocked in the input open
359
349
  // (an RTSP connect can take seconds).
360
350
  this.startAbort = new AbortController();
@@ -373,8 +363,9 @@ export class RTPStream {
373
363
  this.throwIfStopRequested();
374
364
  const videoStream = this.input.video();
375
365
  const audioStream = this.input.audio();
366
+ let videoOutputIsHevc = videoStream?.codecpar.codecId === AV_CODEC_ID_HEVC;
376
367
  // Setup video transcoding if needed
377
- if (videoStream && !this.isVideoCodecSupported(videoStream.codecpar.codecId)) {
368
+ if (videoStream && (this.options.video.transcode || !this.isVideoCodecSupported(videoStream.codecpar.codecId))) {
378
369
  // Check if we need hardware acceleration
379
370
  if (this.options.hardware === 'auto') {
380
371
  this.hardwareContext = HardwareContext.auto();
@@ -382,80 +373,54 @@ export class RTPStream {
382
373
  else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
383
374
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
384
375
  }
385
- this.videoDecoder = await Decoder.create(videoStream, {
386
- exitOnError: false,
387
- hardware: this.hardwareContext,
388
- });
389
- // Determine if we need filters by comparing with current stream properties
390
- const currentWidth = videoStream.codecpar.width;
391
- const currentHeight = videoStream.codecpar.height;
392
376
  const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
393
- const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
394
- (this.options.video.height !== undefined && this.options.video.height !== currentHeight);
395
377
  const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
396
378
  // Get first supported codec
397
379
  const targetCodecId = this.options.supportedVideoCodecs[0];
398
380
  if (!targetCodecId) {
399
381
  throw new Error('No supported video codec specified for transcoding');
400
382
  }
401
- let encoderCodec = null;
402
- if (typeof targetCodecId === 'string') {
403
- encoderCodec = Codec.findEncoderByName(targetCodecId);
404
- }
405
- else {
406
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
407
- }
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);
408
387
  if (!encoderCodec) {
409
388
  throw new Error(`No encoder found for codec ID ${targetCodecId}`);
410
389
  }
411
- // Create filter chain only if needed
412
- if (needsScale || needsFps) {
413
- const filterChain = FilterPreset.chain(this.hardwareContext);
414
- // Add scale filter if dimensions differ
415
- if (needsScale) {
416
- const targetWidth = this.options.video.width ?? -1;
417
- const targetHeight = this.options.video.height ?? -1;
418
- filterChain.scale(targetWidth, targetHeight);
419
- }
420
- // Add fps filter if fps differs
421
- if (needsFps) {
422
- filterChain.filter('fps', { fps: this.options.video.fps });
423
- }
424
- // Software scaling leaves the graph output format unconstrained, so swscale keeps the
425
- // source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
426
- // in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
427
- // anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
428
- // one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
429
- // profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
430
- // codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
431
- // negotiate their format through the frames context instead.
432
- if (needsScale && !this.hardwareContext) {
433
- const formats = encoderCodec.pixelFormats;
434
- const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
435
- const targetFmtName = avGetPixFmtName(targetFmt);
436
- if (targetFmtName) {
437
- filterChain.filter('format', { pix_fmts: targetFmtName });
438
- }
439
- }
440
- this.videoFilter = FilterAPI.create(filterChain.build(), {
441
- hardware: this.hardwareContext,
442
- });
443
- }
444
- let encoderOptions = {};
445
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
446
- encoderOptions.preset = 'ultrafast';
447
- encoderOptions.tune = 'zerolatency';
448
- }
449
- encoderOptions = {
450
- ...encoderOptions,
451
- ...this.options.video.encoderOptions,
452
- };
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);
405
+ const bitrate = this.options.video.bitrate;
453
406
  this.videoEncoder = await Encoder.create(encoderCodec, {
454
407
  decoder: this.videoDecoder,
408
+ filter: this.videoFilter,
455
409
  maxBFrames: 0,
456
410
  options: encoderOptions,
411
+ configure: (ctx) => {
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);
417
+ },
457
418
  });
458
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' } });
459
424
  }
460
425
  // Setup video output
461
426
  this.videoOutput = await this.createVideoOutput();
@@ -505,6 +470,9 @@ export class RTPStream {
505
470
  decoder: this.audioDecoder,
506
471
  filter: this.audioFilter,
507
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 },
508
476
  });
509
477
  this.throwIfStopRequested();
510
478
  }
@@ -536,24 +504,83 @@ export class RTPStream {
536
504
  * Wire the pipeline completion to the onClose/error callbacks.
537
505
  *
538
506
  * On success invokes `onClose()`; on error stops the stream and invokes
539
- * `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).
540
509
  *
541
510
  * @param completion - The pipeline completion promise
542
511
  *
543
512
  * @internal
544
513
  */
545
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.
546
519
  completion
547
520
  .then(() => {
548
521
  // Pipeline completed successfully
549
- 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);
550
542
  })
551
- .catch(async (error) => {
552
- console.error('[RTPStream] Pipeline error:', error);
553
- await this.stop();
554
- 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
+ }
555
550
  });
556
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
+ }
557
584
  /**
558
585
  * Set up encoders and RTP outputs for a pre-composited frame source and start
559
586
  * encoding. No demuxer or decoder is involved - the provided frames are encoded
@@ -580,25 +607,12 @@ export class RTPStream {
580
607
  this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
581
608
  }
582
609
  const targetCodecId = this.options.supportedVideoCodecs[0] ?? FF_ENCODER_LIBX264;
583
- let encoderCodec = null;
584
- if (typeof targetCodecId === 'string') {
585
- encoderCodec = Codec.findEncoderByName(targetCodecId);
586
- }
587
- else {
588
- encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
589
- }
610
+ const encoderCodec = await this.findVideoEncoder(targetCodecId);
590
611
  if (!encoderCodec) {
591
612
  throw new Error(`No encoder found for video codec ${String(targetCodecId)}`);
592
613
  }
593
- let encoderOptions = {};
594
- if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
595
- encoderOptions.preset = 'ultrafast';
596
- encoderOptions.tune = 'zerolatency';
597
- // Make a requested keyframe (frame.pictType = I) an actual IDR, so a
598
- // receiver that missed the stream start can resync (PLI handling).
599
- encoderOptions['forced-idr'] = true;
600
- }
601
- encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
614
+ this.throwIfStopRequested();
615
+ const encoderOptions = liveVideoEncoderOptions(encoderCodec.name, this.options.video.encoderOptions);
602
616
  let fps = this.options.video.fps ?? 20;
603
617
  if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
604
618
  fps = 20;
@@ -610,17 +624,23 @@ export class RTPStream {
610
624
  // browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
611
625
  // that join (or lose) mid-stream must get a keyframe quickly - the x264
612
626
  // default of 250 frames leaves them waiting for many seconds.
627
+ const bitrate = this.options.video.bitrate;
613
628
  this.videoEncoder = await Encoder.create(encoderCodec, {
614
629
  maxBFrames: 0,
615
630
  options: encoderOptions,
616
631
  configure: (ctx) => {
617
632
  ctx.framerate = new Rational(Math.round(fps), 1);
618
633
  ctx.gopSize = Math.max(1, Math.round(fps * 2));
634
+ applyVideoBitrate(ctx, bitrate);
619
635
  },
620
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 });
621
639
  this.videoOutput = await this.createVideoOutput();
622
640
  this.throwIfStopRequested();
623
- 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));
624
644
  }
625
645
  // Audio
626
646
  if (source.audio) {
@@ -653,10 +673,12 @@ export class RTPStream {
653
673
  this.audioEncoder = await Encoder.create(encoderCodec, {
654
674
  filter: this.audioFilter,
655
675
  options: encoderOptions,
676
+ audioFrameBuffer: { maxGapFill: 0 },
656
677
  });
657
678
  this.audioOutput = await this.createAudioOutput();
658
679
  this.throwIfStopRequested();
659
- 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));
660
682
  }
661
683
  this.pipeline = new CombinedPipelineControl(controls);
662
684
  this.attachCompletion(this.pipeline.completion);
@@ -768,7 +790,7 @@ export class RTPStream {
768
790
  const opts = this.signal ? { signal: this.signal } : undefined;
769
791
  if (hasAudio && hasVideo) {
770
792
  this.pipeline = pipeline(this.input, {
771
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
793
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
772
794
  audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
773
795
  }, {
774
796
  video: this.videoOutput,
@@ -777,7 +799,7 @@ export class RTPStream {
777
799
  }
778
800
  else if (hasVideo) {
779
801
  this.pipeline = pipeline(this.input, {
780
- video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
802
+ video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
781
803
  }, this.videoOutput, opts);
782
804
  }
783
805
  else if (hasAudio) {
@@ -796,7 +818,8 @@ export class RTPStream {
796
818
  *
797
819
  * Stops the pipeline, closes output, and releases all FFmpeg resources.
798
820
  * Safe to call multiple times. After stopping, you can call start() again
799
- * to restart the stream.
821
+ * to restart the stream. Resolves even if the pipeline had failed; that
822
+ * error is reported through `onClose(error)`.
800
823
  *
801
824
  * @example
802
825
  * ```typescript
@@ -830,6 +853,9 @@ export class RTPStream {
830
853
  this.stopRequested = true;
831
854
  this.startAbort?.abort();
832
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();
833
859
  const inflightStart = this.startPromise;
834
860
  // Allow a fresh start() after stopping.
835
861
  this.startPromise = undefined;
@@ -852,20 +878,38 @@ export class RTPStream {
852
878
  // the source demuxer's read (frame-source pipelines unwind via iterator.return
853
879
  // instead). Swallow a rejected completion here - the error is already surfaced
854
880
  // to the caller via the onClose(error) path; we just need cleanup to proceed.
855
- if (this.pipeline && !this.pipeline.isStopped()) {
856
- this.pipeline.stop();
881
+ const pipeline = this.pipeline;
882
+ if (pipeline) {
883
+ if (!pipeline.isStopped()) {
884
+ pipeline.stop();
885
+ }
857
886
  try {
858
- await this.pipeline.completion;
887
+ await pipeline.completion;
859
888
  }
860
889
  catch {
861
890
  // Pipeline errored - proceed with teardown regardless.
862
891
  }
863
- 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.
864
905
  }
865
- // Close all resources
866
- await this.videoOutput?.close();
867
906
  this.videoOutput = undefined;
868
- await this.audioOutput?.close();
907
+ try {
908
+ await this.audioOutput?.close();
909
+ }
910
+ catch {
911
+ // Surfaced through the pipeline completion.
912
+ }
869
913
  this.audioOutput = undefined;
870
914
  this.videoEncoder?.close();
871
915
  this.videoEncoder = undefined;
@@ -919,6 +963,27 @@ export class RTPStream {
919
963
  }
920
964
  return isSupported;
921
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
+ }
922
987
  /**
923
988
  * Check if the given video codec is supported.
924
989
  *
@@ -1013,4 +1078,47 @@ export class RTPStream {
1013
1078
  return picked.mask.toString();
1014
1079
  }
1015
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
+ }
1016
1124
  //# sourceMappingURL=rtp-stream.js.map