node-av 6.2.0-beta.3 → 6.2.0-beta.30
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 +13 -8
- package/dist/api/audio-frame-buffer.d.ts +438 -7
- package/dist/api/audio-frame-buffer.js +792 -52
- package/dist/api/audio-frame-buffer.js.map +1 -1
- package/dist/api/bitstream-filter.d.ts +82 -4
- package/dist/api/bitstream-filter.js +197 -100
- package/dist/api/bitstream-filter.js.map +1 -1
- package/dist/api/decoder.d.ts +5 -0
- package/dist/api/decoder.js +13 -6
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +29 -4
- package/dist/api/demuxer.js +209 -60
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.d.ts +43 -1
- package/dist/api/encoder-pool.js +121 -9
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +58 -2
- package/dist/api/encoder.js +173 -57
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter.js +15 -6
- package/dist/api/filter.js.map +1 -1
- package/dist/api/fmp4-stream.d.ts +327 -8
- package/dist/api/fmp4-stream.js +663 -123
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.d.ts +2 -0
- package/dist/api/hardware.js +25 -4
- 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 +76 -5
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +289 -7
- package/dist/api/muxer.js +696 -109
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +24 -0
- package/dist/api/pipeline.js +76 -5
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +77 -8
- package/dist/api/rtp-stream.js +316 -88
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +124 -21
- package/dist/api/scaler.js +347 -114
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/utilities/async-queue.d.ts +3 -0
- package/dist/api/utilities/async-queue.js +8 -0
- package/dist/api/utilities/async-queue.js.map +1 -1
- 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/webrtc-stream.d.ts +2 -0
- package/dist/api/webrtc-stream.js +46 -12
- package/dist/api/webrtc-stream.js.map +1 -1
- package/dist/api/whisper.d.ts +2 -0
- package/dist/api/whisper.js +63 -23
- package/dist/api/whisper.js.map +1 -1
- 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/install.js +104 -26
- package/dist/ffmpeg/install.js.map +1 -1
- 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/codec-context.d.ts +0 -2
- package/dist/lib/codec-context.js +4 -22
- package/dist/lib/codec-context.js.map +1 -1
- package/dist/lib/filter-context.d.ts +0 -1
- package/dist/lib/filter-context.js +2 -11
- package/dist/lib/filter-context.js.map +1 -1
- package/dist/lib/format-context.d.ts +70 -7
- package/dist/lib/format-context.js +77 -21
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +17 -13
- package/dist/lib/frame.js +24 -23
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/hardware-frames-context.d.ts +0 -1
- package/dist/lib/hardware-frames-context.js +2 -8
- package/dist/lib/hardware-frames-context.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 -5
- package/dist/lib/option.d.ts +26 -0
- package/dist/lib/option.js +42 -18
- package/dist/lib/option.js.map +1 -1
- package/dist/lib/packet.d.ts +5 -0
- package/dist/lib/packet.js +5 -0
- package/dist/lib/packet.js.map +1 -1
- package/dist/lib/stream.d.ts +8 -4
- package/dist/lib/stream.js +22 -24
- package/dist/lib/stream.js.map +1 -1
- package/install/check.js +40 -79
- package/package.json +45 -34
- package/build_mac_local.sh +0 -69
package/dist/api/rtp-stream.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { isRtcp, RtpPacket } from 'werift';
|
|
2
|
-
import { AV_HWDEVICE_TYPE_NONE, AV_PICTURE_TYPE_I, AV_SAMPLE_FMT_FLTP } from '../constants/constants.js';
|
|
2
|
+
import { AV_CODEC_ID_HEVC, AV_HWDEVICE_TYPE_NONE, AV_PICTURE_TYPE_I, AV_PIX_FMT_YUV420P, 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 } from '../lib/utilities.js';
|
|
6
|
+
import { avChannelLayoutDefault, avGetPixFmtName } from '../lib/utilities.js';
|
|
7
|
+
import { BitStreamFilterAPI } from './bitstream-filter.js';
|
|
7
8
|
import { MAX_PACKET_SIZE } from './constants.js';
|
|
8
|
-
import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
|
|
9
9
|
import { Decoder } from './decoder.js';
|
|
10
10
|
import { Demuxer } from './demuxer.js';
|
|
11
11
|
import { Encoder } from './encoder.js';
|
|
@@ -13,7 +13,8 @@ import { FilterPreset } from './filter-presets.js';
|
|
|
13
13
|
import { FilterAPI } from './filter.js';
|
|
14
14
|
import { HardwareContext } from './hardware.js';
|
|
15
15
|
import { Muxer } from './muxer.js';
|
|
16
|
-
import { pipeline } from './pipeline.js';
|
|
16
|
+
import { interruptibleFrameSource, pipeline } from './pipeline.js';
|
|
17
|
+
import { pickSupportedLayout, pickSupportedPixelFormat, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
|
|
17
18
|
/**
|
|
18
19
|
* Check whether a streaming-class input is a frame source (not a URL or Demuxer).
|
|
19
20
|
*
|
|
@@ -100,6 +101,7 @@ export class RTPStream {
|
|
|
100
101
|
videoDecoder;
|
|
101
102
|
videoFilter;
|
|
102
103
|
videoEncoder;
|
|
104
|
+
videoBitstreamFilter;
|
|
103
105
|
audioDecoder;
|
|
104
106
|
audioFilter;
|
|
105
107
|
audioEncoder;
|
|
@@ -109,6 +111,11 @@ export class RTPStream {
|
|
|
109
111
|
supportedVideoCodecs;
|
|
110
112
|
supportedAudioCodecs;
|
|
111
113
|
videoKeyframeRequested = false;
|
|
114
|
+
abortHandler;
|
|
115
|
+
stopRequested = false;
|
|
116
|
+
stopPromise;
|
|
117
|
+
startAbort;
|
|
118
|
+
stopSignal = Promise.withResolvers();
|
|
112
119
|
/**
|
|
113
120
|
* @param input - Media input URL or pre-opened Demuxer
|
|
114
121
|
*
|
|
@@ -161,9 +168,11 @@ export class RTPStream {
|
|
|
161
168
|
ssrc: options.video?.ssrc,
|
|
162
169
|
payloadType: options.video?.payloadType,
|
|
163
170
|
mtu: options.video?.mtu ?? MAX_PACKET_SIZE,
|
|
164
|
-
fps: options.video?.fps
|
|
171
|
+
fps: options.video?.fps,
|
|
165
172
|
width: options.video?.width,
|
|
166
173
|
height: options.video?.height,
|
|
174
|
+
bitrate: options.video?.bitrate,
|
|
175
|
+
dumpExtra: options.video?.dumpExtra,
|
|
167
176
|
encoderOptions: options.video?.encoderOptions ?? {},
|
|
168
177
|
},
|
|
169
178
|
audio: {
|
|
@@ -223,10 +232,14 @@ export class RTPStream {
|
|
|
223
232
|
/**
|
|
224
233
|
* Check if the stream is active.
|
|
225
234
|
*
|
|
235
|
+
* Stays true while a requested stop is still unwinding: the pipeline reports
|
|
236
|
+
* itself stopped as soon as stop() is called, but its packets keep flowing
|
|
237
|
+
* and its native resources stay open until completion settles.
|
|
238
|
+
*
|
|
226
239
|
* @returns True if the stream is active, false otherwise
|
|
227
240
|
*/
|
|
228
241
|
get isStreamActive() {
|
|
229
|
-
return this.pipeline !== undefined
|
|
242
|
+
return this.pipeline !== undefined;
|
|
230
243
|
}
|
|
231
244
|
/**
|
|
232
245
|
* Get the demuxer instance.
|
|
@@ -282,7 +295,9 @@ export class RTPStream {
|
|
|
282
295
|
* @internal
|
|
283
296
|
*/
|
|
284
297
|
async *wrapVideoSource(src) {
|
|
285
|
-
|
|
298
|
+
// Pull through the interruptible wrapper: a frame source has no demuxer for
|
|
299
|
+
// stop() to interrupt, so without it a silent source blocks teardown.
|
|
300
|
+
for await (const frame of interruptibleFrameSource(src, this.stopSignal.promise)) {
|
|
286
301
|
if (frame && this.videoKeyframeRequested) {
|
|
287
302
|
this.videoKeyframeRequested = false;
|
|
288
303
|
frame.pictType = AV_PICTURE_TYPE_I;
|
|
@@ -332,11 +347,30 @@ export class RTPStream {
|
|
|
332
347
|
* @internal
|
|
333
348
|
*/
|
|
334
349
|
async doStart() {
|
|
350
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
351
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
352
|
+
if (this.stopPromise) {
|
|
353
|
+
await this.stopPromise;
|
|
354
|
+
}
|
|
335
355
|
if (this.pipeline) {
|
|
336
356
|
return;
|
|
337
357
|
}
|
|
338
358
|
this.signal?.throwIfAborted();
|
|
339
|
-
this.signal
|
|
359
|
+
if (this.signal && !this.abortHandler) {
|
|
360
|
+
// Stop on abort. Keep a reference so stop() can detach the listener again,
|
|
361
|
+
// otherwise restarts would stack one listener per start(). Errors from the
|
|
362
|
+
// async stop() are swallowed here; they already surface via onClose(error).
|
|
363
|
+
this.abortHandler = () => {
|
|
364
|
+
this.abortHandler = undefined;
|
|
365
|
+
this.stop().catch(() => { });
|
|
366
|
+
};
|
|
367
|
+
this.signal.addEventListener('abort', this.abortHandler, { once: true });
|
|
368
|
+
}
|
|
369
|
+
// Re-armed per start so a restart is not torn down by the previous run.
|
|
370
|
+
this.stopSignal = Promise.withResolvers();
|
|
371
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
372
|
+
// (an RTSP connect can take seconds).
|
|
373
|
+
this.startAbort = new AbortController();
|
|
340
374
|
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
341
375
|
if (this.source) {
|
|
342
376
|
await this.startFromFrames();
|
|
@@ -346,10 +380,13 @@ export class RTPStream {
|
|
|
346
380
|
if (!this.inputUrl) {
|
|
347
381
|
throw new Error('No input URL or Demuxer provided');
|
|
348
382
|
}
|
|
349
|
-
this.
|
|
383
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
384
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
350
385
|
}
|
|
386
|
+
this.throwIfStopRequested();
|
|
351
387
|
const videoStream = this.input.video();
|
|
352
388
|
const audioStream = this.input.audio();
|
|
389
|
+
let videoOutputIsHevc = videoStream?.codecpar.codecId === AV_CODEC_ID_HEVC;
|
|
353
390
|
// Setup video transcoding if needed
|
|
354
391
|
if (videoStream && !this.isVideoCodecSupported(videoStream.codecpar.codecId)) {
|
|
355
392
|
// Check if we need hardware acceleration
|
|
@@ -370,6 +407,21 @@ export class RTPStream {
|
|
|
370
407
|
const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
|
|
371
408
|
(this.options.video.height !== undefined && this.options.video.height !== currentHeight);
|
|
372
409
|
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
410
|
+
// Get first supported codec
|
|
411
|
+
const targetCodecId = this.options.supportedVideoCodecs[0];
|
|
412
|
+
if (!targetCodecId) {
|
|
413
|
+
throw new Error('No supported video codec specified for transcoding');
|
|
414
|
+
}
|
|
415
|
+
let encoderCodec = null;
|
|
416
|
+
if (typeof targetCodecId === 'string') {
|
|
417
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
|
|
421
|
+
}
|
|
422
|
+
if (!encoderCodec) {
|
|
423
|
+
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
424
|
+
}
|
|
373
425
|
// Create filter chain only if needed
|
|
374
426
|
if (needsScale || needsFps) {
|
|
375
427
|
const filterChain = FilterPreset.chain(this.hardwareContext);
|
|
@@ -383,25 +435,26 @@ export class RTPStream {
|
|
|
383
435
|
if (needsFps) {
|
|
384
436
|
filterChain.filter('fps', { fps: this.options.video.fps });
|
|
385
437
|
}
|
|
438
|
+
// Software scaling leaves the graph output format unconstrained, so swscale keeps the
|
|
439
|
+
// source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
|
|
440
|
+
// in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
|
|
441
|
+
// anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
|
|
442
|
+
// one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
|
|
443
|
+
// profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
|
|
444
|
+
// codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
|
|
445
|
+
// negotiate their format through the frames context instead.
|
|
446
|
+
if (needsScale && !this.hardwareContext) {
|
|
447
|
+
const formats = encoderCodec.pixelFormats;
|
|
448
|
+
const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
|
|
449
|
+
const targetFmtName = avGetPixFmtName(targetFmt);
|
|
450
|
+
if (targetFmtName) {
|
|
451
|
+
filterChain.filter('format', { pix_fmts: targetFmtName });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
386
454
|
this.videoFilter = FilterAPI.create(filterChain.build(), {
|
|
387
455
|
hardware: this.hardwareContext,
|
|
388
456
|
});
|
|
389
457
|
}
|
|
390
|
-
// Get first supported codec
|
|
391
|
-
const targetCodecId = this.options.supportedVideoCodecs[0];
|
|
392
|
-
if (!targetCodecId) {
|
|
393
|
-
throw new Error('No supported video codec specified for transcoding');
|
|
394
|
-
}
|
|
395
|
-
let encoderCodec = null;
|
|
396
|
-
if (typeof targetCodecId === 'string') {
|
|
397
|
-
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
398
|
-
}
|
|
399
|
-
else {
|
|
400
|
-
encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
|
|
401
|
-
}
|
|
402
|
-
if (!encoderCodec) {
|
|
403
|
-
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
404
|
-
}
|
|
405
458
|
let encoderOptions = {};
|
|
406
459
|
if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
|
|
407
460
|
encoderOptions.preset = 'ultrafast';
|
|
@@ -411,20 +464,25 @@ export class RTPStream {
|
|
|
411
464
|
...encoderOptions,
|
|
412
465
|
...this.options.video.encoderOptions,
|
|
413
466
|
};
|
|
467
|
+
const bitrate = this.options.video.bitrate;
|
|
414
468
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
415
469
|
decoder: this.videoDecoder,
|
|
470
|
+
filter: this.videoFilter,
|
|
416
471
|
maxBFrames: 0,
|
|
417
472
|
options: encoderOptions,
|
|
473
|
+
configure: (ctx) => {
|
|
474
|
+
this.applyBitrate(ctx, bitrate);
|
|
475
|
+
},
|
|
418
476
|
});
|
|
477
|
+
this.throwIfStopRequested();
|
|
478
|
+
videoOutputIsHevc = encoderCodec.id === AV_CODEC_ID_HEVC;
|
|
419
479
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
let fps = this.options.video.fps ?? videoStreamFps;
|
|
423
|
-
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
424
|
-
fps = 20; // Default to 20 FPS if invalid
|
|
480
|
+
if (videoStream && videoOutputIsHevc && this.options.video?.dumpExtra) {
|
|
481
|
+
this.videoBitstreamFilter = BitStreamFilterAPI.create('dump_extra', this.videoEncoder ?? videoStream, { options: { freq: 'k' } });
|
|
425
482
|
}
|
|
426
483
|
// Setup video output
|
|
427
|
-
this.videoOutput = await this.createVideoOutput(
|
|
484
|
+
this.videoOutput = await this.createVideoOutput();
|
|
485
|
+
this.throwIfStopRequested();
|
|
428
486
|
// Setup audio if available and needs transcoding
|
|
429
487
|
if (audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId)) {
|
|
430
488
|
this.audioDecoder = await Decoder.create(audioStream, {
|
|
@@ -470,37 +528,139 @@ export class RTPStream {
|
|
|
470
528
|
decoder: this.audioDecoder,
|
|
471
529
|
filter: this.audioFilter,
|
|
472
530
|
options: encoderOptions,
|
|
531
|
+
// Silence for an input gap would only go out once the input resumes, as a burst of
|
|
532
|
+
// late packets; receivers conceal a timestamp gap like packet loss instead
|
|
533
|
+
audioFrameBuffer: { maxGapFill: 0 },
|
|
473
534
|
});
|
|
535
|
+
this.throwIfStopRequested();
|
|
474
536
|
}
|
|
475
537
|
// Setup audio output if available
|
|
476
538
|
if (audioStream) {
|
|
477
539
|
this.audioOutput = await this.createAudioOutput();
|
|
540
|
+
this.throwIfStopRequested();
|
|
478
541
|
}
|
|
479
542
|
// Start pipeline in background (don't await)
|
|
480
543
|
this.attachCompletion(this.runPipeline());
|
|
481
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* Abort an in-flight startup once stop() was requested.
|
|
547
|
+
*
|
|
548
|
+
* Checked after every await in the startup path: the resources created so
|
|
549
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
550
|
+
* this startup tears them down the moment it unwinds.
|
|
551
|
+
*
|
|
552
|
+
* @throws {Error} If stop() was called while starting
|
|
553
|
+
*
|
|
554
|
+
* @internal
|
|
555
|
+
*/
|
|
556
|
+
throwIfStopRequested() {
|
|
557
|
+
if (this.stopRequested) {
|
|
558
|
+
throw new Error('RTPStream stopped during start');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Cap the video bitrate on the encoder context.
|
|
563
|
+
*
|
|
564
|
+
* Sets the target bitrate together with a VBV cap (maxrate + a 1s buffer).
|
|
565
|
+
* Encoder.create() always seeds a default target bitrate (1 Mbps for video),
|
|
566
|
+
* so the target MUST be lowered along with the cap.
|
|
567
|
+
*
|
|
568
|
+
* @param ctx - Encoder codec context
|
|
569
|
+
*
|
|
570
|
+
* @param bitrate - Maximum bitrate in bits per second, or undefined
|
|
571
|
+
*
|
|
572
|
+
* @internal
|
|
573
|
+
*/
|
|
574
|
+
applyBitrate(ctx, bitrate) {
|
|
575
|
+
if (!bitrate || bitrate <= 0) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const rate = Math.round(bitrate);
|
|
579
|
+
ctx.bitRate = BigInt(rate);
|
|
580
|
+
ctx.rcMaxRate = BigInt(rate);
|
|
581
|
+
ctx.rcBufferSize = rate;
|
|
582
|
+
}
|
|
482
583
|
/**
|
|
483
584
|
* Wire the pipeline completion to the onClose/error callbacks.
|
|
484
585
|
*
|
|
485
586
|
* On success invokes `onClose()`; on error stops the stream and invokes
|
|
486
|
-
* `onClose(error)`.
|
|
587
|
+
* `onClose(error)`. Either way `onClose` runs exactly once and the detached
|
|
588
|
+
* chain never rejects. Runs in the background (not awaited).
|
|
487
589
|
*
|
|
488
590
|
* @param completion - The pipeline completion promise
|
|
489
591
|
*
|
|
490
592
|
* @internal
|
|
491
593
|
*/
|
|
492
594
|
attachCompletion(completion) {
|
|
595
|
+
// Captured so a restart that installed a newer pipeline keeps it.
|
|
596
|
+
const control = this.pipeline;
|
|
597
|
+
// One then() with both handlers: a throwing onClose() in the success path
|
|
598
|
+
// must not fall through into the error path and fire onClose a second time.
|
|
493
599
|
completion
|
|
494
600
|
.then(() => {
|
|
495
601
|
// Pipeline completed successfully
|
|
496
|
-
this.
|
|
602
|
+
this.emitClose();
|
|
603
|
+
}, async (reason) => {
|
|
604
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
605
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
606
|
+
// the stream down - the pipeline unwinding with an AbortError (or an
|
|
607
|
+
// interrupted FFmpeg call) is the expected shutdown path, not a failure.
|
|
608
|
+
// Decided before the stop() below, which would make every failure look deliberate.
|
|
609
|
+
const deliberate = this.isDeliberateStop(reason);
|
|
610
|
+
if (!deliberate) {
|
|
611
|
+
console.error('[RTPStream] Pipeline error:', error);
|
|
612
|
+
}
|
|
613
|
+
// A teardown failure must not swallow the onClose call: without it
|
|
614
|
+
// the owner never learns the stream is gone.
|
|
615
|
+
try {
|
|
616
|
+
await this.stop();
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// doStop() already released whatever it could.
|
|
620
|
+
}
|
|
621
|
+
this.emitClose(deliberate ? undefined : error);
|
|
497
622
|
})
|
|
498
|
-
.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
this.
|
|
623
|
+
.finally(() => {
|
|
624
|
+
// Drop the finished pipeline so isStreamActive reports inactive. The
|
|
625
|
+
// demuxer path already clears it in runPipeline(); a frame source has
|
|
626
|
+
// no such step, so without this it would look active forever.
|
|
627
|
+
if (this.pipeline === control) {
|
|
628
|
+
this.pipeline = undefined;
|
|
629
|
+
}
|
|
502
630
|
});
|
|
503
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Invoke the owner's onClose callback.
|
|
634
|
+
*
|
|
635
|
+
* The completion chain runs detached, so an exception from the callback
|
|
636
|
+
* could only surface as an unhandled rejection - it is logged instead.
|
|
637
|
+
*
|
|
638
|
+
* @param error - Pipeline error, or undefined for a clean end
|
|
639
|
+
*
|
|
640
|
+
* @internal
|
|
641
|
+
*/
|
|
642
|
+
emitClose(error) {
|
|
643
|
+
try {
|
|
644
|
+
this.options.onClose(error);
|
|
645
|
+
}
|
|
646
|
+
catch (callbackError) {
|
|
647
|
+
// The stream is already closed; logged so a broken restart path in the
|
|
648
|
+
// owner does not vanish silently.
|
|
649
|
+
console.error('[RTPStream] onClose callback threw:', callbackError);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
654
|
+
*
|
|
655
|
+
* @param error - The pipeline rejection reason
|
|
656
|
+
*
|
|
657
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
658
|
+
*
|
|
659
|
+
* @internal
|
|
660
|
+
*/
|
|
661
|
+
isDeliberateStop(error) {
|
|
662
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
663
|
+
}
|
|
504
664
|
/**
|
|
505
665
|
* Set up encoders and RTP outputs for a pre-composited frame source and start
|
|
506
666
|
* encoding. No demuxer or decoder is involved - the provided frames are encoded
|
|
@@ -557,15 +717,18 @@ export class RTPStream {
|
|
|
557
717
|
// browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
|
|
558
718
|
// that join (or lose) mid-stream must get a keyframe quickly - the x264
|
|
559
719
|
// default of 250 frames leaves them waiting for many seconds.
|
|
720
|
+
const bitrate = this.options.video.bitrate;
|
|
560
721
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
561
722
|
maxBFrames: 0,
|
|
562
723
|
options: encoderOptions,
|
|
563
724
|
configure: (ctx) => {
|
|
564
725
|
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
565
726
|
ctx.gopSize = Math.max(1, Math.round(fps * 2));
|
|
727
|
+
this.applyBitrate(ctx, bitrate);
|
|
566
728
|
},
|
|
567
729
|
});
|
|
568
|
-
this.videoOutput = await this.createVideoOutput(
|
|
730
|
+
this.videoOutput = await this.createVideoOutput();
|
|
731
|
+
this.throwIfStopRequested();
|
|
569
732
|
controls.push(pipeline(this.wrapVideoSource(source.video), this.videoEncoder, this.videoOutput, opts));
|
|
570
733
|
}
|
|
571
734
|
// Audio
|
|
@@ -599,9 +762,12 @@ export class RTPStream {
|
|
|
599
762
|
this.audioEncoder = await Encoder.create(encoderCodec, {
|
|
600
763
|
filter: this.audioFilter,
|
|
601
764
|
options: encoderOptions,
|
|
765
|
+
audioFrameBuffer: { maxGapFill: 0 },
|
|
602
766
|
});
|
|
603
767
|
this.audioOutput = await this.createAudioOutput();
|
|
604
|
-
|
|
768
|
+
this.throwIfStopRequested();
|
|
769
|
+
const audio = interruptibleFrameSource(source.audio, this.stopSignal.promise);
|
|
770
|
+
controls.push(pipeline(audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
|
|
605
771
|
}
|
|
606
772
|
this.pipeline = new CombinedPipelineControl(controls);
|
|
607
773
|
this.attachCompletion(this.pipeline.completion);
|
|
@@ -609,21 +775,19 @@ export class RTPStream {
|
|
|
609
775
|
/**
|
|
610
776
|
* Create the RTP video output muxer.
|
|
611
777
|
*
|
|
612
|
-
* Rewrites RTP sequence numbers (continuous) and
|
|
613
|
-
*
|
|
614
|
-
* `onVideoPacket`. Uses `this.input` when present
|
|
615
|
-
* for the frame-source path.
|
|
616
|
-
*
|
|
617
|
-
* @param fps - Frame rate used for the RTP timestamp increment
|
|
778
|
+
* Rewrites RTP sequence numbers (continuous) and rebases the PTS-derived
|
|
779
|
+
* timestamps (90 kHz) onto a random initial value in the write callback, then
|
|
780
|
+
* forwards each packet to `onVideoPacket`. Uses `this.input` when present
|
|
781
|
+
* (demuxer path) and no input for the frame-source path.
|
|
618
782
|
*
|
|
619
783
|
* @returns The configured video muxer
|
|
620
784
|
*
|
|
621
785
|
* @internal
|
|
622
786
|
*/
|
|
623
|
-
async createVideoOutput(
|
|
787
|
+
async createVideoOutput() {
|
|
624
788
|
let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
625
|
-
|
|
626
|
-
|
|
789
|
+
const videoTimestampBase = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
|
|
790
|
+
let videoTimestampOffset;
|
|
627
791
|
const videoMuxerOptions = {};
|
|
628
792
|
if (this.options.video.ssrc !== undefined) {
|
|
629
793
|
videoMuxerOptions.ssrc = this.options.video.ssrc;
|
|
@@ -641,12 +805,14 @@ export class RTPStream {
|
|
|
641
805
|
// Fix sequence number - ensure continuous sequence
|
|
642
806
|
rtpPacket.header.sequenceNumber = videoSequenceNumber;
|
|
643
807
|
videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
808
|
+
// Rebase the timestamp onto our own random initial value. FFmpeg's RTP
|
|
809
|
+
// muxer derives it from the packet PTS in the 90 kHz clock (plus its own
|
|
810
|
+
// random base), so the deltas carry the real presentation times -
|
|
811
|
+
// including B-frame reordering and VFR cadence. Only the base is swapped;
|
|
812
|
+
// modular uint32 arithmetic keeps wrap-around intact, and packets of the
|
|
813
|
+
// same frame keep sharing a timestamp.
|
|
814
|
+
videoTimestampOffset ??= (videoTimestampBase - rtpPacket.header.timestamp) >>> 0;
|
|
815
|
+
rtpPacket.header.timestamp = (rtpPacket.header.timestamp + videoTimestampOffset) >>> 0;
|
|
650
816
|
this.options.onVideoPacket(rtpPacket);
|
|
651
817
|
return buffer.length;
|
|
652
818
|
},
|
|
@@ -713,7 +879,7 @@ export class RTPStream {
|
|
|
713
879
|
const opts = this.signal ? { signal: this.signal } : undefined;
|
|
714
880
|
if (hasAudio && hasVideo) {
|
|
715
881
|
this.pipeline = pipeline(this.input, {
|
|
716
|
-
video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
|
|
882
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
|
|
717
883
|
audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
|
|
718
884
|
}, {
|
|
719
885
|
video: this.videoOutput,
|
|
@@ -722,7 +888,7 @@ export class RTPStream {
|
|
|
722
888
|
}
|
|
723
889
|
else if (hasVideo) {
|
|
724
890
|
this.pipeline = pipeline(this.input, {
|
|
725
|
-
video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
|
|
891
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder, this.videoBitstreamFilter],
|
|
726
892
|
}, this.videoOutput, opts);
|
|
727
893
|
}
|
|
728
894
|
else if (hasAudio) {
|
|
@@ -741,7 +907,8 @@ export class RTPStream {
|
|
|
741
907
|
*
|
|
742
908
|
* Stops the pipeline, closes output, and releases all FFmpeg resources.
|
|
743
909
|
* Safe to call multiple times. After stopping, you can call start() again
|
|
744
|
-
* to restart the stream.
|
|
910
|
+
* to restart the stream. Resolves even if the pipeline had failed; that
|
|
911
|
+
* error is reported through `onClose(error)`.
|
|
745
912
|
*
|
|
746
913
|
* @example
|
|
747
914
|
* ```typescript
|
|
@@ -755,43 +922,104 @@ export class RTPStream {
|
|
|
755
922
|
* ```
|
|
756
923
|
*/
|
|
757
924
|
async stop() {
|
|
925
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
926
|
+
// double-closing native resources.
|
|
927
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
928
|
+
this.stopPromise = undefined;
|
|
929
|
+
});
|
|
930
|
+
await this.stopPromise;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Perform the actual teardown (see {@link stop}).
|
|
934
|
+
*
|
|
935
|
+
* @internal
|
|
936
|
+
*/
|
|
937
|
+
async doStop() {
|
|
938
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
939
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
940
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
941
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
942
|
+
this.stopRequested = true;
|
|
943
|
+
this.startAbort?.abort();
|
|
944
|
+
this.startAbort = undefined;
|
|
945
|
+
// Unblocks a frame source that went silent, so the completion barrier below
|
|
946
|
+
// cannot wait on a pull that will never settle.
|
|
947
|
+
this.stopSignal.resolve();
|
|
948
|
+
const inflightStart = this.startPromise;
|
|
758
949
|
// Allow a fresh start() after stopping.
|
|
759
950
|
this.startPromise = undefined;
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
951
|
+
if (inflightStart) {
|
|
952
|
+
try {
|
|
953
|
+
await inflightStart;
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
// Detach the abort listener so it cannot fire for a torn-down session and
|
|
961
|
+
// does not accumulate across start()/stop() cycles.
|
|
962
|
+
if (this.abortHandler) {
|
|
963
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
964
|
+
this.abortHandler = undefined;
|
|
965
|
+
}
|
|
966
|
+
// Stop pipeline if running and wait for completion. pipeline.stop() interrupts
|
|
967
|
+
// the source demuxer's read (frame-source pipelines unwind via iterator.return
|
|
968
|
+
// instead). Swallow a rejected completion here - the error is already surfaced
|
|
969
|
+
// to the caller via the onClose(error) path; we just need cleanup to proceed.
|
|
970
|
+
const pipeline = this.pipeline;
|
|
971
|
+
if (pipeline) {
|
|
972
|
+
if (!pipeline.isStopped()) {
|
|
973
|
+
pipeline.stop();
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
await pipeline.completion;
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
980
|
+
}
|
|
981
|
+
if (this.pipeline === pipeline) {
|
|
982
|
+
this.pipeline = undefined;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// Close all resources. close() rethrows a failed background write after it
|
|
986
|
+
// freed the muxer. That error already ended the pipeline and reaches the
|
|
987
|
+
// owner through onClose(error), so it must not abort the teardown here and
|
|
988
|
+
// leak the other output, the codecs and the input (an RTSP connection).
|
|
766
989
|
try {
|
|
767
|
-
await this.
|
|
990
|
+
await this.videoOutput?.close();
|
|
768
991
|
}
|
|
769
992
|
catch {
|
|
770
|
-
//
|
|
771
|
-
}
|
|
772
|
-
this.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
993
|
+
// Surfaced through the pipeline completion.
|
|
994
|
+
}
|
|
995
|
+
this.videoOutput = undefined;
|
|
996
|
+
try {
|
|
997
|
+
await this.audioOutput?.close();
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
// Surfaced through the pipeline completion.
|
|
1001
|
+
}
|
|
1002
|
+
this.audioOutput = undefined;
|
|
1003
|
+
this.videoEncoder?.close();
|
|
1004
|
+
this.videoEncoder = undefined;
|
|
1005
|
+
this.videoFilter?.close();
|
|
1006
|
+
this.videoFilter = undefined;
|
|
1007
|
+
this.videoDecoder?.close();
|
|
1008
|
+
this.videoDecoder = undefined;
|
|
1009
|
+
this.audioEncoder?.close();
|
|
1010
|
+
this.audioEncoder = undefined;
|
|
1011
|
+
this.audioFilter?.close();
|
|
1012
|
+
this.audioFilter = undefined;
|
|
1013
|
+
this.audioDecoder?.close();
|
|
1014
|
+
this.audioDecoder = undefined;
|
|
1015
|
+
this.hardwareContext?.dispose();
|
|
1016
|
+
this.hardwareContext = undefined;
|
|
1017
|
+
await this.input?.close();
|
|
1018
|
+
this.input = undefined;
|
|
1019
|
+
}
|
|
1020
|
+
finally {
|
|
1021
|
+
this.stopRequested = false;
|
|
1022
|
+
}
|
|
795
1023
|
}
|
|
796
1024
|
/**
|
|
797
1025
|
* Check if the given audio codec is supported.
|