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.
- 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 +58 -20
- package/dist/api/rtp-stream.js +237 -129
- 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 ?? (() => { }),
|
|
@@ -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
|
|
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.
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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.
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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)`.
|
|
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.
|
|
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
|
-
.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
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
|
+
}
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
856
|
-
|
|
881
|
+
const pipeline = this.pipeline;
|
|
882
|
+
if (pipeline) {
|
|
883
|
+
if (!pipeline.isStopped()) {
|
|
884
|
+
pipeline.stop();
|
|
885
|
+
}
|
|
857
886
|
try {
|
|
858
|
-
await
|
|
887
|
+
await pipeline.completion;
|
|
859
888
|
}
|
|
860
889
|
catch {
|
|
861
890
|
// Pipeline errored - proceed with teardown regardless.
|
|
862
891
|
}
|
|
863
|
-
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.
|
|
864
905
|
}
|
|
865
|
-
// Close all resources
|
|
866
|
-
await this.videoOutput?.close();
|
|
867
906
|
this.videoOutput = undefined;
|
|
868
|
-
|
|
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
|