node-av 6.2.0-beta.9 → 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.
- package/README.md +7 -19
- package/dist/api/audio-frame-buffer.d.ts +378 -19
- package/dist/api/audio-frame-buffer.js +715 -101
- package/dist/api/audio-frame-buffer.js.map +1 -1
- package/dist/api/data/test_av1.ivf +0 -0
- package/dist/api/data/test_hevc.h265 +0 -0
- package/dist/api/data/test_vp9.ivf +0 -0
- package/dist/api/decoder.d.ts +117 -2
- package/dist/api/decoder.js +290 -23
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +19 -3
- package/dist/api/demuxer.js +95 -29
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.js +9 -1
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +100 -6
- package/dist/api/encoder.js +249 -108
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter-presets.d.ts +25 -1
- package/dist/api/filter-presets.js +63 -3
- package/dist/api/filter-presets.js.map +1 -1
- package/dist/api/filter.d.ts +62 -3
- package/dist/api/filter.js +118 -36
- package/dist/api/filter.js.map +1 -1
- package/dist/api/fmp4-stream.d.ts +179 -15
- package/dist/api/fmp4-stream.js +341 -120
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.d.ts +2 -0
- package/dist/api/hardware.js +33 -10
- package/dist/api/hardware.js.map +1 -1
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js.map +1 -1
- package/dist/api/io-stream.d.ts +3 -2
- package/dist/api/io-stream.js +23 -7
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +276 -7
- package/dist/api/muxer.js +649 -116
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +22 -0
- package/dist/api/pipeline.js +53 -0
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +54 -33
- package/dist/api/rtp-stream.js +232 -153
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +39 -6
- package/dist/api/scaler.js +72 -18
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/utilities/live-decoding.d.ts +15 -0
- package/dist/api/utilities/live-decoding.js +32 -0
- package/dist/api/utilities/live-decoding.js.map +1 -0
- package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
- package/dist/api/utilities/packet-age-watchdog.js +594 -0
- package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
- package/dist/api/utilities/scale-size.d.ts +51 -0
- package/dist/api/utilities/scale-size.js +55 -0
- package/dist/api/utilities/scale-size.js.map +1 -0
- package/dist/api/utilities/video-transcode.d.ts +218 -0
- package/dist/api/utilities/video-transcode.js +624 -0
- package/dist/api/utilities/video-transcode.js.map +1 -0
- package/dist/constants/constants.d.ts +1 -0
- package/dist/constants/constants.js +1 -0
- package/dist/constants/constants.js.map +1 -1
- package/dist/constants/filter-options.d.ts +2 -0
- package/dist/ffmpeg/utils.js +7 -4
- package/dist/ffmpeg/utils.js.map +1 -1
- package/dist/lib/binding.d.ts +10 -1
- package/dist/lib/binding.js +25 -16
- package/dist/lib/binding.js.map +1 -1
- package/dist/lib/bitstream-filter-context.d.ts +2 -0
- package/dist/lib/bitstream-filter-context.js +2 -0
- package/dist/lib/bitstream-filter-context.js.map +1 -1
- package/dist/lib/filter-context.d.ts +3 -0
- package/dist/lib/filter-context.js +3 -0
- package/dist/lib/filter-context.js.map +1 -1
- package/dist/lib/filter-graph-segment.d.ts +3 -2
- package/dist/lib/filter-graph-segment.js +3 -2
- package/dist/lib/filter-graph-segment.js.map +1 -1
- package/dist/lib/filter-graph.d.ts +4 -4
- package/dist/lib/filter-graph.js +4 -4
- package/dist/lib/filter-inout.d.ts +4 -0
- package/dist/lib/filter-inout.js +4 -0
- package/dist/lib/filter-inout.js.map +1 -1
- package/dist/lib/format-context.d.ts +27 -3
- package/dist/lib/format-context.js +30 -6
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +6 -2
- package/dist/lib/frame.js +11 -3
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/io-context.d.ts +4 -2
- package/dist/lib/io-context.js +4 -2
- package/dist/lib/io-context.js.map +1 -1
- package/dist/lib/native-types.d.ts +7 -3
- package/dist/lib/output-format.d.ts +42 -0
- package/dist/lib/output-format.js +46 -0
- package/dist/lib/output-format.js.map +1 -1
- package/dist/lib/stream.d.ts +5 -0
- package/dist/lib/stream.js +5 -0
- package/dist/lib/stream.js.map +1 -1
- package/dist/lib/utilities.d.ts +26 -0
- package/dist/lib/utilities.js +28 -0
- package/dist/lib/utilities.js.map +1 -1
- package/install/check.js +14 -16
- package/package.json +27 -44
package/dist/api/rtp-stream.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { isRtcp, RtpPacket } from 'werift';
|
|
2
|
-
import {
|
|
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
|
|
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,
|
|
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
|
|
155
|
-
this.supportedAudioCodecs = new Set(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
|
|
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.
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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.
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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)`.
|
|
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.
|
|
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
|
-
.
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
881
|
+
const pipeline = this.pipeline;
|
|
882
|
+
if (pipeline) {
|
|
883
|
+
if (!pipeline.isStopped()) {
|
|
884
|
+
pipeline.stop();
|
|
885
|
+
}
|
|
886
886
|
try {
|
|
887
|
-
await
|
|
887
|
+
await pipeline.completion;
|
|
888
888
|
}
|
|
889
889
|
catch {
|
|
890
890
|
// Pipeline errored - proceed with teardown regardless.
|
|
891
891
|
}
|
|
892
|
-
this.pipeline
|
|
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
|
-
|
|
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
|