node-av 6.2.0-beta.9 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -19
- package/dist/api/audio-frame-buffer.d.ts +378 -19
- package/dist/api/audio-frame-buffer.js +715 -101
- package/dist/api/audio-frame-buffer.js.map +1 -1
- package/dist/api/data/test_av1.ivf +0 -0
- package/dist/api/data/test_hevc.h265 +0 -0
- package/dist/api/data/test_vp9.ivf +0 -0
- package/dist/api/decoder.d.ts +117 -2
- package/dist/api/decoder.js +290 -23
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +19 -3
- package/dist/api/demuxer.js +95 -29
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.js +9 -1
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +100 -6
- package/dist/api/encoder.js +249 -108
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter-presets.d.ts +25 -1
- package/dist/api/filter-presets.js +63 -3
- package/dist/api/filter-presets.js.map +1 -1
- package/dist/api/filter.d.ts +62 -3
- package/dist/api/filter.js +118 -36
- package/dist/api/filter.js.map +1 -1
- package/dist/api/fmp4-stream.d.ts +179 -15
- package/dist/api/fmp4-stream.js +341 -120
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.d.ts +2 -0
- package/dist/api/hardware.js +33 -10
- package/dist/api/hardware.js.map +1 -1
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js.map +1 -1
- package/dist/api/io-stream.d.ts +3 -2
- package/dist/api/io-stream.js +23 -7
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +276 -7
- package/dist/api/muxer.js +649 -116
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +22 -0
- package/dist/api/pipeline.js +53 -0
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +54 -33
- package/dist/api/rtp-stream.js +232 -153
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +39 -6
- package/dist/api/scaler.js +72 -18
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/utilities/live-decoding.d.ts +15 -0
- package/dist/api/utilities/live-decoding.js +32 -0
- package/dist/api/utilities/live-decoding.js.map +1 -0
- package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
- package/dist/api/utilities/packet-age-watchdog.js +594 -0
- package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
- package/dist/api/utilities/scale-size.d.ts +51 -0
- package/dist/api/utilities/scale-size.js +55 -0
- package/dist/api/utilities/scale-size.js.map +1 -0
- package/dist/api/utilities/video-transcode.d.ts +218 -0
- package/dist/api/utilities/video-transcode.js +624 -0
- package/dist/api/utilities/video-transcode.js.map +1 -0
- package/dist/constants/constants.d.ts +1 -0
- package/dist/constants/constants.js +1 -0
- package/dist/constants/constants.js.map +1 -1
- package/dist/constants/filter-options.d.ts +2 -0
- package/dist/ffmpeg/utils.js +7 -4
- package/dist/ffmpeg/utils.js.map +1 -1
- package/dist/lib/binding.d.ts +10 -1
- package/dist/lib/binding.js +25 -16
- package/dist/lib/binding.js.map +1 -1
- package/dist/lib/bitstream-filter-context.d.ts +2 -0
- package/dist/lib/bitstream-filter-context.js +2 -0
- package/dist/lib/bitstream-filter-context.js.map +1 -1
- package/dist/lib/filter-context.d.ts +3 -0
- package/dist/lib/filter-context.js +3 -0
- package/dist/lib/filter-context.js.map +1 -1
- package/dist/lib/filter-graph-segment.d.ts +3 -2
- package/dist/lib/filter-graph-segment.js +3 -2
- package/dist/lib/filter-graph-segment.js.map +1 -1
- package/dist/lib/filter-graph.d.ts +4 -4
- package/dist/lib/filter-graph.js +4 -4
- package/dist/lib/filter-inout.d.ts +4 -0
- package/dist/lib/filter-inout.js +4 -0
- package/dist/lib/filter-inout.js.map +1 -1
- package/dist/lib/format-context.d.ts +27 -3
- package/dist/lib/format-context.js +30 -6
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +6 -2
- package/dist/lib/frame.js +11 -3
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/io-context.d.ts +4 -2
- package/dist/lib/io-context.js +4 -2
- package/dist/lib/io-context.js.map +1 -1
- package/dist/lib/native-types.d.ts +7 -3
- package/dist/lib/output-format.d.ts +42 -0
- package/dist/lib/output-format.js +46 -0
- package/dist/lib/output-format.js.map +1 -1
- package/dist/lib/stream.d.ts +5 -0
- package/dist/lib/stream.js +5 -0
- package/dist/lib/stream.js.map +1 -1
- package/dist/lib/utilities.d.ts +26 -0
- package/dist/lib/utilities.js +28 -0
- package/dist/lib/utilities.js.map +1 -1
- package/install/check.js +14 -16
- package/package.json +27 -44
package/dist/api/fmp4-stream.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AV_CODEC_ID_AAC, AV_CODEC_ID_AV1, AV_CODEC_ID_FLAC, AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_OPUS, AV_HWDEVICE_TYPE_NONE,
|
|
2
|
-
import { FF_ENCODER_AAC, FF_ENCODER_LIBX264 } from '../constants/encoders.js';
|
|
1
|
+
import { AV_CODEC_ID_AAC, AV_CODEC_ID_AV1, AV_CODEC_ID_FLAC, AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_OPUS, AV_HWDEVICE_TYPE_NONE, AV_SAMPLE_FMT_FLT, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
|
|
2
|
+
import { FF_ENCODER_AAC, FF_ENCODER_LIBOPUS, FF_ENCODER_LIBX264 } from '../constants/encoders.js';
|
|
3
3
|
import { Codec } from '../lib/codec.js';
|
|
4
4
|
import { Rational } from '../lib/rational.js';
|
|
5
|
-
import { avGetCodecString,
|
|
5
|
+
import { avGetCodecString, avGetMediaTypeString } from '../lib/utilities.js';
|
|
6
6
|
import { Decoder } from './decoder.js';
|
|
7
7
|
import { Demuxer } from './demuxer.js';
|
|
8
8
|
import { Encoder } from './encoder.js';
|
|
@@ -10,8 +10,11 @@ import { FilterPreset } from './filter-presets.js';
|
|
|
10
10
|
import { FilterAPI } from './filter.js';
|
|
11
11
|
import { HardwareContext } from './hardware.js';
|
|
12
12
|
import { Muxer } from './muxer.js';
|
|
13
|
-
import { consumeStreamInParallel, pipeline, PipelineControlImpl } from './pipeline.js';
|
|
14
|
-
import {
|
|
13
|
+
import { consumeStreamInParallel, interruptibleFrameSource, pipeline, PipelineControlImpl } from './pipeline.js';
|
|
14
|
+
import { configureLiveDecoderThreading } from './utilities/live-decoding.js';
|
|
15
|
+
import { PacketAgeWatchdog } from './utilities/packet-age-watchdog.js';
|
|
16
|
+
import { transcodeScale } from './utilities/scale-size.js';
|
|
17
|
+
import { applyVideoBitrate, createVideoTranscodeFilter, findUsableHardwareEncoder, openTranscodeDecoder } from './utilities/video-transcode.js';
|
|
15
18
|
/**
|
|
16
19
|
* Target codec strings for fMP4 streaming.
|
|
17
20
|
*/
|
|
@@ -23,11 +26,18 @@ export const FMP4_CODECS = {
|
|
|
23
26
|
FLAC: 'flac',
|
|
24
27
|
OPUS: 'opus',
|
|
25
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Shortest fragDuration in seconds the transcoded video GOP follows.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const MIN_FRAGMENT_GOP = 0.5;
|
|
26
35
|
/**
|
|
27
36
|
* High-level fMP4 streaming with automatic codec detection and transcoding.
|
|
28
37
|
*
|
|
29
38
|
* Provides fragmented MP4 streaming for clients.
|
|
30
|
-
* Automatically transcodes video to H.264 and audio to AAC
|
|
39
|
+
* Automatically transcodes video to H.264 and audio to AAC (Opus when the client
|
|
40
|
+
* lists Opus but not AAC) if not supported by client.
|
|
31
41
|
* Client sends supported codecs, server transcodes accordingly.
|
|
32
42
|
* Essential component for building adaptive streaming servers.
|
|
33
43
|
*
|
|
@@ -69,6 +79,7 @@ export class FMP4Stream {
|
|
|
69
79
|
options;
|
|
70
80
|
inputUrl;
|
|
71
81
|
inputOptions;
|
|
82
|
+
liveInput;
|
|
72
83
|
input;
|
|
73
84
|
source;
|
|
74
85
|
output;
|
|
@@ -87,9 +98,12 @@ export class FMP4Stream {
|
|
|
87
98
|
pendingFragment = [];
|
|
88
99
|
abortHandler;
|
|
89
100
|
stopRequested = false;
|
|
101
|
+
discardOutput = false;
|
|
90
102
|
stopPromise;
|
|
91
103
|
startAbort;
|
|
104
|
+
stopSignal = Promise.withResolvers();
|
|
92
105
|
fragmentQueue = null;
|
|
106
|
+
packetAgeWatchdog;
|
|
93
107
|
_droppedFragments = 0;
|
|
94
108
|
_initSegment = null;
|
|
95
109
|
_initSegmentResolve = null;
|
|
@@ -104,9 +118,26 @@ export class FMP4Stream {
|
|
|
104
118
|
*
|
|
105
119
|
* Use {@link create} factory method
|
|
106
120
|
*
|
|
121
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
122
|
+
*
|
|
107
123
|
* @internal
|
|
108
124
|
*/
|
|
109
125
|
constructor(input, options) {
|
|
126
|
+
const isLiveInput = typeof input === 'string' && /^(rtsps?|rtmp(?:e|s|t|te|ts)?|s?rtp|udp|srt|tcp|tls|sctp):/i.test(input);
|
|
127
|
+
this.liveInput = isLiveInput;
|
|
128
|
+
const dtsBackwardThreshold = options.dtsBackwardThreshold ?? 10;
|
|
129
|
+
// Only a source read in real time pairs a gap with the matching wait.
|
|
130
|
+
const dtsForwardThreshold = options.dtsForwardThreshold ?? (isLiveInput ? 10 : 0);
|
|
131
|
+
const maxPacketAge = options.maxPacketAge ?? 60;
|
|
132
|
+
for (const [name, value] of [
|
|
133
|
+
['dtsBackwardThreshold', dtsBackwardThreshold],
|
|
134
|
+
['dtsForwardThreshold', dtsForwardThreshold],
|
|
135
|
+
['maxPacketAge', maxPacketAge],
|
|
136
|
+
]) {
|
|
137
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
138
|
+
throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${value}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
110
141
|
if (typeof input === 'string') {
|
|
111
142
|
this.inputUrl = input;
|
|
112
143
|
}
|
|
@@ -119,7 +150,6 @@ export class FMP4Stream {
|
|
|
119
150
|
this.source = input;
|
|
120
151
|
}
|
|
121
152
|
const inputUrl = this.inputUrl ?? '';
|
|
122
|
-
const isLiveInput = /^(rtsp|rtmp|rtp|udp|srt|tcp|sctp):/i.test(inputUrl);
|
|
123
153
|
this.inputOptions = {
|
|
124
154
|
...options.inputOptions,
|
|
125
155
|
options: {
|
|
@@ -141,6 +171,8 @@ export class FMP4Stream {
|
|
|
141
171
|
width: options.video?.width,
|
|
142
172
|
height: options.video?.height,
|
|
143
173
|
bitrate: options.video?.bitrate,
|
|
174
|
+
transcode: options.video?.transcode,
|
|
175
|
+
tag: options.video?.tag,
|
|
144
176
|
encoderOptions: options.video?.encoderOptions ?? {},
|
|
145
177
|
},
|
|
146
178
|
audio: {
|
|
@@ -150,6 +182,9 @@ export class FMP4Stream {
|
|
|
150
182
|
boxMode: options.boxMode ?? false,
|
|
151
183
|
maxQueuedFragments: options.maxQueuedFragments ?? 16,
|
|
152
184
|
movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
|
|
185
|
+
dtsBackwardThreshold,
|
|
186
|
+
dtsForwardThreshold,
|
|
187
|
+
maxPacketAge,
|
|
153
188
|
};
|
|
154
189
|
this.signal = options.signal;
|
|
155
190
|
// Parse supported codecs
|
|
@@ -170,6 +205,8 @@ export class FMP4Stream {
|
|
|
170
205
|
*
|
|
171
206
|
* @returns Configured fMP4 stream instance
|
|
172
207
|
*
|
|
208
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
209
|
+
*
|
|
173
210
|
* @example
|
|
174
211
|
* ```typescript
|
|
175
212
|
* // Stream from file with codec negotiation
|
|
@@ -288,7 +325,7 @@ export class FMP4Stream {
|
|
|
288
325
|
parts.push(FMP4_CODECS.H264);
|
|
289
326
|
}
|
|
290
327
|
if (this.source.audio) {
|
|
291
|
-
parts.push(
|
|
328
|
+
parts.push(this.audioTranscodeTarget());
|
|
292
329
|
}
|
|
293
330
|
return parts.join(',');
|
|
294
331
|
}
|
|
@@ -302,7 +339,7 @@ export class FMP4Stream {
|
|
|
302
339
|
// Determine video codec string
|
|
303
340
|
let videoCodec = null;
|
|
304
341
|
if (videoCodecId) {
|
|
305
|
-
const needsVideoTranscode =
|
|
342
|
+
const needsVideoTranscode = this.transcodesVideo(videoCodecId);
|
|
306
343
|
if (needsVideoTranscode) {
|
|
307
344
|
// Transcoding to H.264
|
|
308
345
|
videoCodec = FMP4_CODECS.H264;
|
|
@@ -332,8 +369,7 @@ export class FMP4Stream {
|
|
|
332
369
|
if (audioCodecId) {
|
|
333
370
|
const needsAudioTranscode = !this.isAudioCodecSupported(audioCodecId);
|
|
334
371
|
if (needsAudioTranscode) {
|
|
335
|
-
|
|
336
|
-
audioCodec = FMP4_CODECS.AAC;
|
|
372
|
+
audioCodec = this.audioTranscodeTarget();
|
|
337
373
|
}
|
|
338
374
|
else if (audioCodecId === AV_CODEC_ID_AAC) {
|
|
339
375
|
// AAC - use fixed codec string
|
|
@@ -355,13 +391,60 @@ export class FMP4Stream {
|
|
|
355
391
|
// Combine video and audio codec strings
|
|
356
392
|
return [videoCodec, audioCodec].filter(Boolean).join(',');
|
|
357
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Video description of the muxed fMP4 output (output codec, RFC 6381 codec string, resolution, fps).
|
|
396
|
+
*
|
|
397
|
+
* Available after {@link start} has opened the input.
|
|
398
|
+
*
|
|
399
|
+
* @returns The output video info, or `undefined` when there is no video track.
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* ```typescript
|
|
403
|
+
* const videoInfo = stream.getVideoInfo();
|
|
404
|
+
* if (videoInfo) {
|
|
405
|
+
* console.log(`Codec: ${videoInfo.codec}, Resolution: ${videoInfo.width}x${videoInfo.height}, FPS: ${videoInfo.fps}`);
|
|
406
|
+
* }
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
getVideoInfo() {
|
|
410
|
+
if (this.source) {
|
|
411
|
+
if (!this.source.video) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
codec: 'h264',
|
|
416
|
+
codecString: FMP4_CODECS.H264,
|
|
417
|
+
width: this.options.video?.width ?? 0,
|
|
418
|
+
height: this.options.video?.height ?? 0,
|
|
419
|
+
fps: this.options.video?.fps ?? 0,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const videoStream = this.input?.video();
|
|
423
|
+
if (!videoStream) {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
const { codecpar } = videoStream;
|
|
427
|
+
const codecId = codecpar.codecId;
|
|
428
|
+
const transcode = this.transcodesVideo(codecId);
|
|
429
|
+
const codecString = transcode ? FMP4_CODECS.H264 : (avGetCodecString(codecpar) ?? FMP4_CODECS.H264);
|
|
430
|
+
const codec = transcode || codecId === AV_CODEC_ID_H264 ? 'h264' : codecId === AV_CODEC_ID_HEVC ? 'hevc' : codecId === AV_CODEC_ID_AV1 ? 'av1' : 'unknown';
|
|
431
|
+
const size = transcode ? transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height).output : undefined;
|
|
432
|
+
const rate = videoStream.avgFrameRate;
|
|
433
|
+
return {
|
|
434
|
+
codec,
|
|
435
|
+
codecString,
|
|
436
|
+
width: size?.width ?? codecpar.width,
|
|
437
|
+
height: size?.height ?? codecpar.height,
|
|
438
|
+
fps: transcode && this.options.video?.fps ? this.options.video.fps : rate.den ? rate.num / rate.den : 0,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
358
441
|
/**
|
|
359
442
|
* Start streaming media to fMP4 chunks.
|
|
360
443
|
*
|
|
361
444
|
* Begins the media processing pipeline, reading packets from input,
|
|
362
445
|
* transcoding based on supported codecs, and generating fMP4 chunks.
|
|
363
446
|
* Video transcodes to H.264 if H.264/H.265 not supported.
|
|
364
|
-
* Audio transcodes to AAC
|
|
447
|
+
* Audio transcodes to AAC (Opus when only Opus is listed) if the input codec is not supported.
|
|
365
448
|
* This method returns immediately after starting the pipeline.
|
|
366
449
|
*
|
|
367
450
|
* @returns Promise that resolves when pipeline is started
|
|
@@ -414,6 +497,9 @@ export class FMP4Stream {
|
|
|
414
497
|
};
|
|
415
498
|
this.signal?.addEventListener('abort', this.abortHandler, { once: true });
|
|
416
499
|
this._droppedFragments = 0;
|
|
500
|
+
this.discardOutput = false;
|
|
501
|
+
// Re-armed per start so a restart is not torn down by the previous run.
|
|
502
|
+
this.stopSignal = Promise.withResolvers();
|
|
417
503
|
// Lets stop() interrupt a startup that is blocked in the input open
|
|
418
504
|
// (an RTSP connect can take seconds).
|
|
419
505
|
this.startAbort = new AbortController();
|
|
@@ -434,7 +520,7 @@ export class FMP4Stream {
|
|
|
434
520
|
const videoStream = this.input.video();
|
|
435
521
|
const audioStream = this.input.audio();
|
|
436
522
|
// Check if video needs transcoding
|
|
437
|
-
const needsVideoTranscode = videoStream &&
|
|
523
|
+
const needsVideoTranscode = videoStream && this.transcodesVideo(videoStream.codecpar.codecId);
|
|
438
524
|
if (needsVideoTranscode) {
|
|
439
525
|
// Check if we need hardware acceleration
|
|
440
526
|
if (this.options.hardware === 'auto') {
|
|
@@ -444,52 +530,28 @@ export class FMP4Stream {
|
|
|
444
530
|
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
445
531
|
}
|
|
446
532
|
// Transcode to H.264
|
|
447
|
-
|
|
448
|
-
|
|
533
|
+
const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
|
|
534
|
+
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
535
|
+
const { codecpar } = videoStream;
|
|
536
|
+
const scale = transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height);
|
|
537
|
+
// a GPU encodes up to a size of its own, test it with the size it will get
|
|
538
|
+
const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions, scale.output)) ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
539
|
+
this.throwIfStopRequested();
|
|
540
|
+
this.videoDecoder = await openTranscodeDecoder(videoStream, this.hardwareContext, {
|
|
449
541
|
exitOnError: false,
|
|
542
|
+
// Frame threading holds back one frame per thread before the first output;
|
|
543
|
+
// files keep the full thread count for throughput
|
|
544
|
+
...(this.liveInput ? { configure: configureLiveDecoderThreading } : {}),
|
|
450
545
|
});
|
|
451
546
|
this.throwIfStopRequested();
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
// Create filter chain only if needed
|
|
461
|
-
if (needsScale || needsFps) {
|
|
462
|
-
const filterChain = FilterPreset.chain(this.hardwareContext);
|
|
463
|
-
// Add scale filter if dimensions differ
|
|
464
|
-
if (needsScale) {
|
|
465
|
-
const targetWidth = this.options.video.width ?? -1;
|
|
466
|
-
const targetHeight = this.options.video.height ?? -1;
|
|
467
|
-
filterChain.scale(targetWidth, targetHeight);
|
|
468
|
-
}
|
|
469
|
-
// Add fps filter if fps differs
|
|
470
|
-
if (needsFps) {
|
|
471
|
-
filterChain.filter('fps', { fps: this.options.video.fps });
|
|
472
|
-
}
|
|
473
|
-
// Software scaling leaves the graph output format unconstrained, so swscale keeps the
|
|
474
|
-
// source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
|
|
475
|
-
// in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
|
|
476
|
-
// anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
|
|
477
|
-
// one format every browser/player can decode via MSE (unlike e.g. the 4:2:2 High profile
|
|
478
|
-
// that a least-loss pick would choose for a yuyv422 source). Fall back to a codec-supported
|
|
479
|
-
// format only if the encoder genuinely can't take yuv420p. Hardware chains negotiate their
|
|
480
|
-
// format through the frames context instead.
|
|
481
|
-
if (needsScale && !this.hardwareContext) {
|
|
482
|
-
const formats = encoderCodec.pixelFormats;
|
|
483
|
-
const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
|
|
484
|
-
const targetFmtName = avGetPixFmtName(targetFmt);
|
|
485
|
-
if (targetFmtName) {
|
|
486
|
-
filterChain.filter('format', { pix_fmts: targetFmtName });
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
this.videoFilter = FilterAPI.create(filterChain.build(), {
|
|
490
|
-
hardware: this.hardwareContext,
|
|
491
|
-
});
|
|
492
|
-
}
|
|
547
|
+
// The graph follows the frames: whether they come from the device and in
|
|
548
|
+
// which format shows only once they are decoded
|
|
549
|
+
this.videoFilter = createVideoTranscodeFilter({
|
|
550
|
+
...scale.target,
|
|
551
|
+
fps: needsFps ? this.options.video.fps : undefined,
|
|
552
|
+
encoder: encoderCodec,
|
|
553
|
+
hardware: this.hardwareContext,
|
|
554
|
+
});
|
|
493
555
|
let encoderOptions = {};
|
|
494
556
|
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
495
557
|
encoderOptions.preset = 'ultrafast';
|
|
@@ -501,14 +563,17 @@ export class FMP4Stream {
|
|
|
501
563
|
};
|
|
502
564
|
const effectiveFps = this.options.video.fps ?? currentFps;
|
|
503
565
|
const fpsForGop = isFinite(effectiveFps) && effectiveFps > 0 ? effectiveFps : 30;
|
|
504
|
-
const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
|
|
505
566
|
const bitrate = this.options.video.bitrate;
|
|
506
567
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
507
568
|
decoder: this.videoDecoder,
|
|
569
|
+
filter: this.videoFilter,
|
|
570
|
+
// Hardware encoders default to B-frames, which delay every fragment and
|
|
571
|
+
// fail on some D3D12 drivers that report support for them
|
|
572
|
+
context: { maxBFrames: 0 },
|
|
508
573
|
options: encoderOptions,
|
|
509
574
|
configure: (ctx) => {
|
|
510
|
-
ctx.gopSize =
|
|
511
|
-
|
|
575
|
+
ctx.gopSize = this.gopSize(fpsForGop);
|
|
576
|
+
applyVideoBitrate(ctx, bitrate);
|
|
512
577
|
},
|
|
513
578
|
});
|
|
514
579
|
this.throwIfStopRequested();
|
|
@@ -516,18 +581,10 @@ export class FMP4Stream {
|
|
|
516
581
|
// Check if audio needs transcoding
|
|
517
582
|
const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
|
|
518
583
|
if (needsAudioTranscode) {
|
|
519
|
-
// Transcode to AAC
|
|
520
584
|
this.audioDecoder = await Decoder.create(audioStream, {
|
|
521
585
|
exitOnError: false,
|
|
522
586
|
});
|
|
523
|
-
|
|
524
|
-
const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, targetSampleRate, 'stereo').build();
|
|
525
|
-
this.audioFilter = FilterAPI.create(filterChain);
|
|
526
|
-
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
527
|
-
decoder: this.audioDecoder,
|
|
528
|
-
filter: this.audioFilter,
|
|
529
|
-
options: this.options.audio.encoderOptions,
|
|
530
|
-
});
|
|
587
|
+
await this.createAudioEncoder(this.audioDecoder, audioStream.codecpar.channels);
|
|
531
588
|
this.throwIfStopRequested();
|
|
532
589
|
}
|
|
533
590
|
// Setup output with callback
|
|
@@ -552,35 +609,31 @@ export class FMP4Stream {
|
|
|
552
609
|
}
|
|
553
610
|
}
|
|
554
611
|
/**
|
|
555
|
-
*
|
|
612
|
+
* GOP size for the video encoder that starts every fragment with a keyframe.
|
|
556
613
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
* No-op when no bitrate was requested.
|
|
614
|
+
* frag_duration flushes a fragment once it is that long, mid-GOP on a
|
|
615
|
+
* P-frame unless the GOP has the same length, and strict consumers reject
|
|
616
|
+
* such fragments. Shorter fragments (the 1 µs default flushes after every
|
|
617
|
+
* frame) are not matched: a GOP of a few frames multiplies the bitrate, so
|
|
618
|
+
* below MIN_FRAGMENT_GOP the GOP is 2 s, as with a fragDuration of 0.
|
|
563
619
|
*
|
|
564
|
-
* @param
|
|
620
|
+
* @param fps - Output frame rate
|
|
565
621
|
*
|
|
566
|
-
* @
|
|
622
|
+
* @returns GOP size in frames
|
|
567
623
|
*
|
|
568
624
|
* @internal
|
|
569
625
|
*/
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
ctx.rcMaxRate = BigInt(Math.round(bitrate));
|
|
575
|
-
// 1s buffer keeps per-fragment size close to the cap instead of allowing
|
|
576
|
-
// multi-second bursts over it.
|
|
577
|
-
ctx.rcBufferSize = Math.round(bitrate);
|
|
626
|
+
gopSize(fps) {
|
|
627
|
+
const fragSeconds = this.options.fragDuration / 1_000_000;
|
|
628
|
+
const seconds = fragSeconds >= MIN_FRAGMENT_GOP ? fragSeconds : 2;
|
|
629
|
+
return Math.max(1, Math.round(fps * seconds));
|
|
578
630
|
}
|
|
579
631
|
/**
|
|
580
632
|
* Create the fMP4 output muxer with the fragment-emitting write callback.
|
|
581
633
|
*
|
|
582
634
|
* Uses `this.input` when present (demuxer path) and no input for the
|
|
583
|
-
* frame-source path.
|
|
635
|
+
* frame-source path. Attaches a fresh packet age watchdog unless
|
|
636
|
+
* `maxPacketAge` is 0.
|
|
584
637
|
*
|
|
585
638
|
* @returns The configured muxer
|
|
586
639
|
*
|
|
@@ -589,12 +642,17 @@ export class FMP4Stream {
|
|
|
589
642
|
async createOutput() {
|
|
590
643
|
const cb = {
|
|
591
644
|
write: (buffer) => {
|
|
645
|
+
// Swallows what the teardown of a failed session flushes (see attachCompletion).
|
|
646
|
+
if (this.discardOutput) {
|
|
647
|
+
return buffer.length;
|
|
648
|
+
}
|
|
592
649
|
if (this.options.boxMode) {
|
|
593
650
|
// Box mode: buffer until we have complete boxes
|
|
594
651
|
this.processBoxMode(buffer);
|
|
595
652
|
}
|
|
596
653
|
else {
|
|
597
654
|
// Chunk mode: send raw data immediately
|
|
655
|
+
this.packetAgeWatchdog?.consumeChunk(buffer);
|
|
598
656
|
const info = { isComplete: false, boxes: [] };
|
|
599
657
|
this.options.onData(buffer, info);
|
|
600
658
|
this.pushFragment(buffer, info);
|
|
@@ -602,45 +660,126 @@ export class FMP4Stream {
|
|
|
602
660
|
return buffer.length;
|
|
603
661
|
},
|
|
604
662
|
};
|
|
605
|
-
|
|
663
|
+
const output = await Muxer.open(cb, {
|
|
606
664
|
input: this.input,
|
|
607
665
|
format: 'mp4',
|
|
608
666
|
bufferSize: this.options.bufferSize,
|
|
609
667
|
exitOnError: false,
|
|
668
|
+
dtsBackwardThreshold: this.options.dtsBackwardThreshold,
|
|
669
|
+
dtsForwardThreshold: this.options.dtsForwardThreshold,
|
|
670
|
+
configure: (fmt) => {
|
|
671
|
+
const tag = this.options.video?.tag;
|
|
672
|
+
if (!tag) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
for (const stream of fmt.streams) {
|
|
676
|
+
if (stream.codecpar.codecId === AV_CODEC_ID_HEVC) {
|
|
677
|
+
stream.codecpar.codecTag = tag;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
},
|
|
610
681
|
options: {
|
|
611
682
|
movflags: this.options.movFlags,
|
|
612
683
|
frag_duration: this.options.fragDuration,
|
|
613
684
|
},
|
|
614
685
|
});
|
|
686
|
+
// Per session: a restart must not inherit the previous session's backlog.
|
|
687
|
+
if (this.options.maxPacketAge > 0) {
|
|
688
|
+
this.packetAgeWatchdog = new PacketAgeWatchdog(this.options.maxPacketAge, (index) => {
|
|
689
|
+
const codecType = output.getStream(index)?.codecpar.codecType;
|
|
690
|
+
return codecType === undefined ? '' : (avGetMediaTypeString(codecType) ?? '');
|
|
691
|
+
});
|
|
692
|
+
output.setPacketObserver(this.packetAgeWatchdog);
|
|
693
|
+
}
|
|
694
|
+
return output;
|
|
615
695
|
}
|
|
616
696
|
/**
|
|
617
697
|
* Wire the pipeline completion to the fragment/close callbacks.
|
|
618
698
|
*
|
|
619
699
|
* On success ends the fragment iterator and invokes `onClose()`; on error
|
|
620
|
-
* stops the stream
|
|
700
|
+
* stops the stream, discarding the output the muxer still holds, and invokes
|
|
701
|
+
* `onClose(error)`. Either way `onClose` runs exactly once and the detached
|
|
702
|
+
* chain never rejects. Runs in the background.
|
|
621
703
|
*
|
|
622
704
|
* @param completion - The pipeline completion promise
|
|
623
705
|
*
|
|
624
706
|
* @internal
|
|
625
707
|
*/
|
|
626
708
|
attachCompletion(completion) {
|
|
627
|
-
|
|
628
|
-
|
|
709
|
+
// One then() with both handlers: a throwing onClose() in the success path
|
|
710
|
+
// must not fall through into the error path and fire onClose a second time.
|
|
711
|
+
completion.then(() => {
|
|
629
712
|
this.endFragments();
|
|
630
713
|
// No-op in the normal case (init segment resolved long ago) - only
|
|
631
714
|
// fires if the stream ended without ever emitting ftyp+moov.
|
|
632
715
|
this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
|
|
633
|
-
this.
|
|
634
|
-
})
|
|
635
|
-
|
|
716
|
+
this.emitClose();
|
|
717
|
+
}, async (reason) => {
|
|
718
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
636
719
|
this.endFragments();
|
|
637
720
|
// Reject with the pipeline error so an initSegment consumer sees the
|
|
638
721
|
// actual failure instead of the generic "stopped" error from stop().
|
|
639
|
-
this.rejectInitSegment(error
|
|
640
|
-
|
|
641
|
-
|
|
722
|
+
this.rejectInitSegment(error);
|
|
723
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
724
|
+
// the stream down - the pipeline unwinding with an AbortError is the
|
|
725
|
+
// expected shutdown path, not a failure. Decided before the stop()
|
|
726
|
+
// below, which would make every failure look deliberate.
|
|
727
|
+
const deliberate = this.isDeliberateStop(reason);
|
|
728
|
+
// Closing the output flushes the muxer's backlog and the trailer. After
|
|
729
|
+
// a failure that backlog is suspect: when a backward jump ends the
|
|
730
|
+
// session, the packets the timeline had jumped ahead to are still
|
|
731
|
+
// queued and would leave as a fragment placed that far ahead; after a
|
|
732
|
+
// stall it holds up to maxPacketAge of retained samples. Nothing is
|
|
733
|
+
// emitted past the failure, through onData or fragments(). A
|
|
734
|
+
// deliberate stop keeps its tail.
|
|
735
|
+
if (!deliberate) {
|
|
736
|
+
this.discardOutput = true;
|
|
737
|
+
}
|
|
738
|
+
// Release the input (e.g. the RTSP connection) before reporting, so an
|
|
739
|
+
// owner that restarts from onClose never overlaps a live session. A
|
|
740
|
+
// teardown failure must not swallow the onClose call: without it the
|
|
741
|
+
// owner never learns the stream is gone.
|
|
742
|
+
try {
|
|
743
|
+
await this.stop();
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// doStop() already released whatever it could.
|
|
747
|
+
}
|
|
748
|
+
this.emitClose(deliberate ? undefined : error);
|
|
642
749
|
});
|
|
643
750
|
}
|
|
751
|
+
/**
|
|
752
|
+
* Invoke the owner's onClose callback.
|
|
753
|
+
*
|
|
754
|
+
* The completion chain runs detached, so an exception from the callback
|
|
755
|
+
* could only surface as an unhandled rejection - it is logged instead.
|
|
756
|
+
*
|
|
757
|
+
* @param error - Pipeline error, or undefined for a clean end
|
|
758
|
+
*
|
|
759
|
+
* @internal
|
|
760
|
+
*/
|
|
761
|
+
emitClose(error) {
|
|
762
|
+
try {
|
|
763
|
+
this.options.onClose(error);
|
|
764
|
+
}
|
|
765
|
+
catch (callbackError) {
|
|
766
|
+
// The stream is already closed; logged so a broken restart path in the
|
|
767
|
+
// owner does not vanish silently.
|
|
768
|
+
console.error('[FMP4Stream] onClose callback threw:', callbackError);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
773
|
+
*
|
|
774
|
+
* @param error - The pipeline rejection reason
|
|
775
|
+
*
|
|
776
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
777
|
+
*
|
|
778
|
+
* @internal
|
|
779
|
+
*/
|
|
780
|
+
isDeliberateStop(error) {
|
|
781
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
782
|
+
}
|
|
644
783
|
/**
|
|
645
784
|
* Set up encoders and the fMP4 muxer for a pre-composited frame source and
|
|
646
785
|
* start encoding. No demuxer or decoder is involved - the provided frames are
|
|
@@ -667,7 +806,8 @@ export class FMP4Stream {
|
|
|
667
806
|
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
668
807
|
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
669
808
|
}
|
|
670
|
-
const encoderCodec = this.hardwareContext
|
|
809
|
+
const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions)) ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
810
|
+
this.throwIfStopRequested();
|
|
671
811
|
let encoderOptions = {};
|
|
672
812
|
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
673
813
|
encoderOptions.preset = 'ultrafast';
|
|
@@ -679,29 +819,22 @@ export class FMP4Stream {
|
|
|
679
819
|
fps = 30;
|
|
680
820
|
}
|
|
681
821
|
// Set the framerate explicitly - without a decoder the encoder would infer
|
|
682
|
-
// it from the frame timebase and pick an absurd level.
|
|
683
|
-
// fragment duration (2s default) so every fMP4 fragment starts with a
|
|
684
|
-
// keyframe; otherwise frag_duration flushes fragments mid-GOP on a P-frame
|
|
685
|
-
// and strict consumers reject them.
|
|
686
|
-
const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
|
|
822
|
+
// it from the frame timebase and pick an absurd level.
|
|
687
823
|
const bitrate = this.options.video.bitrate;
|
|
688
824
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
825
|
+
context: { maxBFrames: 0 },
|
|
689
826
|
options: encoderOptions,
|
|
690
827
|
configure: (ctx) => {
|
|
691
828
|
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
692
|
-
ctx.gopSize =
|
|
693
|
-
|
|
829
|
+
ctx.gopSize = this.gopSize(fps);
|
|
830
|
+
applyVideoBitrate(ctx, bitrate);
|
|
694
831
|
},
|
|
695
832
|
});
|
|
833
|
+
// Source frames may sit on another device or in a format the encoder rejects
|
|
834
|
+
this.videoFilter = createVideoTranscodeFilter({ width: -1, height: -1, encoder: encoderCodec, hardware: this.hardwareContext });
|
|
696
835
|
}
|
|
697
|
-
// Audio encoder (AAC), resampling via aformat like the demuxer path.
|
|
698
836
|
if (source.audio) {
|
|
699
|
-
|
|
700
|
-
this.audioFilter = FilterAPI.create(filterChain);
|
|
701
|
-
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
702
|
-
filter: this.audioFilter,
|
|
703
|
-
options: this.options.audio.encoderOptions,
|
|
704
|
-
});
|
|
837
|
+
await this.createAudioEncoder();
|
|
705
838
|
}
|
|
706
839
|
this.throwIfStopRequested();
|
|
707
840
|
this.output = await this.createOutput();
|
|
@@ -710,13 +843,18 @@ export class FMP4Stream {
|
|
|
710
843
|
let control;
|
|
711
844
|
const shouldStop = () => control?.isStopped() ?? false;
|
|
712
845
|
const tasks = [];
|
|
713
|
-
|
|
846
|
+
// A frame source has no demuxer for stop() to interrupt, so wrap it to make
|
|
847
|
+
// a silent source releasable instead of blocking teardown forever.
|
|
848
|
+
const stopped = this.stopSignal.promise;
|
|
849
|
+
if (source.video && this.videoFilter && this.videoEncoder) {
|
|
714
850
|
const streamIndex = this.output.addStream(this.videoEncoder);
|
|
715
|
-
|
|
851
|
+
const video = interruptibleFrameSource(source.video, stopped);
|
|
852
|
+
tasks.push(consumeStreamInParallel(this.videoEncoder.packets(this.videoFilter.frames(video)), this.output, streamIndex, shouldStop));
|
|
716
853
|
}
|
|
717
854
|
if (source.audio && this.audioFilter && this.audioEncoder) {
|
|
718
855
|
const streamIndex = this.output.addStream(this.audioEncoder);
|
|
719
|
-
|
|
856
|
+
const audio = interruptibleFrameSource(source.audio, stopped);
|
|
857
|
+
tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
|
|
720
858
|
}
|
|
721
859
|
// Close the shared muxer only after ALL streams are done (flushes the trailer).
|
|
722
860
|
const run = Promise.all(tasks).then(async () => {
|
|
@@ -731,7 +869,8 @@ export class FMP4Stream {
|
|
|
731
869
|
*
|
|
732
870
|
* Stops the pipeline, closes output, and releases all FFmpeg resources.
|
|
733
871
|
* Safe to call multiple times. After stopping, you can call start() again
|
|
734
|
-
* to restart the stream.
|
|
872
|
+
* to restart the stream. Resolves even if the pipeline had failed; that
|
|
873
|
+
* error is reported through `onClose(error)`.
|
|
735
874
|
*
|
|
736
875
|
* @example
|
|
737
876
|
* ```typescript
|
|
@@ -765,6 +904,9 @@ export class FMP4Stream {
|
|
|
765
904
|
this.stopRequested = true;
|
|
766
905
|
this.startAbort?.abort();
|
|
767
906
|
this.startAbort = undefined;
|
|
907
|
+
// Unblocks a frame source that went silent, so the completion barrier below
|
|
908
|
+
// cannot wait on a pull that will never settle.
|
|
909
|
+
this.stopSignal.resolve();
|
|
768
910
|
const inflightStart = this.startPromise;
|
|
769
911
|
// Allow a fresh start() after stopping.
|
|
770
912
|
this.startPromise = undefined;
|
|
@@ -788,22 +930,42 @@ export class FMP4Stream {
|
|
|
788
930
|
// Stop pipeline if running and wait for completion. Swallow a rejected
|
|
789
931
|
// completion - the error is already surfaced via the onClose(error) path;
|
|
790
932
|
// we just need cleanup to proceed.
|
|
791
|
-
|
|
792
|
-
|
|
933
|
+
const pipeline = this.pipeline;
|
|
934
|
+
if (pipeline) {
|
|
935
|
+
if (!pipeline.isStopped()) {
|
|
936
|
+
pipeline.stop();
|
|
937
|
+
}
|
|
793
938
|
try {
|
|
794
|
-
await
|
|
939
|
+
await pipeline.completion;
|
|
795
940
|
}
|
|
796
941
|
catch {
|
|
797
942
|
// Pipeline errored - proceed with teardown regardless.
|
|
798
943
|
}
|
|
799
|
-
this.pipeline
|
|
944
|
+
if (this.pipeline === pipeline) {
|
|
945
|
+
this.pipeline = undefined;
|
|
946
|
+
}
|
|
800
947
|
}
|
|
801
948
|
// Close all resources. The output goes FIRST: its async write worker may
|
|
802
949
|
// still be draining (header init reads the input's stream parameters),
|
|
803
950
|
// so freeing the input before the muxer has fully settled is a
|
|
804
951
|
// use-after-free on the worker thread.
|
|
805
|
-
|
|
952
|
+
// After a failure nothing the output emits is used (see attachCompletion),
|
|
953
|
+
// so the trailer is skipped instead of flushed into the void.
|
|
954
|
+
if (this.discardOutput) {
|
|
955
|
+
this.output?.discardOnClose();
|
|
956
|
+
}
|
|
957
|
+
// close() rethrows a failed background write after it freed the muxer.
|
|
958
|
+
// That error already ended the pipeline and reaches the owner through
|
|
959
|
+
// onClose(error), so it must not abort the teardown here and leak the
|
|
960
|
+
// codecs and the input (an RTSP connection) below.
|
|
961
|
+
try {
|
|
962
|
+
await this.output?.close();
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
// Surfaced through the pipeline completion.
|
|
966
|
+
}
|
|
806
967
|
this.output = undefined;
|
|
968
|
+
this.packetAgeWatchdog = undefined;
|
|
807
969
|
this.videoDecoder?.close();
|
|
808
970
|
this.videoDecoder = undefined;
|
|
809
971
|
this.videoFilter?.close();
|
|
@@ -931,6 +1093,18 @@ export class FMP4Stream {
|
|
|
931
1093
|
await this.pipeline.completion;
|
|
932
1094
|
this.pipeline = undefined;
|
|
933
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Check if the video is encoded again instead of passed through.
|
|
1098
|
+
*
|
|
1099
|
+
* @param codecId - Codec ID of the source video
|
|
1100
|
+
*
|
|
1101
|
+
* @returns True if `video.transcode` is set or the codec is not in supported codecs
|
|
1102
|
+
*
|
|
1103
|
+
* @internal
|
|
1104
|
+
*/
|
|
1105
|
+
transcodesVideo(codecId) {
|
|
1106
|
+
return this.options.video.transcode === true || !this.isVideoCodecSupported(codecId);
|
|
1107
|
+
}
|
|
934
1108
|
/**
|
|
935
1109
|
* Check if video codec is supported.
|
|
936
1110
|
*
|
|
@@ -952,6 +1126,47 @@ export class FMP4Stream {
|
|
|
952
1126
|
}
|
|
953
1127
|
return false;
|
|
954
1128
|
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Codec that unsupported audio is transcoded to.
|
|
1131
|
+
*
|
|
1132
|
+
* @returns Opus when the client lists Opus but not AAC, otherwise AAC
|
|
1133
|
+
*
|
|
1134
|
+
* @internal
|
|
1135
|
+
*/
|
|
1136
|
+
audioTranscodeTarget() {
|
|
1137
|
+
return this.supportedCodecs.has(FMP4_CODECS.OPUS) && !this.supportedCodecs.has(FMP4_CODECS.AAC) ? FMP4_CODECS.OPUS : FMP4_CODECS.AAC;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Create the audio filter and encoder for the transcode target.
|
|
1141
|
+
*
|
|
1142
|
+
* AAC is resampled to 44.1 kHz stereo. Opus is resampled to 48 kHz, the only
|
|
1143
|
+
* rate it transmits, keeps mono input mono and uses 20 ms frames.
|
|
1144
|
+
*
|
|
1145
|
+
* @param decoder - Input audio decoder, absent for a frame source
|
|
1146
|
+
*
|
|
1147
|
+
* @param channels - Input channel count, 0 when unknown
|
|
1148
|
+
*
|
|
1149
|
+
* @internal
|
|
1150
|
+
*/
|
|
1151
|
+
async createAudioEncoder(decoder, channels = 0) {
|
|
1152
|
+
if (this.audioTranscodeTarget() === FMP4_CODECS.OPUS) {
|
|
1153
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain()
|
|
1154
|
+
.aformat(AV_SAMPLE_FMT_FLT, 48000, channels === 1 ? 'mono' : 'stereo')
|
|
1155
|
+
.build());
|
|
1156
|
+
this.audioEncoder = await Encoder.create(FF_ENCODER_LIBOPUS, {
|
|
1157
|
+
decoder,
|
|
1158
|
+
filter: this.audioFilter,
|
|
1159
|
+
options: { frame_duration: 20, ...this.options.audio.encoderOptions },
|
|
1160
|
+
});
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build());
|
|
1164
|
+
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
1165
|
+
decoder,
|
|
1166
|
+
filter: this.audioFilter,
|
|
1167
|
+
options: this.options.audio.encoderOptions,
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
955
1170
|
/**
|
|
956
1171
|
* Check if audio codec is supported.
|
|
957
1172
|
*
|
|
@@ -1033,6 +1248,9 @@ export class FMP4Stream {
|
|
|
1033
1248
|
// is corrupt - recovery is impossible, so drop the rest of this chunk
|
|
1034
1249
|
// rather than emit garbage framing downstream.
|
|
1035
1250
|
if (boxSize < headerSize) {
|
|
1251
|
+
// Emitted samples can no longer be counted - stop judging packet ages
|
|
1252
|
+
// instead of mistaking the lost framing for a stalled muxer.
|
|
1253
|
+
this.packetAgeWatchdog?.onFramingLost();
|
|
1036
1254
|
break;
|
|
1037
1255
|
}
|
|
1038
1256
|
// Check if we have the complete box
|
|
@@ -1047,6 +1265,9 @@ export class FMP4Stream {
|
|
|
1047
1265
|
headerSize,
|
|
1048
1266
|
raw: chunk.subarray(offset, offset + boxSize),
|
|
1049
1267
|
};
|
|
1268
|
+
if (parsed.type === 'moof' || parsed.type === 'moov') {
|
|
1269
|
+
this.packetAgeWatchdog?.consumeBox(parsed.raw);
|
|
1270
|
+
}
|
|
1050
1271
|
if (parsed.type === 'moof') {
|
|
1051
1272
|
flushImmediate();
|
|
1052
1273
|
if (this.pendingFragment.length > 0) {
|