node-av 6.1.1 → 6.2.0-beta.10
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 +29 -0
- package/dist/api/audio-frame-buffer.d.ts +73 -1
- package/dist/api/audio-frame-buffer.js +127 -1
- 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 +12 -5
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +10 -1
- package/dist/api/demuxer.js +114 -31
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.d.ts +43 -1
- package/dist/api/encoder-pool.js +113 -9
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +2 -0
- package/dist/api/encoder.js +105 -41
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter-complex.d.ts +16 -10
- package/dist/api/filter-complex.js +129 -81
- package/dist/api/filter-complex.js.map +1 -1
- package/dist/api/filter-presets.d.ts +15 -1
- package/dist/api/filter-presets.js +23 -5
- package/dist/api/filter-presets.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 +211 -4
- package/dist/api/fmp4-stream.js +550 -84
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.js +4 -3
- package/dist/api/hardware.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/io-stream.js +61 -5
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +13 -0
- package/dist/api/muxer.js +82 -19
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +122 -0
- package/dist/api/pipeline.js +25 -7
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +146 -1
- package/dist/api/rtp-stream.js +533 -125
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +102 -16
- package/dist/api/scaler.js +316 -106
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/types.d.ts +17 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- 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/webrtc-stream.d.ts +17 -10
- package/dist/api/webrtc-stream.js +117 -24
- 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/ffmpeg/install.js +115 -29
- package/dist/ffmpeg/install.js.map +1 -1
- package/dist/lib/binding.d.ts +1 -0
- package/dist/lib/binding.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 +45 -4
- package/dist/lib/format-context.js +50 -16
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +12 -12
- package/dist/lib/frame.js +13 -20
- 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/native-types.d.ts +2 -2
- 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 +7 -1
- package/dist/lib/packet.js +9 -2
- package/dist/lib/packet.js.map +1 -1
- package/dist/lib/stream.d.ts +3 -4
- package/dist/lib/stream.js +17 -24
- package/dist/lib/stream.js.map +1 -1
- package/dist/webrtc/index.d.ts +1 -0
- package/dist/webrtc/index.js.map +1 -1
- package/install/check.js +26 -63
- package/package.json +36 -19
- package/build_mac_local.sh +0 -69
package/dist/api/rtp-stream.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isRtcp, RtpPacket } from 'werift';
|
|
2
|
-
import { AV_HWDEVICE_TYPE_NONE } from '../constants/constants.js';
|
|
2
|
+
import { 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
|
-
import {
|
|
5
|
+
import { Rational } from '../lib/rational.js';
|
|
6
|
+
import { avChannelLayoutDefault, avGetPixFmtName } from '../lib/utilities.js';
|
|
6
7
|
import { MAX_PACKET_SIZE } from './constants.js';
|
|
7
|
-
import { pickSupportedLayout, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
|
|
8
8
|
import { Decoder } from './decoder.js';
|
|
9
9
|
import { Demuxer } from './demuxer.js';
|
|
10
10
|
import { Encoder } from './encoder.js';
|
|
@@ -13,6 +13,49 @@ import { FilterAPI } from './filter.js';
|
|
|
13
13
|
import { HardwareContext } from './hardware.js';
|
|
14
14
|
import { Muxer } from './muxer.js';
|
|
15
15
|
import { pipeline } from './pipeline.js';
|
|
16
|
+
import { pickSupportedLayout, pickSupportedPixelFormat, pickSupportedRate, pickSupportedSampleFormat } from './utilities/codec-format.js';
|
|
17
|
+
/**
|
|
18
|
+
* Check whether a streaming-class input is a frame source (not a URL or Demuxer).
|
|
19
|
+
*
|
|
20
|
+
* @param input - The input passed to `create()`
|
|
21
|
+
*
|
|
22
|
+
* @returns True if `input` is a {@link MediaFrameSource}
|
|
23
|
+
*
|
|
24
|
+
* @internal
|
|
25
|
+
*/
|
|
26
|
+
export function isMediaFrameSource(input) {
|
|
27
|
+
return typeof input !== 'string' && !(input instanceof Demuxer);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Combine one or two pipeline controls behind a single {@link PipelineControl}.
|
|
31
|
+
*
|
|
32
|
+
* The frame-source path runs separate video/audio pipelines; this presents them
|
|
33
|
+
* as one control so `stop()`/`completion` work uniformly.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
class CombinedPipelineControl {
|
|
38
|
+
controls;
|
|
39
|
+
constructor(controls) {
|
|
40
|
+
this.controls = controls;
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
for (const control of this.controls) {
|
|
44
|
+
control.stop();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
isStopped() {
|
|
48
|
+
return this.controls.every((control) => control.isStopped());
|
|
49
|
+
}
|
|
50
|
+
get completion() {
|
|
51
|
+
return Promise.all(this.controls.map((control) => control.completion)).then(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
get progress() {
|
|
54
|
+
// Delegate to the first control (video when present) - a merged view is not
|
|
55
|
+
// meaningful across independently-clocked streams.
|
|
56
|
+
return this.controls[0]?.progress ?? { frames: 0, bytes: 0, time: 0, fps: 0, bitrate: 0, speed: 0, elapsed: 0 };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
16
59
|
/**
|
|
17
60
|
* Generic RTP streaming with automatic codec detection and transcoding.
|
|
18
61
|
*
|
|
@@ -50,6 +93,7 @@ export class RTPStream {
|
|
|
50
93
|
inputUrl;
|
|
51
94
|
inputOptions;
|
|
52
95
|
input;
|
|
96
|
+
source;
|
|
53
97
|
videoOutput;
|
|
54
98
|
audioOutput;
|
|
55
99
|
hardwareContext;
|
|
@@ -60,9 +104,15 @@ export class RTPStream {
|
|
|
60
104
|
audioFilter;
|
|
61
105
|
audioEncoder;
|
|
62
106
|
pipeline;
|
|
107
|
+
startPromise;
|
|
63
108
|
signal;
|
|
64
109
|
supportedVideoCodecs;
|
|
65
110
|
supportedAudioCodecs;
|
|
111
|
+
videoKeyframeRequested = false;
|
|
112
|
+
abortHandler;
|
|
113
|
+
stopRequested = false;
|
|
114
|
+
stopPromise;
|
|
115
|
+
startAbort;
|
|
66
116
|
/**
|
|
67
117
|
* @param input - Media input URL or pre-opened Demuxer
|
|
68
118
|
*
|
|
@@ -76,10 +126,14 @@ export class RTPStream {
|
|
|
76
126
|
if (typeof input === 'string') {
|
|
77
127
|
this.inputUrl = input;
|
|
78
128
|
}
|
|
79
|
-
else {
|
|
129
|
+
else if (input instanceof Demuxer) {
|
|
80
130
|
this.inputUrl = null;
|
|
81
131
|
this.input = input;
|
|
82
132
|
}
|
|
133
|
+
else {
|
|
134
|
+
this.inputUrl = null;
|
|
135
|
+
this.source = input;
|
|
136
|
+
}
|
|
83
137
|
const inputUrl = this.inputUrl ?? '';
|
|
84
138
|
this.inputOptions = {
|
|
85
139
|
...options.inputOptions,
|
|
@@ -111,9 +165,10 @@ export class RTPStream {
|
|
|
111
165
|
ssrc: options.video?.ssrc,
|
|
112
166
|
payloadType: options.video?.payloadType,
|
|
113
167
|
mtu: options.video?.mtu ?? MAX_PACKET_SIZE,
|
|
114
|
-
fps: options.video?.fps
|
|
168
|
+
fps: options.video?.fps,
|
|
115
169
|
width: options.video?.width,
|
|
116
170
|
height: options.video?.height,
|
|
171
|
+
bitrate: options.video?.bitrate,
|
|
117
172
|
encoderOptions: options.video?.encoderOptions ?? {},
|
|
118
173
|
},
|
|
119
174
|
audio: {
|
|
@@ -199,6 +254,47 @@ export class RTPStream {
|
|
|
199
254
|
getInput() {
|
|
200
255
|
return this.input;
|
|
201
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Request that the next encoded video frame is a keyframe.
|
|
259
|
+
*
|
|
260
|
+
* Used to answer receiver feedback (e.g. a WebRTC PLI) so a receiver that
|
|
261
|
+
* missed the stream start or lost packets can resync without waiting for the
|
|
262
|
+
* next scheduled GOP keyframe. Currently effective for frame-source inputs
|
|
263
|
+
* ({@link MediaFrameSource}); for demuxer inputs the encoder's regular GOP
|
|
264
|
+
* cadence applies.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```typescript
|
|
268
|
+
* transceiver.sender.onPictureLossIndication.subscribe(() => {
|
|
269
|
+
* stream.requestVideoKeyframe();
|
|
270
|
+
* });
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
requestVideoKeyframe() {
|
|
274
|
+
this.videoKeyframeRequested = true;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Pass video frames through, forcing a keyframe when one was requested.
|
|
278
|
+
*
|
|
279
|
+
* Sets `pictType = I` on the next frame after {@link requestVideoKeyframe};
|
|
280
|
+
* combined with the encoder's `forced-idr` option this produces an IDR the
|
|
281
|
+
* receiver can resync on.
|
|
282
|
+
*
|
|
283
|
+
* @param src - The user-provided video frame source
|
|
284
|
+
*
|
|
285
|
+
* @yields {Frame | null} The unchanged frames (with pictType forced when requested)
|
|
286
|
+
*
|
|
287
|
+
* @internal
|
|
288
|
+
*/
|
|
289
|
+
async *wrapVideoSource(src) {
|
|
290
|
+
for await (const frame of src) {
|
|
291
|
+
if (frame && this.videoKeyframeRequested) {
|
|
292
|
+
this.videoKeyframeRequested = false;
|
|
293
|
+
frame.pictType = AV_PICTURE_TYPE_I;
|
|
294
|
+
}
|
|
295
|
+
yield frame;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
202
298
|
/**
|
|
203
299
|
* Start streaming media to RTP packets.
|
|
204
300
|
*
|
|
@@ -227,17 +323,55 @@ export class RTPStream {
|
|
|
227
323
|
* ```
|
|
228
324
|
*/
|
|
229
325
|
async start() {
|
|
326
|
+
// Idempotent: concurrent and repeated calls share a single startup (e.g.
|
|
327
|
+
// an explicit start() racing WebRTCStream.setOffer()'s automatic one).
|
|
328
|
+
this.startPromise ??= this.doStart().catch((error) => {
|
|
329
|
+
this.startPromise = undefined;
|
|
330
|
+
throw error;
|
|
331
|
+
});
|
|
332
|
+
await this.startPromise;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Perform the actual startup (see {@link start}).
|
|
336
|
+
*
|
|
337
|
+
* @internal
|
|
338
|
+
*/
|
|
339
|
+
async doStart() {
|
|
340
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
341
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
342
|
+
if (this.stopPromise) {
|
|
343
|
+
await this.stopPromise;
|
|
344
|
+
}
|
|
230
345
|
if (this.pipeline) {
|
|
231
346
|
return;
|
|
232
347
|
}
|
|
233
348
|
this.signal?.throwIfAborted();
|
|
234
|
-
this.signal
|
|
349
|
+
if (this.signal && !this.abortHandler) {
|
|
350
|
+
// Stop on abort. Keep a reference so stop() can detach the listener again,
|
|
351
|
+
// otherwise restarts would stack one listener per start(). Errors from the
|
|
352
|
+
// async stop() are swallowed here; they already surface via onClose(error).
|
|
353
|
+
this.abortHandler = () => {
|
|
354
|
+
this.abortHandler = undefined;
|
|
355
|
+
this.stop().catch(() => { });
|
|
356
|
+
};
|
|
357
|
+
this.signal.addEventListener('abort', this.abortHandler, { once: true });
|
|
358
|
+
}
|
|
359
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
360
|
+
// (an RTSP connect can take seconds).
|
|
361
|
+
this.startAbort = new AbortController();
|
|
362
|
+
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
363
|
+
if (this.source) {
|
|
364
|
+
await this.startFromFrames();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
235
367
|
if (!this.input) {
|
|
236
368
|
if (!this.inputUrl) {
|
|
237
369
|
throw new Error('No input URL or Demuxer provided');
|
|
238
370
|
}
|
|
239
|
-
this.
|
|
371
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
372
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
240
373
|
}
|
|
374
|
+
this.throwIfStopRequested();
|
|
241
375
|
const videoStream = this.input.video();
|
|
242
376
|
const audioStream = this.input.audio();
|
|
243
377
|
// Setup video transcoding if needed
|
|
@@ -260,6 +394,21 @@ export class RTPStream {
|
|
|
260
394
|
const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
|
|
261
395
|
(this.options.video.height !== undefined && this.options.video.height !== currentHeight);
|
|
262
396
|
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
397
|
+
// Get first supported codec
|
|
398
|
+
const targetCodecId = this.options.supportedVideoCodecs[0];
|
|
399
|
+
if (!targetCodecId) {
|
|
400
|
+
throw new Error('No supported video codec specified for transcoding');
|
|
401
|
+
}
|
|
402
|
+
let encoderCodec = null;
|
|
403
|
+
if (typeof targetCodecId === 'string') {
|
|
404
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
|
|
408
|
+
}
|
|
409
|
+
if (!encoderCodec) {
|
|
410
|
+
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
411
|
+
}
|
|
263
412
|
// Create filter chain only if needed
|
|
264
413
|
if (needsScale || needsFps) {
|
|
265
414
|
const filterChain = FilterPreset.chain(this.hardwareContext);
|
|
@@ -273,25 +422,26 @@ export class RTPStream {
|
|
|
273
422
|
if (needsFps) {
|
|
274
423
|
filterChain.filter('fps', { fps: this.options.video.fps });
|
|
275
424
|
}
|
|
425
|
+
// Software scaling leaves the graph output format unconstrained, so swscale keeps the
|
|
426
|
+
// source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
|
|
427
|
+
// in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
|
|
428
|
+
// anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
|
|
429
|
+
// one format every browser/player can decode over WebRTC (unlike e.g. the 4:2:2 High
|
|
430
|
+
// profile that a least-loss pick would choose for a yuyv422 source). Fall back to a
|
|
431
|
+
// codec-supported format only if the encoder genuinely can't take yuv420p. Hardware chains
|
|
432
|
+
// negotiate their format through the frames context instead.
|
|
433
|
+
if (needsScale && !this.hardwareContext) {
|
|
434
|
+
const formats = encoderCodec.pixelFormats;
|
|
435
|
+
const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
|
|
436
|
+
const targetFmtName = avGetPixFmtName(targetFmt);
|
|
437
|
+
if (targetFmtName) {
|
|
438
|
+
filterChain.filter('format', { pix_fmts: targetFmtName });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
276
441
|
this.videoFilter = FilterAPI.create(filterChain.build(), {
|
|
277
442
|
hardware: this.hardwareContext,
|
|
278
443
|
});
|
|
279
444
|
}
|
|
280
|
-
// Get first supported codec
|
|
281
|
-
const targetCodecId = this.options.supportedVideoCodecs[0];
|
|
282
|
-
if (!targetCodecId) {
|
|
283
|
-
throw new Error('No supported video codec specified for transcoding');
|
|
284
|
-
}
|
|
285
|
-
let encoderCodec = null;
|
|
286
|
-
if (typeof targetCodecId === 'string') {
|
|
287
|
-
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
|
|
291
|
-
}
|
|
292
|
-
if (!encoderCodec) {
|
|
293
|
-
throw new Error(`No encoder found for codec ID ${targetCodecId}`);
|
|
294
|
-
}
|
|
295
445
|
let encoderOptions = {};
|
|
296
446
|
if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
|
|
297
447
|
encoderOptions.preset = 'ultrafast';
|
|
@@ -301,60 +451,20 @@ export class RTPStream {
|
|
|
301
451
|
...encoderOptions,
|
|
302
452
|
...this.options.video.encoderOptions,
|
|
303
453
|
};
|
|
454
|
+
const bitrate = this.options.video.bitrate;
|
|
304
455
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
305
456
|
decoder: this.videoDecoder,
|
|
306
457
|
maxBFrames: 0,
|
|
307
458
|
options: encoderOptions,
|
|
459
|
+
configure: (ctx) => {
|
|
460
|
+
this.applyBitrate(ctx, bitrate);
|
|
461
|
+
},
|
|
308
462
|
});
|
|
309
|
-
|
|
310
|
-
// Initialize RTP sequence numbers and timestamps
|
|
311
|
-
let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
312
|
-
let videoTimestamp = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
|
|
313
|
-
let audioSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
314
|
-
// Calculate video timestamp increment
|
|
315
|
-
const videoStreamFps = videoStream ? videoStream.avgFrameRate.num / videoStream.avgFrameRate.den : 20;
|
|
316
|
-
let fps = this.options.video.fps ?? videoStreamFps;
|
|
317
|
-
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
318
|
-
fps = 20; // Default to 20 FPS if invalid
|
|
319
|
-
}
|
|
320
|
-
const videoTimestampIncrement = 90000 / fps;
|
|
321
|
-
// Build RTP muxer options for video
|
|
322
|
-
const videoMuxerOptions = {};
|
|
323
|
-
if (this.options.video.ssrc !== undefined) {
|
|
324
|
-
videoMuxerOptions.ssrc = this.options.video.ssrc;
|
|
325
|
-
}
|
|
326
|
-
if (this.options.video.payloadType !== undefined) {
|
|
327
|
-
videoMuxerOptions.payload_type = this.options.video.payloadType;
|
|
463
|
+
this.throwIfStopRequested();
|
|
328
464
|
}
|
|
329
465
|
// Setup video output
|
|
330
|
-
this.videoOutput = await
|
|
331
|
-
|
|
332
|
-
if (isRtcp(buffer)) {
|
|
333
|
-
// Ignore RTCP packets
|
|
334
|
-
return buffer.length;
|
|
335
|
-
}
|
|
336
|
-
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
337
|
-
// Fix sequence number - ensure continuous sequence
|
|
338
|
-
rtpPacket.header.sequenceNumber = videoSequenceNumber;
|
|
339
|
-
videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
340
|
-
// Fix timestamp - calculate based on FPS
|
|
341
|
-
// All packets in same frame have same timestamp (marker=false)
|
|
342
|
-
// Only increment timestamp when frame ends (marker=true)
|
|
343
|
-
rtpPacket.header.timestamp = videoTimestamp;
|
|
344
|
-
// Increment timestamp for next frame when current frame ends
|
|
345
|
-
if (rtpPacket.header.marker) {
|
|
346
|
-
videoTimestamp = (videoTimestamp + videoTimestampIncrement) >>> 0; // Unsigned 32-bit wrap
|
|
347
|
-
}
|
|
348
|
-
this.options.onVideoPacket(rtpPacket);
|
|
349
|
-
return buffer.length;
|
|
350
|
-
},
|
|
351
|
-
}, {
|
|
352
|
-
input: this.input,
|
|
353
|
-
copyInitialNonkeyframes: true,
|
|
354
|
-
format: 'rtp',
|
|
355
|
-
maxPacketSize: this.options.video.mtu,
|
|
356
|
-
options: videoMuxerOptions,
|
|
357
|
-
});
|
|
466
|
+
this.videoOutput = await this.createVideoOutput();
|
|
467
|
+
this.throwIfStopRequested();
|
|
358
468
|
// Setup audio if available and needs transcoding
|
|
359
469
|
if (audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId)) {
|
|
360
470
|
this.audioDecoder = await Decoder.create(audioStream, {
|
|
@@ -401,50 +511,298 @@ export class RTPStream {
|
|
|
401
511
|
filter: this.audioFilter,
|
|
402
512
|
options: encoderOptions,
|
|
403
513
|
});
|
|
514
|
+
this.throwIfStopRequested();
|
|
404
515
|
}
|
|
405
516
|
// Setup audio output if available
|
|
406
517
|
if (audioStream) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (this.options.audio.ssrc !== undefined) {
|
|
410
|
-
audioMuxerOptions.ssrc = this.options.audio.ssrc;
|
|
411
|
-
}
|
|
412
|
-
if (this.options.audio.payloadType !== undefined) {
|
|
413
|
-
audioMuxerOptions.payload_type = this.options.audio.payloadType;
|
|
414
|
-
}
|
|
415
|
-
this.audioOutput = await Muxer.open({
|
|
416
|
-
write: (buffer) => {
|
|
417
|
-
if (isRtcp(buffer)) {
|
|
418
|
-
// Ignore RTCP packets
|
|
419
|
-
return buffer.length;
|
|
420
|
-
}
|
|
421
|
-
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
422
|
-
// Fix sequence number - ensure continuous sequence
|
|
423
|
-
rtpPacket.header.sequenceNumber = audioSequenceNumber;
|
|
424
|
-
audioSequenceNumber = (audioSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
425
|
-
this.options.onAudioPacket(rtpPacket);
|
|
426
|
-
return buffer.length;
|
|
427
|
-
},
|
|
428
|
-
}, {
|
|
429
|
-
input: this.input,
|
|
430
|
-
copyInitialNonkeyframes: true,
|
|
431
|
-
format: 'rtp',
|
|
432
|
-
maxPacketSize: this.options.audio.mtu,
|
|
433
|
-
options: audioMuxerOptions,
|
|
434
|
-
});
|
|
518
|
+
this.audioOutput = await this.createAudioOutput();
|
|
519
|
+
this.throwIfStopRequested();
|
|
435
520
|
}
|
|
436
521
|
// Start pipeline in background (don't await)
|
|
437
|
-
this.runPipeline()
|
|
522
|
+
this.attachCompletion(this.runPipeline());
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Abort an in-flight startup once stop() was requested.
|
|
526
|
+
*
|
|
527
|
+
* Checked after every await in the startup path: the resources created so
|
|
528
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
529
|
+
* this startup tears them down the moment it unwinds.
|
|
530
|
+
*
|
|
531
|
+
* @throws {Error} If stop() was called while starting
|
|
532
|
+
*
|
|
533
|
+
* @internal
|
|
534
|
+
*/
|
|
535
|
+
throwIfStopRequested() {
|
|
536
|
+
if (this.stopRequested) {
|
|
537
|
+
throw new Error('RTPStream stopped during start');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Cap the video bitrate on the encoder context.
|
|
542
|
+
*
|
|
543
|
+
* Sets the target bitrate together with a VBV cap (maxrate + a 1s buffer).
|
|
544
|
+
* Encoder.create() always seeds a default target bitrate (1 Mbps for video),
|
|
545
|
+
* so the target MUST be lowered along with the cap.
|
|
546
|
+
*
|
|
547
|
+
* @param ctx - Encoder codec context
|
|
548
|
+
*
|
|
549
|
+
* @param bitrate - Maximum bitrate in bits per second, or undefined
|
|
550
|
+
*
|
|
551
|
+
* @internal
|
|
552
|
+
*/
|
|
553
|
+
applyBitrate(ctx, bitrate) {
|
|
554
|
+
if (!bitrate || bitrate <= 0) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const rate = Math.round(bitrate);
|
|
558
|
+
ctx.bitRate = BigInt(rate);
|
|
559
|
+
ctx.rcMaxRate = BigInt(rate);
|
|
560
|
+
ctx.rcBufferSize = rate;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Wire the pipeline completion to the onClose/error callbacks.
|
|
564
|
+
*
|
|
565
|
+
* On success invokes `onClose()`; on error stops the stream and invokes
|
|
566
|
+
* `onClose(error)`. Runs in the background (not awaited).
|
|
567
|
+
*
|
|
568
|
+
* @param completion - The pipeline completion promise
|
|
569
|
+
*
|
|
570
|
+
* @internal
|
|
571
|
+
*/
|
|
572
|
+
attachCompletion(completion) {
|
|
573
|
+
completion
|
|
438
574
|
.then(() => {
|
|
439
575
|
// Pipeline completed successfully
|
|
440
576
|
this.options.onClose?.();
|
|
441
577
|
})
|
|
442
578
|
.catch(async (error) => {
|
|
579
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
580
|
+
// the stream down - the pipeline unwinding with an AbortError (or an
|
|
581
|
+
// interrupted FFmpeg call) is the expected shutdown path, not a failure.
|
|
582
|
+
if (this.isDeliberateStop(error)) {
|
|
583
|
+
await this.stop().catch(() => { });
|
|
584
|
+
this.options.onClose?.();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
443
587
|
console.error('[RTPStream] Pipeline error:', error);
|
|
444
588
|
await this.stop();
|
|
445
589
|
this.options.onClose?.(error);
|
|
446
590
|
});
|
|
447
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
594
|
+
*
|
|
595
|
+
* @param error - The pipeline rejection reason
|
|
596
|
+
*
|
|
597
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
598
|
+
*
|
|
599
|
+
* @internal
|
|
600
|
+
*/
|
|
601
|
+
isDeliberateStop(error) {
|
|
602
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Set up encoders and RTP outputs for a pre-composited frame source and start
|
|
606
|
+
* encoding. No demuxer or decoder is involved - the provided frames are encoded
|
|
607
|
+
* straight to RTP. Video and audio (whichever are present) run as separate
|
|
608
|
+
* pipelines combined behind a single control.
|
|
609
|
+
*
|
|
610
|
+
* @throws {Error} If the source has neither video nor audio frames
|
|
611
|
+
*
|
|
612
|
+
* @internal
|
|
613
|
+
*/
|
|
614
|
+
async startFromFrames() {
|
|
615
|
+
const source = this.source;
|
|
616
|
+
if (!source.video && !source.audio) {
|
|
617
|
+
throw new Error('Frame source must provide video and/or audio frames');
|
|
618
|
+
}
|
|
619
|
+
const controls = [];
|
|
620
|
+
const opts = this.signal ? { signal: this.signal } : undefined;
|
|
621
|
+
// Video
|
|
622
|
+
if (source.video) {
|
|
623
|
+
if (this.options.hardware === 'auto') {
|
|
624
|
+
this.hardwareContext = HardwareContext.auto();
|
|
625
|
+
}
|
|
626
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
627
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
628
|
+
}
|
|
629
|
+
const targetCodecId = this.options.supportedVideoCodecs[0] ?? FF_ENCODER_LIBX264;
|
|
630
|
+
let encoderCodec = null;
|
|
631
|
+
if (typeof targetCodecId === 'string') {
|
|
632
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
encoderCodec = this.hardwareContext?.getEncoderCodec(targetCodecId) ?? Codec.findEncoder(targetCodecId);
|
|
636
|
+
}
|
|
637
|
+
if (!encoderCodec) {
|
|
638
|
+
throw new Error(`No encoder found for video codec ${String(targetCodecId)}`);
|
|
639
|
+
}
|
|
640
|
+
let encoderOptions = {};
|
|
641
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264 || encoderCodec.name === FF_ENCODER_LIBX265) {
|
|
642
|
+
encoderOptions.preset = 'ultrafast';
|
|
643
|
+
encoderOptions.tune = 'zerolatency';
|
|
644
|
+
// Make a requested keyframe (frame.pictType = I) an actual IDR, so a
|
|
645
|
+
// receiver that missed the stream start can resync (PLI handling).
|
|
646
|
+
encoderOptions['forced-idr'] = true;
|
|
647
|
+
}
|
|
648
|
+
encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
|
|
649
|
+
let fps = this.options.video.fps ?? 20;
|
|
650
|
+
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
651
|
+
fps = 20;
|
|
652
|
+
}
|
|
653
|
+
// No decoder/filter: the encoder adopts width/height/pixelFormat/timeBase
|
|
654
|
+
// from the first composited frame. Set the framerate explicitly, otherwise
|
|
655
|
+
// the encoder infers it from the frame timebase (often a fine-grained tick
|
|
656
|
+
// like 1/12800), which makes x264 pick an absurd level (huge MB rate) that
|
|
657
|
+
// browsers can't decode over WebRTC. Bound the GOP to ~2s: live receivers
|
|
658
|
+
// that join (or lose) mid-stream must get a keyframe quickly - the x264
|
|
659
|
+
// default of 250 frames leaves them waiting for many seconds.
|
|
660
|
+
const bitrate = this.options.video.bitrate;
|
|
661
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
662
|
+
maxBFrames: 0,
|
|
663
|
+
options: encoderOptions,
|
|
664
|
+
configure: (ctx) => {
|
|
665
|
+
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
666
|
+
ctx.gopSize = Math.max(1, Math.round(fps * 2));
|
|
667
|
+
this.applyBitrate(ctx, bitrate);
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
this.videoOutput = await this.createVideoOutput();
|
|
671
|
+
this.throwIfStopRequested();
|
|
672
|
+
controls.push(pipeline(this.wrapVideoSource(source.video), this.videoEncoder, this.videoOutput, opts));
|
|
673
|
+
}
|
|
674
|
+
// Audio
|
|
675
|
+
if (source.audio) {
|
|
676
|
+
const targetCodecId = this.options.supportedAudioCodecs[0] ?? FF_ENCODER_LIBOPUS;
|
|
677
|
+
let encoderCodec = null;
|
|
678
|
+
if (typeof targetCodecId === 'string') {
|
|
679
|
+
encoderCodec = Codec.findEncoderByName(targetCodecId);
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
encoderCodec = Codec.findEncoder(targetCodecId);
|
|
683
|
+
}
|
|
684
|
+
if (!encoderCodec) {
|
|
685
|
+
throw new Error(`No encoder found for audio codec ${String(targetCodecId)}`);
|
|
686
|
+
}
|
|
687
|
+
// There is no input stream to derive formats from - resample to the desired
|
|
688
|
+
// (or codec-preferred) sample format/rate/layout via an aformat filter.
|
|
689
|
+
const desiredSampleFormat = this.options.audio?.sampleFormat ?? AV_SAMPLE_FMT_FLTP;
|
|
690
|
+
const desiredSampleRate = this.options.audio?.sampleRate ?? 48000;
|
|
691
|
+
const desiredChannels = this.options.audio?.channels ?? 2;
|
|
692
|
+
const targetSampleFormat = this.selectSampleFormat(encoderCodec, desiredSampleFormat);
|
|
693
|
+
const targetSampleRate = this.selectSampleRate(encoderCodec, desiredSampleRate);
|
|
694
|
+
const channelLayoutStr = this.selectChannelLayout(encoderCodec, desiredChannels);
|
|
695
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(targetSampleFormat, targetSampleRate, channelLayoutStr).build());
|
|
696
|
+
let encoderOptions = {};
|
|
697
|
+
if (encoderCodec.name === FF_ENCODER_LIBOPUS) {
|
|
698
|
+
encoderOptions.application = 'lowdelay';
|
|
699
|
+
encoderOptions.frame_duration = 20;
|
|
700
|
+
}
|
|
701
|
+
encoderOptions = { ...encoderOptions, ...this.options.audio.encoderOptions };
|
|
702
|
+
this.audioEncoder = await Encoder.create(encoderCodec, {
|
|
703
|
+
filter: this.audioFilter,
|
|
704
|
+
options: encoderOptions,
|
|
705
|
+
});
|
|
706
|
+
this.audioOutput = await this.createAudioOutput();
|
|
707
|
+
this.throwIfStopRequested();
|
|
708
|
+
controls.push(pipeline(source.audio, this.audioFilter, this.audioEncoder, this.audioOutput, opts));
|
|
709
|
+
}
|
|
710
|
+
this.pipeline = new CombinedPipelineControl(controls);
|
|
711
|
+
this.attachCompletion(this.pipeline.completion);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Create the RTP video output muxer.
|
|
715
|
+
*
|
|
716
|
+
* Rewrites RTP sequence numbers (continuous) and rebases the PTS-derived
|
|
717
|
+
* timestamps (90 kHz) onto a random initial value in the write callback, then
|
|
718
|
+
* forwards each packet to `onVideoPacket`. Uses `this.input` when present
|
|
719
|
+
* (demuxer path) and no input for the frame-source path.
|
|
720
|
+
*
|
|
721
|
+
* @returns The configured video muxer
|
|
722
|
+
*
|
|
723
|
+
* @internal
|
|
724
|
+
*/
|
|
725
|
+
async createVideoOutput() {
|
|
726
|
+
let videoSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
727
|
+
const videoTimestampBase = Math.floor(Math.random() * 0xffffffff) >>> 0; // unsigned 32-bit
|
|
728
|
+
let videoTimestampOffset;
|
|
729
|
+
const videoMuxerOptions = {};
|
|
730
|
+
if (this.options.video.ssrc !== undefined) {
|
|
731
|
+
videoMuxerOptions.ssrc = this.options.video.ssrc;
|
|
732
|
+
}
|
|
733
|
+
if (this.options.video.payloadType !== undefined) {
|
|
734
|
+
videoMuxerOptions.payload_type = this.options.video.payloadType;
|
|
735
|
+
}
|
|
736
|
+
return await Muxer.open({
|
|
737
|
+
write: (buffer) => {
|
|
738
|
+
if (isRtcp(buffer)) {
|
|
739
|
+
// Ignore RTCP packets
|
|
740
|
+
return buffer.length;
|
|
741
|
+
}
|
|
742
|
+
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
743
|
+
// Fix sequence number - ensure continuous sequence
|
|
744
|
+
rtpPacket.header.sequenceNumber = videoSequenceNumber;
|
|
745
|
+
videoSequenceNumber = (videoSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
746
|
+
// Rebase the timestamp onto our own random initial value. FFmpeg's RTP
|
|
747
|
+
// muxer derives it from the packet PTS in the 90 kHz clock (plus its own
|
|
748
|
+
// random base), so the deltas carry the real presentation times -
|
|
749
|
+
// including B-frame reordering and VFR cadence. Only the base is swapped;
|
|
750
|
+
// modular uint32 arithmetic keeps wrap-around intact, and packets of the
|
|
751
|
+
// same frame keep sharing a timestamp.
|
|
752
|
+
videoTimestampOffset ??= (videoTimestampBase - rtpPacket.header.timestamp) >>> 0;
|
|
753
|
+
rtpPacket.header.timestamp = (rtpPacket.header.timestamp + videoTimestampOffset) >>> 0;
|
|
754
|
+
this.options.onVideoPacket(rtpPacket);
|
|
755
|
+
return buffer.length;
|
|
756
|
+
},
|
|
757
|
+
}, {
|
|
758
|
+
input: this.input,
|
|
759
|
+
copyInitialNonkeyframes: true,
|
|
760
|
+
format: 'rtp',
|
|
761
|
+
maxPacketSize: this.options.video.mtu,
|
|
762
|
+
options: videoMuxerOptions,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Create the RTP audio output muxer.
|
|
767
|
+
*
|
|
768
|
+
* Rewrites RTP sequence numbers (continuous) in the write callback and forwards
|
|
769
|
+
* each packet to `onAudioPacket`. Uses `this.input` when present (demuxer path)
|
|
770
|
+
* and no input for the frame-source path.
|
|
771
|
+
*
|
|
772
|
+
* @returns The configured audio muxer
|
|
773
|
+
*
|
|
774
|
+
* @internal
|
|
775
|
+
*/
|
|
776
|
+
async createAudioOutput() {
|
|
777
|
+
let audioSequenceNumber = Math.floor(Math.random() * 0xffff);
|
|
778
|
+
const audioMuxerOptions = {};
|
|
779
|
+
if (this.options.audio.ssrc !== undefined) {
|
|
780
|
+
audioMuxerOptions.ssrc = this.options.audio.ssrc;
|
|
781
|
+
}
|
|
782
|
+
if (this.options.audio.payloadType !== undefined) {
|
|
783
|
+
audioMuxerOptions.payload_type = this.options.audio.payloadType;
|
|
784
|
+
}
|
|
785
|
+
return await Muxer.open({
|
|
786
|
+
write: (buffer) => {
|
|
787
|
+
if (isRtcp(buffer)) {
|
|
788
|
+
// Ignore RTCP packets
|
|
789
|
+
return buffer.length;
|
|
790
|
+
}
|
|
791
|
+
const rtpPacket = RtpPacket.deSerialize(buffer);
|
|
792
|
+
// Fix sequence number - ensure continuous sequence
|
|
793
|
+
rtpPacket.header.sequenceNumber = audioSequenceNumber;
|
|
794
|
+
audioSequenceNumber = (audioSequenceNumber + 1) & 0xffff; // Wrap at 16-bit
|
|
795
|
+
this.options.onAudioPacket(rtpPacket);
|
|
796
|
+
return buffer.length;
|
|
797
|
+
},
|
|
798
|
+
}, {
|
|
799
|
+
input: this.input,
|
|
800
|
+
copyInitialNonkeyframes: true,
|
|
801
|
+
format: 'rtp',
|
|
802
|
+
maxPacketSize: this.options.audio.mtu,
|
|
803
|
+
options: audioMuxerOptions,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
448
806
|
/**
|
|
449
807
|
* Run the streaming pipeline until completion or stopped.
|
|
450
808
|
*
|
|
@@ -501,33 +859,83 @@ export class RTPStream {
|
|
|
501
859
|
* ```
|
|
502
860
|
*/
|
|
503
861
|
async stop() {
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
this.
|
|
522
|
-
this.
|
|
523
|
-
this.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
this.
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
862
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
863
|
+
// double-closing native resources.
|
|
864
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
865
|
+
this.stopPromise = undefined;
|
|
866
|
+
});
|
|
867
|
+
await this.stopPromise;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Perform the actual teardown (see {@link stop}).
|
|
871
|
+
*
|
|
872
|
+
* @internal
|
|
873
|
+
*/
|
|
874
|
+
async doStop() {
|
|
875
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
876
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
877
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
878
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
879
|
+
this.stopRequested = true;
|
|
880
|
+
this.startAbort?.abort();
|
|
881
|
+
this.startAbort = undefined;
|
|
882
|
+
const inflightStart = this.startPromise;
|
|
883
|
+
// Allow a fresh start() after stopping.
|
|
884
|
+
this.startPromise = undefined;
|
|
885
|
+
if (inflightStart) {
|
|
886
|
+
try {
|
|
887
|
+
await inflightStart;
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
try {
|
|
894
|
+
// Detach the abort listener so it cannot fire for a torn-down session and
|
|
895
|
+
// does not accumulate across start()/stop() cycles.
|
|
896
|
+
if (this.abortHandler) {
|
|
897
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
898
|
+
this.abortHandler = undefined;
|
|
899
|
+
}
|
|
900
|
+
// Stop pipeline if running and wait for completion. pipeline.stop() interrupts
|
|
901
|
+
// the source demuxer's read (frame-source pipelines unwind via iterator.return
|
|
902
|
+
// instead). Swallow a rejected completion here - the error is already surfaced
|
|
903
|
+
// to the caller via the onClose(error) path; we just need cleanup to proceed.
|
|
904
|
+
if (this.pipeline && !this.pipeline.isStopped()) {
|
|
905
|
+
this.pipeline.stop();
|
|
906
|
+
try {
|
|
907
|
+
await this.pipeline.completion;
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
911
|
+
}
|
|
912
|
+
this.pipeline = undefined;
|
|
913
|
+
}
|
|
914
|
+
// Close all resources
|
|
915
|
+
await this.videoOutput?.close();
|
|
916
|
+
this.videoOutput = undefined;
|
|
917
|
+
await this.audioOutput?.close();
|
|
918
|
+
this.audioOutput = undefined;
|
|
919
|
+
this.videoEncoder?.close();
|
|
920
|
+
this.videoEncoder = undefined;
|
|
921
|
+
this.videoFilter?.close();
|
|
922
|
+
this.videoFilter = undefined;
|
|
923
|
+
this.videoDecoder?.close();
|
|
924
|
+
this.videoDecoder = undefined;
|
|
925
|
+
this.audioEncoder?.close();
|
|
926
|
+
this.audioEncoder = undefined;
|
|
927
|
+
this.audioFilter?.close();
|
|
928
|
+
this.audioFilter = undefined;
|
|
929
|
+
this.audioDecoder?.close();
|
|
930
|
+
this.audioDecoder = undefined;
|
|
931
|
+
this.hardwareContext?.dispose();
|
|
932
|
+
this.hardwareContext = undefined;
|
|
933
|
+
await this.input?.close();
|
|
934
|
+
this.input = undefined;
|
|
935
|
+
}
|
|
936
|
+
finally {
|
|
937
|
+
this.stopRequested = false;
|
|
938
|
+
}
|
|
531
939
|
}
|
|
532
940
|
/**
|
|
533
941
|
* Check if the given audio codec is supported.
|