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/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, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
|
|
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_PIX_FMT_YUV420P, 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 } from '../lib/utilities.js';
|
|
5
|
+
import { avGetCodecString, avGetMediaTypeString, avGetPixFmtName } 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,7 +10,9 @@ 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';
|
|
13
|
+
import { consumeStreamInParallel, interruptibleFrameSource, pipeline, PipelineControlImpl } from './pipeline.js';
|
|
14
|
+
import { pickSupportedPixelFormat } from './utilities/codec-format.js';
|
|
15
|
+
import { PacketAgeWatchdog } from './utilities/packet-age-watchdog.js';
|
|
14
16
|
/**
|
|
15
17
|
* Target codec strings for fMP4 streaming.
|
|
16
18
|
*/
|
|
@@ -22,11 +24,18 @@ export const FMP4_CODECS = {
|
|
|
22
24
|
FLAC: 'flac',
|
|
23
25
|
OPUS: 'opus',
|
|
24
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Shortest fragDuration in seconds the transcoded video GOP follows.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
const MIN_FRAGMENT_GOP = 0.5;
|
|
25
33
|
/**
|
|
26
34
|
* High-level fMP4 streaming with automatic codec detection and transcoding.
|
|
27
35
|
*
|
|
28
36
|
* Provides fragmented MP4 streaming for clients.
|
|
29
|
-
* Automatically transcodes video to H.264 and audio to AAC
|
|
37
|
+
* Automatically transcodes video to H.264 and audio to AAC (Opus when the client
|
|
38
|
+
* lists Opus but not AAC) if not supported by client.
|
|
30
39
|
* Client sends supported codecs, server transcodes accordingly.
|
|
31
40
|
* Essential component for building adaptive streaming servers.
|
|
32
41
|
*
|
|
@@ -83,9 +92,19 @@ export class FMP4Stream {
|
|
|
83
92
|
signal;
|
|
84
93
|
supportedCodecs;
|
|
85
94
|
incompleteBoxBuffer = null;
|
|
95
|
+
pendingFragment = [];
|
|
96
|
+
abortHandler;
|
|
97
|
+
stopRequested = false;
|
|
98
|
+
discardOutput = false;
|
|
99
|
+
stopPromise;
|
|
100
|
+
startAbort;
|
|
101
|
+
stopSignal = Promise.withResolvers();
|
|
86
102
|
fragmentQueue = null;
|
|
103
|
+
packetAgeWatchdog;
|
|
104
|
+
_droppedFragments = 0;
|
|
87
105
|
_initSegment = null;
|
|
88
106
|
_initSegmentResolve = null;
|
|
107
|
+
_initSegmentReject = null;
|
|
89
108
|
_initSegmentPromise = null;
|
|
90
109
|
_ftypData = null;
|
|
91
110
|
_moovData = null;
|
|
@@ -96,9 +115,25 @@ export class FMP4Stream {
|
|
|
96
115
|
*
|
|
97
116
|
* Use {@link create} factory method
|
|
98
117
|
*
|
|
118
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
119
|
+
*
|
|
99
120
|
* @internal
|
|
100
121
|
*/
|
|
101
122
|
constructor(input, options) {
|
|
123
|
+
const isLiveInput = typeof input === 'string' && /^(rtsps?|rtmp(?:e|s|t|te|ts)?|s?rtp|udp|srt|tcp|tls|sctp):/i.test(input);
|
|
124
|
+
const dtsBackwardThreshold = options.dtsBackwardThreshold ?? 10;
|
|
125
|
+
// Only a source read in real time pairs a gap with the matching wait.
|
|
126
|
+
const dtsForwardThreshold = options.dtsForwardThreshold ?? (isLiveInput ? 10 : 0);
|
|
127
|
+
const maxPacketAge = options.maxPacketAge ?? 60;
|
|
128
|
+
for (const [name, value] of [
|
|
129
|
+
['dtsBackwardThreshold', dtsBackwardThreshold],
|
|
130
|
+
['dtsForwardThreshold', dtsForwardThreshold],
|
|
131
|
+
['maxPacketAge', maxPacketAge],
|
|
132
|
+
]) {
|
|
133
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
134
|
+
throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${value}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
102
137
|
if (typeof input === 'string') {
|
|
103
138
|
this.inputUrl = input;
|
|
104
139
|
}
|
|
@@ -114,10 +149,7 @@ export class FMP4Stream {
|
|
|
114
149
|
this.inputOptions = {
|
|
115
150
|
...options.inputOptions,
|
|
116
151
|
options: {
|
|
117
|
-
flags: 'low_delay',
|
|
118
|
-
fflags: 'nobuffer',
|
|
119
|
-
// analyzeduration: 0,
|
|
120
|
-
// probesize: 32,
|
|
152
|
+
...(isLiveInput ? { flags: 'low_delay', fflags: 'nobuffer' } : {}),
|
|
121
153
|
timeout: 10000000,
|
|
122
154
|
rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
|
|
123
155
|
...options.inputOptions?.options,
|
|
@@ -134,6 +166,8 @@ export class FMP4Stream {
|
|
|
134
166
|
fps: options.video?.fps,
|
|
135
167
|
width: options.video?.width,
|
|
136
168
|
height: options.video?.height,
|
|
169
|
+
bitrate: options.video?.bitrate,
|
|
170
|
+
tag: options.video?.tag,
|
|
137
171
|
encoderOptions: options.video?.encoderOptions ?? {},
|
|
138
172
|
},
|
|
139
173
|
audio: {
|
|
@@ -141,7 +175,11 @@ export class FMP4Stream {
|
|
|
141
175
|
},
|
|
142
176
|
bufferSize: options.bufferSize ?? 2 * 1024 * 1024,
|
|
143
177
|
boxMode: options.boxMode ?? false,
|
|
178
|
+
maxQueuedFragments: options.maxQueuedFragments ?? 16,
|
|
144
179
|
movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
|
|
180
|
+
dtsBackwardThreshold,
|
|
181
|
+
dtsForwardThreshold,
|
|
182
|
+
maxPacketAge,
|
|
145
183
|
};
|
|
146
184
|
this.signal = options.signal;
|
|
147
185
|
// Parse supported codecs
|
|
@@ -162,6 +200,8 @@ export class FMP4Stream {
|
|
|
162
200
|
*
|
|
163
201
|
* @returns Configured fMP4 stream instance
|
|
164
202
|
*
|
|
203
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
204
|
+
*
|
|
165
205
|
* @example
|
|
166
206
|
* ```typescript
|
|
167
207
|
* // Stream from file with codec negotiation
|
|
@@ -189,6 +229,8 @@ export class FMP4Stream {
|
|
|
189
229
|
*
|
|
190
230
|
* Only available when boxMode is enabled.
|
|
191
231
|
* Resolves once the first ftyp and moov boxes have been received.
|
|
232
|
+
* Rejects if the stream fails or is stopped before the init segment
|
|
233
|
+
* was produced, so awaiting it never hangs.
|
|
192
234
|
*
|
|
193
235
|
* @throws {Error} If boxMode is not enabled
|
|
194
236
|
*/
|
|
@@ -196,16 +238,39 @@ export class FMP4Stream {
|
|
|
196
238
|
if (!this.options.boxMode) {
|
|
197
239
|
throw new Error('initSegment is only available in box mode');
|
|
198
240
|
}
|
|
199
|
-
this._initSegmentPromise ??= new Promise((resolve) => {
|
|
241
|
+
this._initSegmentPromise ??= new Promise((resolve, reject) => {
|
|
200
242
|
if (this._initSegment) {
|
|
201
243
|
resolve(this._initSegment);
|
|
202
244
|
}
|
|
203
245
|
else {
|
|
204
246
|
this._initSegmentResolve = resolve;
|
|
247
|
+
this._initSegmentReject = reject;
|
|
205
248
|
}
|
|
206
249
|
});
|
|
207
250
|
return this._initSegmentPromise;
|
|
208
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Number of media fragments dropped due to consumer backpressure.
|
|
254
|
+
*
|
|
255
|
+
* Incremented whenever the {@link fragments} backlog exceeds
|
|
256
|
+
* `maxQueuedFragments` and the oldest fragment is discarded.
|
|
257
|
+
* Reset to 0 when the stream is (re)started.
|
|
258
|
+
*
|
|
259
|
+
* @returns Total dropped fragments for the current run
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* ```typescript
|
|
263
|
+
* for await (const fragment of stream.fragments()) {
|
|
264
|
+
* await sendToClient(fragment.data);
|
|
265
|
+
* if (stream.droppedFragments > 0) {
|
|
266
|
+
* console.warn(`Consumer too slow: ${stream.droppedFragments} fragments dropped`);
|
|
267
|
+
* }
|
|
268
|
+
* }
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
get droppedFragments() {
|
|
272
|
+
return this._droppedFragments;
|
|
273
|
+
}
|
|
209
274
|
/**
|
|
210
275
|
* Get the demuxer instance.
|
|
211
276
|
*
|
|
@@ -255,7 +320,7 @@ export class FMP4Stream {
|
|
|
255
320
|
parts.push(FMP4_CODECS.H264);
|
|
256
321
|
}
|
|
257
322
|
if (this.source.audio) {
|
|
258
|
-
parts.push(
|
|
323
|
+
parts.push(this.audioTranscodeTarget());
|
|
259
324
|
}
|
|
260
325
|
return parts.join(',');
|
|
261
326
|
}
|
|
@@ -299,8 +364,7 @@ export class FMP4Stream {
|
|
|
299
364
|
if (audioCodecId) {
|
|
300
365
|
const needsAudioTranscode = !this.isAudioCodecSupported(audioCodecId);
|
|
301
366
|
if (needsAudioTranscode) {
|
|
302
|
-
|
|
303
|
-
audioCodec = FMP4_CODECS.AAC;
|
|
367
|
+
audioCodec = this.audioTranscodeTarget();
|
|
304
368
|
}
|
|
305
369
|
else if (audioCodecId === AV_CODEC_ID_AAC) {
|
|
306
370
|
// AAC - use fixed codec string
|
|
@@ -322,13 +386,58 @@ export class FMP4Stream {
|
|
|
322
386
|
// Combine video and audio codec strings
|
|
323
387
|
return [videoCodec, audioCodec].filter(Boolean).join(',');
|
|
324
388
|
}
|
|
389
|
+
/**
|
|
390
|
+
* Video description of the muxed fMP4 output (output codec, RFC 6381 codec string, resolution, fps).
|
|
391
|
+
*
|
|
392
|
+
* Available after {@link start} has opened the input.
|
|
393
|
+
*
|
|
394
|
+
* @returns The output video info, or `undefined` when there is no video track.
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```typescript
|
|
398
|
+
* const videoInfo = stream.getVideoInfo();
|
|
399
|
+
* if (videoInfo) {
|
|
400
|
+
* console.log(`Codec: ${videoInfo.codec}, Resolution: ${videoInfo.width}x${videoInfo.height}, FPS: ${videoInfo.fps}`);
|
|
401
|
+
* }
|
|
402
|
+
* ```
|
|
403
|
+
*/
|
|
404
|
+
getVideoInfo() {
|
|
405
|
+
if (this.source) {
|
|
406
|
+
if (!this.source.video) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
codec: 'h264',
|
|
411
|
+
codecString: FMP4_CODECS.H264,
|
|
412
|
+
width: this.options.video?.width ?? 0,
|
|
413
|
+
height: this.options.video?.height ?? 0,
|
|
414
|
+
fps: this.options.video?.fps ?? 0,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const videoStream = this.input?.video();
|
|
418
|
+
if (!videoStream) {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
const codecId = videoStream.codecpar.codecId;
|
|
422
|
+
const transcode = !this.isVideoCodecSupported(codecId);
|
|
423
|
+
const codecString = transcode ? FMP4_CODECS.H264 : (avGetCodecString(videoStream.codecpar) ?? FMP4_CODECS.H264);
|
|
424
|
+
const codec = transcode || codecId === AV_CODEC_ID_H264 ? 'h264' : codecId === AV_CODEC_ID_HEVC ? 'hevc' : codecId === AV_CODEC_ID_AV1 ? 'av1' : 'unknown';
|
|
425
|
+
const rate = videoStream.avgFrameRate;
|
|
426
|
+
return {
|
|
427
|
+
codec,
|
|
428
|
+
codecString,
|
|
429
|
+
width: transcode && this.options.video?.width ? this.options.video.width : videoStream.codecpar.width,
|
|
430
|
+
height: transcode && this.options.video?.height ? this.options.video.height : videoStream.codecpar.height,
|
|
431
|
+
fps: transcode && this.options.video?.fps ? this.options.video.fps : rate.den ? rate.num / rate.den : 0,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
325
434
|
/**
|
|
326
435
|
* Start streaming media to fMP4 chunks.
|
|
327
436
|
*
|
|
328
437
|
* Begins the media processing pipeline, reading packets from input,
|
|
329
438
|
* transcoding based on supported codecs, and generating fMP4 chunks.
|
|
330
439
|
* Video transcodes to H.264 if H.264/H.265 not supported.
|
|
331
|
-
* Audio transcodes to AAC
|
|
440
|
+
* Audio transcodes to AAC (Opus when only Opus is listed) if the input codec is not supported.
|
|
332
441
|
* This method returns immediately after starting the pipeline.
|
|
333
442
|
*
|
|
334
443
|
* @returns Promise that resolves when pipeline is started
|
|
@@ -352,6 +461,8 @@ export class FMP4Stream {
|
|
|
352
461
|
async start() {
|
|
353
462
|
this.startPromise ??= this.doStart().catch((error) => {
|
|
354
463
|
this.startPromise = undefined;
|
|
464
|
+
// A pending initSegment consumer must not hang when startup fails.
|
|
465
|
+
this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
|
|
355
466
|
throw error;
|
|
356
467
|
});
|
|
357
468
|
await this.startPromise;
|
|
@@ -362,11 +473,29 @@ export class FMP4Stream {
|
|
|
362
473
|
* @internal
|
|
363
474
|
*/
|
|
364
475
|
async doStart() {
|
|
476
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
477
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
478
|
+
if (this.stopPromise) {
|
|
479
|
+
await this.stopPromise;
|
|
480
|
+
}
|
|
365
481
|
if (this.pipeline) {
|
|
366
482
|
return;
|
|
367
483
|
}
|
|
368
484
|
this.signal?.throwIfAborted();
|
|
369
|
-
|
|
485
|
+
// Store the handler so stop() can remove it - otherwise listeners would
|
|
486
|
+
// accumulate across restarts. Teardown errors are swallowed here: they are
|
|
487
|
+
// already surfaced via onClose(error) by the completion handler.
|
|
488
|
+
this.abortHandler = () => {
|
|
489
|
+
this.stop().catch(() => { });
|
|
490
|
+
};
|
|
491
|
+
this.signal?.addEventListener('abort', this.abortHandler, { once: true });
|
|
492
|
+
this._droppedFragments = 0;
|
|
493
|
+
this.discardOutput = false;
|
|
494
|
+
// Re-armed per start so a restart is not torn down by the previous run.
|
|
495
|
+
this.stopSignal = Promise.withResolvers();
|
|
496
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
497
|
+
// (an RTSP connect can take seconds).
|
|
498
|
+
this.startAbort = new AbortController();
|
|
370
499
|
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
371
500
|
if (this.source) {
|
|
372
501
|
await this.startFromFrames();
|
|
@@ -377,8 +506,10 @@ export class FMP4Stream {
|
|
|
377
506
|
if (!this.inputUrl) {
|
|
378
507
|
throw new Error('No input URL or Demuxer provided');
|
|
379
508
|
}
|
|
380
|
-
this.
|
|
509
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
510
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
381
511
|
}
|
|
512
|
+
this.throwIfStopRequested();
|
|
382
513
|
const videoStream = this.input.video();
|
|
383
514
|
const audioStream = this.input.audio();
|
|
384
515
|
// Check if video needs transcoding
|
|
@@ -396,6 +527,7 @@ export class FMP4Stream {
|
|
|
396
527
|
hardware: this.hardwareContext,
|
|
397
528
|
exitOnError: false,
|
|
398
529
|
});
|
|
530
|
+
this.throwIfStopRequested();
|
|
399
531
|
// Determine if we need filters by comparing with current stream properties
|
|
400
532
|
const currentWidth = videoStream.codecpar.width;
|
|
401
533
|
const currentHeight = videoStream.codecpar.height;
|
|
@@ -403,6 +535,7 @@ export class FMP4Stream {
|
|
|
403
535
|
const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
|
|
404
536
|
(this.options.video.height !== undefined && this.options.video.height !== currentHeight);
|
|
405
537
|
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
538
|
+
const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
406
539
|
// Create filter chain only if needed
|
|
407
540
|
if (needsScale || needsFps) {
|
|
408
541
|
const filterChain = FilterPreset.chain(this.hardwareContext);
|
|
@@ -416,13 +549,28 @@ export class FMP4Stream {
|
|
|
416
549
|
if (needsFps) {
|
|
417
550
|
filterChain.filter('fps', { fps: this.options.video.fps });
|
|
418
551
|
}
|
|
552
|
+
// Software scaling leaves the graph output format unconstrained, so swscale keeps the
|
|
553
|
+
// source format on both sides of the scale. Packed formats like yuyv422 can't be scaled
|
|
554
|
+
// in place (yuyv422 -> yuyv422 fails with ENOSYS), and the encoder wouldn't accept them
|
|
555
|
+
// anyway. Pin the output to yuv420p so the graph negotiates a real conversion: it is the
|
|
556
|
+
// one format every browser/player can decode via MSE (unlike e.g. the 4:2:2 High profile
|
|
557
|
+
// that a least-loss pick would choose for a yuyv422 source). Fall back to a codec-supported
|
|
558
|
+
// format only if the encoder genuinely can't take yuv420p. Hardware chains negotiate their
|
|
559
|
+
// format through the frames context instead.
|
|
560
|
+
if (needsScale && !this.hardwareContext) {
|
|
561
|
+
const formats = encoderCodec.pixelFormats;
|
|
562
|
+
const targetFmt = !formats || formats.includes(AV_PIX_FMT_YUV420P) ? AV_PIX_FMT_YUV420P : pickSupportedPixelFormat(videoStream.codecpar.format, formats);
|
|
563
|
+
const targetFmtName = avGetPixFmtName(targetFmt);
|
|
564
|
+
if (targetFmtName) {
|
|
565
|
+
filterChain.filter('format', { pix_fmts: targetFmtName });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
419
568
|
this.videoFilter = FilterAPI.create(filterChain.build(), {
|
|
420
569
|
hardware: this.hardwareContext,
|
|
421
570
|
});
|
|
422
571
|
}
|
|
423
|
-
const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
424
572
|
let encoderOptions = {};
|
|
425
|
-
if (encoderCodec.name === FF_ENCODER_LIBX264
|
|
573
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
426
574
|
encoderOptions.preset = 'ultrafast';
|
|
427
575
|
encoderOptions.tune = 'zerolatency';
|
|
428
576
|
}
|
|
@@ -430,36 +578,100 @@ export class FMP4Stream {
|
|
|
430
578
|
...encoderOptions,
|
|
431
579
|
...this.options.video.encoderOptions,
|
|
432
580
|
};
|
|
581
|
+
const effectiveFps = this.options.video.fps ?? currentFps;
|
|
582
|
+
const fpsForGop = isFinite(effectiveFps) && effectiveFps > 0 ? effectiveFps : 30;
|
|
583
|
+
const bitrate = this.options.video.bitrate;
|
|
433
584
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
434
585
|
decoder: this.videoDecoder,
|
|
435
586
|
options: encoderOptions,
|
|
587
|
+
configure: (ctx) => {
|
|
588
|
+
ctx.gopSize = this.gopSize(fpsForGop);
|
|
589
|
+
this.applyBitrate(ctx, bitrate);
|
|
590
|
+
},
|
|
436
591
|
});
|
|
592
|
+
this.throwIfStopRequested();
|
|
437
593
|
}
|
|
438
594
|
// Check if audio needs transcoding
|
|
439
595
|
const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
|
|
440
596
|
if (needsAudioTranscode) {
|
|
441
|
-
// Transcode to AAC
|
|
442
597
|
this.audioDecoder = await Decoder.create(audioStream, {
|
|
443
598
|
exitOnError: false,
|
|
444
599
|
});
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
this.audioFilter = FilterAPI.create(filterChain);
|
|
448
|
-
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
449
|
-
decoder: this.audioDecoder,
|
|
450
|
-
filter: this.audioFilter,
|
|
451
|
-
options: this.options.audio.encoderOptions,
|
|
452
|
-
});
|
|
600
|
+
await this.createAudioEncoder(this.audioDecoder, audioStream.codecpar.channels);
|
|
601
|
+
this.throwIfStopRequested();
|
|
453
602
|
}
|
|
454
603
|
// Setup output with callback
|
|
455
604
|
this.output = await this.createOutput();
|
|
605
|
+
this.throwIfStopRequested();
|
|
456
606
|
this.attachCompletion(this.runPipeline());
|
|
457
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Abort an in-flight startup once stop() was requested.
|
|
610
|
+
*
|
|
611
|
+
* Checked after every await in the startup path: the resources created so
|
|
612
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
613
|
+
* this startup tears them down the moment it unwinds.
|
|
614
|
+
*
|
|
615
|
+
* @throws {Error} If stop() was called while starting
|
|
616
|
+
*
|
|
617
|
+
* @internal
|
|
618
|
+
*/
|
|
619
|
+
throwIfStopRequested() {
|
|
620
|
+
if (this.stopRequested) {
|
|
621
|
+
throw new Error('FMP4Stream stopped during start');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Cap the video bitrate on the encoder context.
|
|
626
|
+
*
|
|
627
|
+
* Applies a VBV rate cap (maxrate + a 1s buffer) WITHOUT setting a target
|
|
628
|
+
* bitrate, so the encoder keeps its default constant-quality mode but can
|
|
629
|
+
* never exceed the negotiated ceiling. Setting the bitrate as an ABR target
|
|
630
|
+
* instead would push a normally-lighter stream UP to fill it, overshooting on
|
|
631
|
+
* bursts - the opposite of what strict rate-capped consumers want.
|
|
632
|
+
* No-op when no bitrate was requested.
|
|
633
|
+
*
|
|
634
|
+
* @param ctx - Encoder codec context
|
|
635
|
+
*
|
|
636
|
+
* @param bitrate - Maximum bitrate in bits per second, or undefined
|
|
637
|
+
*
|
|
638
|
+
* @internal
|
|
639
|
+
*/
|
|
640
|
+
applyBitrate(ctx, bitrate) {
|
|
641
|
+
if (!bitrate || bitrate <= 0) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
ctx.rcMaxRate = BigInt(Math.round(bitrate));
|
|
645
|
+
// 1s buffer keeps per-fragment size close to the cap instead of allowing
|
|
646
|
+
// multi-second bursts over it.
|
|
647
|
+
ctx.rcBufferSize = Math.round(bitrate);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* GOP size for the video encoder that starts every fragment with a keyframe.
|
|
651
|
+
*
|
|
652
|
+
* frag_duration flushes a fragment once it is that long, mid-GOP on a
|
|
653
|
+
* P-frame unless the GOP has the same length, and strict consumers reject
|
|
654
|
+
* such fragments. Shorter fragments (the 1 µs default flushes after every
|
|
655
|
+
* frame) are not matched: a GOP of a few frames multiplies the bitrate, so
|
|
656
|
+
* below MIN_FRAGMENT_GOP the GOP is 2 s, as with a fragDuration of 0.
|
|
657
|
+
*
|
|
658
|
+
* @param fps - Output frame rate
|
|
659
|
+
*
|
|
660
|
+
* @returns GOP size in frames
|
|
661
|
+
*
|
|
662
|
+
* @internal
|
|
663
|
+
*/
|
|
664
|
+
gopSize(fps) {
|
|
665
|
+
const fragSeconds = this.options.fragDuration / 1_000_000;
|
|
666
|
+
const seconds = fragSeconds >= MIN_FRAGMENT_GOP ? fragSeconds : 2;
|
|
667
|
+
return Math.max(1, Math.round(fps * seconds));
|
|
668
|
+
}
|
|
458
669
|
/**
|
|
459
670
|
* Create the fMP4 output muxer with the fragment-emitting write callback.
|
|
460
671
|
*
|
|
461
672
|
* Uses `this.input` when present (demuxer path) and no input for the
|
|
462
|
-
* frame-source path.
|
|
673
|
+
* frame-source path. Attaches a fresh packet age watchdog unless
|
|
674
|
+
* `maxPacketAge` is 0.
|
|
463
675
|
*
|
|
464
676
|
* @returns The configured muxer
|
|
465
677
|
*
|
|
@@ -468,12 +680,17 @@ export class FMP4Stream {
|
|
|
468
680
|
async createOutput() {
|
|
469
681
|
const cb = {
|
|
470
682
|
write: (buffer) => {
|
|
683
|
+
// Swallows what the teardown of a failed session flushes (see attachCompletion).
|
|
684
|
+
if (this.discardOutput) {
|
|
685
|
+
return buffer.length;
|
|
686
|
+
}
|
|
471
687
|
if (this.options.boxMode) {
|
|
472
688
|
// Box mode: buffer until we have complete boxes
|
|
473
689
|
this.processBoxMode(buffer);
|
|
474
690
|
}
|
|
475
691
|
else {
|
|
476
692
|
// Chunk mode: send raw data immediately
|
|
693
|
+
this.packetAgeWatchdog?.consumeChunk(buffer);
|
|
477
694
|
const info = { isComplete: false, boxes: [] };
|
|
478
695
|
this.options.onData(buffer, info);
|
|
479
696
|
this.pushFragment(buffer, info);
|
|
@@ -481,39 +698,126 @@ export class FMP4Stream {
|
|
|
481
698
|
return buffer.length;
|
|
482
699
|
},
|
|
483
700
|
};
|
|
484
|
-
|
|
701
|
+
const output = await Muxer.open(cb, {
|
|
485
702
|
input: this.input,
|
|
486
703
|
format: 'mp4',
|
|
487
704
|
bufferSize: this.options.bufferSize,
|
|
488
705
|
exitOnError: false,
|
|
706
|
+
dtsBackwardThreshold: this.options.dtsBackwardThreshold,
|
|
707
|
+
dtsForwardThreshold: this.options.dtsForwardThreshold,
|
|
708
|
+
configure: (fmt) => {
|
|
709
|
+
const tag = this.options.video?.tag;
|
|
710
|
+
if (!tag) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
for (const stream of fmt.streams) {
|
|
714
|
+
if (stream.codecpar.codecId === AV_CODEC_ID_HEVC) {
|
|
715
|
+
stream.codecpar.codecTag = tag;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
},
|
|
489
719
|
options: {
|
|
490
720
|
movflags: this.options.movFlags,
|
|
491
721
|
frag_duration: this.options.fragDuration,
|
|
492
722
|
},
|
|
493
723
|
});
|
|
724
|
+
// Per session: a restart must not inherit the previous session's backlog.
|
|
725
|
+
if (this.options.maxPacketAge > 0) {
|
|
726
|
+
this.packetAgeWatchdog = new PacketAgeWatchdog(this.options.maxPacketAge, (index) => {
|
|
727
|
+
const codecType = output.getStream(index)?.codecpar.codecType;
|
|
728
|
+
return codecType === undefined ? '' : (avGetMediaTypeString(codecType) ?? '');
|
|
729
|
+
});
|
|
730
|
+
output.setPacketObserver(this.packetAgeWatchdog);
|
|
731
|
+
}
|
|
732
|
+
return output;
|
|
494
733
|
}
|
|
495
734
|
/**
|
|
496
735
|
* Wire the pipeline completion to the fragment/close callbacks.
|
|
497
736
|
*
|
|
498
737
|
* On success ends the fragment iterator and invokes `onClose()`; on error
|
|
499
|
-
* stops the stream
|
|
738
|
+
* stops the stream, discarding the output the muxer still holds, and invokes
|
|
739
|
+
* `onClose(error)`. Either way `onClose` runs exactly once and the detached
|
|
740
|
+
* chain never rejects. Runs in the background.
|
|
500
741
|
*
|
|
501
742
|
* @param completion - The pipeline completion promise
|
|
502
743
|
*
|
|
503
744
|
* @internal
|
|
504
745
|
*/
|
|
505
746
|
attachCompletion(completion) {
|
|
506
|
-
|
|
507
|
-
|
|
747
|
+
// One then() with both handlers: a throwing onClose() in the success path
|
|
748
|
+
// must not fall through into the error path and fire onClose a second time.
|
|
749
|
+
completion.then(() => {
|
|
508
750
|
this.endFragments();
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
.
|
|
751
|
+
// No-op in the normal case (init segment resolved long ago) - only
|
|
752
|
+
// fires if the stream ended without ever emitting ftyp+moov.
|
|
753
|
+
this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
|
|
754
|
+
this.emitClose();
|
|
755
|
+
}, async (reason) => {
|
|
756
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
512
757
|
this.endFragments();
|
|
513
|
-
|
|
514
|
-
|
|
758
|
+
// Reject with the pipeline error so an initSegment consumer sees the
|
|
759
|
+
// actual failure instead of the generic "stopped" error from stop().
|
|
760
|
+
this.rejectInitSegment(error);
|
|
761
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
762
|
+
// the stream down - the pipeline unwinding with an AbortError is the
|
|
763
|
+
// expected shutdown path, not a failure. Decided before the stop()
|
|
764
|
+
// below, which would make every failure look deliberate.
|
|
765
|
+
const deliberate = this.isDeliberateStop(reason);
|
|
766
|
+
// Closing the output flushes the muxer's backlog and the trailer. After
|
|
767
|
+
// a failure that backlog is suspect: when a backward jump ends the
|
|
768
|
+
// session, the packets the timeline had jumped ahead to are still
|
|
769
|
+
// queued and would leave as a fragment placed that far ahead; after a
|
|
770
|
+
// stall it holds up to maxPacketAge of retained samples. Nothing is
|
|
771
|
+
// emitted past the failure, through onData or fragments(). A
|
|
772
|
+
// deliberate stop keeps its tail.
|
|
773
|
+
if (!deliberate) {
|
|
774
|
+
this.discardOutput = true;
|
|
775
|
+
}
|
|
776
|
+
// Release the input (e.g. the RTSP connection) before reporting, so an
|
|
777
|
+
// owner that restarts from onClose never overlaps a live session. A
|
|
778
|
+
// teardown failure must not swallow the onClose call: without it the
|
|
779
|
+
// owner never learns the stream is gone.
|
|
780
|
+
try {
|
|
781
|
+
await this.stop();
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// doStop() already released whatever it could.
|
|
785
|
+
}
|
|
786
|
+
this.emitClose(deliberate ? undefined : error);
|
|
515
787
|
});
|
|
516
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* Invoke the owner's onClose callback.
|
|
791
|
+
*
|
|
792
|
+
* The completion chain runs detached, so an exception from the callback
|
|
793
|
+
* could only surface as an unhandled rejection - it is logged instead.
|
|
794
|
+
*
|
|
795
|
+
* @param error - Pipeline error, or undefined for a clean end
|
|
796
|
+
*
|
|
797
|
+
* @internal
|
|
798
|
+
*/
|
|
799
|
+
emitClose(error) {
|
|
800
|
+
try {
|
|
801
|
+
this.options.onClose(error);
|
|
802
|
+
}
|
|
803
|
+
catch (callbackError) {
|
|
804
|
+
// The stream is already closed; logged so a broken restart path in the
|
|
805
|
+
// owner does not vanish silently.
|
|
806
|
+
console.error('[FMP4Stream] onClose callback threw:', callbackError);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
811
|
+
*
|
|
812
|
+
* @param error - The pipeline rejection reason
|
|
813
|
+
*
|
|
814
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
815
|
+
*
|
|
816
|
+
* @internal
|
|
817
|
+
*/
|
|
818
|
+
isDeliberateStop(error) {
|
|
819
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
820
|
+
}
|
|
517
821
|
/**
|
|
518
822
|
* Set up encoders and the fMP4 muxer for a pre-composited frame source and
|
|
519
823
|
* start encoding. No demuxer or decoder is involved - the provided frames are
|
|
@@ -552,37 +856,39 @@ export class FMP4Stream {
|
|
|
552
856
|
fps = 30;
|
|
553
857
|
}
|
|
554
858
|
// Set the framerate explicitly - without a decoder the encoder would infer
|
|
555
|
-
// it from the frame timebase and pick an absurd level.
|
|
556
|
-
|
|
859
|
+
// it from the frame timebase and pick an absurd level.
|
|
860
|
+
const bitrate = this.options.video.bitrate;
|
|
557
861
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
558
862
|
options: encoderOptions,
|
|
559
863
|
configure: (ctx) => {
|
|
560
864
|
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
561
|
-
ctx.gopSize =
|
|
865
|
+
ctx.gopSize = this.gopSize(fps);
|
|
866
|
+
this.applyBitrate(ctx, bitrate);
|
|
562
867
|
},
|
|
563
868
|
});
|
|
564
869
|
}
|
|
565
|
-
// Audio encoder (AAC), resampling via aformat like the demuxer path.
|
|
566
870
|
if (source.audio) {
|
|
567
|
-
|
|
568
|
-
this.audioFilter = FilterAPI.create(filterChain);
|
|
569
|
-
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
570
|
-
filter: this.audioFilter,
|
|
571
|
-
options: this.options.audio.encoderOptions,
|
|
572
|
-
});
|
|
871
|
+
await this.createAudioEncoder();
|
|
573
872
|
}
|
|
873
|
+
this.throwIfStopRequested();
|
|
574
874
|
this.output = await this.createOutput();
|
|
875
|
+
this.throwIfStopRequested();
|
|
575
876
|
// eslint-disable-next-line prefer-const
|
|
576
877
|
let control;
|
|
577
878
|
const shouldStop = () => control?.isStopped() ?? false;
|
|
578
879
|
const tasks = [];
|
|
880
|
+
// A frame source has no demuxer for stop() to interrupt, so wrap it to make
|
|
881
|
+
// a silent source releasable instead of blocking teardown forever.
|
|
882
|
+
const stopped = this.stopSignal.promise;
|
|
579
883
|
if (source.video && this.videoEncoder) {
|
|
580
884
|
const streamIndex = this.output.addStream(this.videoEncoder);
|
|
581
|
-
|
|
885
|
+
const video = interruptibleFrameSource(source.video, stopped);
|
|
886
|
+
tasks.push(consumeStreamInParallel(this.videoEncoder.packets(video), this.output, streamIndex, shouldStop));
|
|
582
887
|
}
|
|
583
888
|
if (source.audio && this.audioFilter && this.audioEncoder) {
|
|
584
889
|
const streamIndex = this.output.addStream(this.audioEncoder);
|
|
585
|
-
|
|
890
|
+
const audio = interruptibleFrameSource(source.audio, stopped);
|
|
891
|
+
tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
|
|
586
892
|
}
|
|
587
893
|
// Close the shared muxer only after ALL streams are done (flushes the trailer).
|
|
588
894
|
const run = Promise.all(tasks).then(async () => {
|
|
@@ -597,7 +903,8 @@ export class FMP4Stream {
|
|
|
597
903
|
*
|
|
598
904
|
* Stops the pipeline, closes output, and releases all FFmpeg resources.
|
|
599
905
|
* Safe to call multiple times. After stopping, you can call start() again
|
|
600
|
-
* to restart the stream.
|
|
906
|
+
* to restart the stream. Resolves even if the pipeline had failed; that
|
|
907
|
+
* error is reported through `onClose(error)`.
|
|
601
908
|
*
|
|
602
909
|
* @example
|
|
603
910
|
* ```typescript
|
|
@@ -611,46 +918,117 @@ export class FMP4Stream {
|
|
|
611
918
|
* ```
|
|
612
919
|
*/
|
|
613
920
|
async stop() {
|
|
614
|
-
|
|
921
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
922
|
+
// double-closing native resources.
|
|
923
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
924
|
+
this.stopPromise = undefined;
|
|
925
|
+
});
|
|
926
|
+
await this.stopPromise;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Perform the actual teardown (see {@link stop}).
|
|
930
|
+
*
|
|
931
|
+
* @internal
|
|
932
|
+
*/
|
|
933
|
+
async doStop() {
|
|
934
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
935
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
936
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
937
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
938
|
+
this.stopRequested = true;
|
|
939
|
+
this.startAbort?.abort();
|
|
940
|
+
this.startAbort = undefined;
|
|
941
|
+
// Unblocks a frame source that went silent, so the completion barrier below
|
|
942
|
+
// cannot wait on a pull that will never settle.
|
|
943
|
+
this.stopSignal.resolve();
|
|
944
|
+
const inflightStart = this.startPromise;
|
|
615
945
|
// Allow a fresh start() after stopping.
|
|
616
946
|
this.startPromise = undefined;
|
|
617
|
-
|
|
618
|
-
// completion - the error is already surfaced via the onClose(error) path;
|
|
619
|
-
// we just need cleanup to proceed.
|
|
620
|
-
if (this.pipeline && !this.pipeline.isStopped()) {
|
|
621
|
-
this.pipeline.stop();
|
|
947
|
+
if (inflightStart) {
|
|
622
948
|
try {
|
|
623
|
-
await
|
|
949
|
+
await inflightStart;
|
|
624
950
|
}
|
|
625
951
|
catch {
|
|
626
|
-
//
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
952
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
this.endFragments();
|
|
957
|
+
// A consumer awaiting the init segment must not hang forever.
|
|
958
|
+
this.rejectInitSegment(new Error('FMP4Stream stopped before init segment was produced'));
|
|
959
|
+
// Remove the abort listener so it does not accumulate across restarts.
|
|
960
|
+
if (this.abortHandler) {
|
|
961
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
962
|
+
this.abortHandler = undefined;
|
|
963
|
+
}
|
|
964
|
+
// Stop pipeline if running and wait for completion. Swallow a rejected
|
|
965
|
+
// completion - the error is already surfaced via the onClose(error) path;
|
|
966
|
+
// we just need cleanup to proceed.
|
|
967
|
+
const pipeline = this.pipeline;
|
|
968
|
+
if (pipeline) {
|
|
969
|
+
if (!pipeline.isStopped()) {
|
|
970
|
+
pipeline.stop();
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
await pipeline.completion;
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
977
|
+
}
|
|
978
|
+
if (this.pipeline === pipeline) {
|
|
979
|
+
this.pipeline = undefined;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// Close all resources. The output goes FIRST: its async write worker may
|
|
983
|
+
// still be draining (header init reads the input's stream parameters),
|
|
984
|
+
// so freeing the input before the muxer has fully settled is a
|
|
985
|
+
// use-after-free on the worker thread.
|
|
986
|
+
// After a failure nothing the output emits is used (see attachCompletion),
|
|
987
|
+
// so the trailer is skipped instead of flushed into the void.
|
|
988
|
+
if (this.discardOutput) {
|
|
989
|
+
this.output?.discardOnClose();
|
|
990
|
+
}
|
|
991
|
+
// close() rethrows a failed background write after it freed the muxer.
|
|
992
|
+
// That error already ended the pipeline and reaches the owner through
|
|
993
|
+
// onClose(error), so it must not abort the teardown here and leak the
|
|
994
|
+
// codecs and the input (an RTSP connection) below.
|
|
995
|
+
try {
|
|
996
|
+
await this.output?.close();
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// Surfaced through the pipeline completion.
|
|
1000
|
+
}
|
|
1001
|
+
this.output = undefined;
|
|
1002
|
+
this.packetAgeWatchdog = undefined;
|
|
1003
|
+
this.videoDecoder?.close();
|
|
1004
|
+
this.videoDecoder = undefined;
|
|
1005
|
+
this.videoFilter?.close();
|
|
1006
|
+
this.videoFilter = undefined;
|
|
1007
|
+
this.videoEncoder?.close();
|
|
1008
|
+
this.videoEncoder = undefined;
|
|
1009
|
+
this.audioDecoder?.close();
|
|
1010
|
+
this.audioDecoder = undefined;
|
|
1011
|
+
this.audioFilter?.close();
|
|
1012
|
+
this.audioFilter = undefined;
|
|
1013
|
+
this.audioEncoder?.close();
|
|
1014
|
+
this.audioEncoder = undefined;
|
|
1015
|
+
this.hardwareContext?.dispose();
|
|
1016
|
+
this.hardwareContext = undefined;
|
|
1017
|
+
await this.input?.close();
|
|
1018
|
+
this.input = undefined;
|
|
1019
|
+
// Reset parser and init segment state so a restart parses a clean stream.
|
|
1020
|
+
this.incompleteBoxBuffer = null;
|
|
1021
|
+
this.pendingFragment = [];
|
|
1022
|
+
this._initSegment = null;
|
|
1023
|
+
this._initSegmentResolve = null;
|
|
1024
|
+
this._initSegmentReject = null;
|
|
1025
|
+
this._initSegmentPromise = null;
|
|
1026
|
+
this._ftypData = null;
|
|
1027
|
+
this._moovData = null;
|
|
1028
|
+
}
|
|
1029
|
+
finally {
|
|
1030
|
+
this.stopRequested = false;
|
|
1031
|
+
}
|
|
654
1032
|
}
|
|
655
1033
|
/**
|
|
656
1034
|
* Async generator that yields media fragments (moof+mdat chunks).
|
|
@@ -663,8 +1041,16 @@ export class FMP4Stream {
|
|
|
663
1041
|
*
|
|
664
1042
|
* The generator completes when the stream stops or the pipeline ends.
|
|
665
1043
|
*
|
|
1044
|
+
* Only a single consumer is supported: a second concurrent fragments() call
|
|
1045
|
+
* would silently strand the first consumer, so it throws instead. The
|
|
1046
|
+
* backlog is bounded by `maxQueuedFragments` - if the consumer falls behind
|
|
1047
|
+
* a live source, the oldest fragments are dropped (see
|
|
1048
|
+
* {@link droppedFragments}).
|
|
1049
|
+
*
|
|
666
1050
|
* @yields {FMP4Fragment} Media fragment with data buffer and box info
|
|
667
1051
|
*
|
|
1052
|
+
* @throws {Error} If another fragments() iterator is already active
|
|
1053
|
+
*
|
|
668
1054
|
* @example
|
|
669
1055
|
* ```typescript
|
|
670
1056
|
* const stream = FMP4Stream.create('rtsp://camera/stream', {
|
|
@@ -681,6 +1067,9 @@ export class FMP4Stream {
|
|
|
681
1067
|
* ```
|
|
682
1068
|
*/
|
|
683
1069
|
async *fragments() {
|
|
1070
|
+
if (this.fragmentQueue) {
|
|
1071
|
+
throw new Error('fragments() supports only a single consumer; the previous iterator is still active');
|
|
1072
|
+
}
|
|
684
1073
|
this.fragmentQueue = { queue: [], resolve: null, done: false };
|
|
685
1074
|
try {
|
|
686
1075
|
while (true) {
|
|
@@ -759,6 +1148,47 @@ export class FMP4Stream {
|
|
|
759
1148
|
}
|
|
760
1149
|
return false;
|
|
761
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Codec that unsupported audio is transcoded to.
|
|
1153
|
+
*
|
|
1154
|
+
* @returns Opus when the client lists Opus but not AAC, otherwise AAC
|
|
1155
|
+
*
|
|
1156
|
+
* @internal
|
|
1157
|
+
*/
|
|
1158
|
+
audioTranscodeTarget() {
|
|
1159
|
+
return this.supportedCodecs.has(FMP4_CODECS.OPUS) && !this.supportedCodecs.has(FMP4_CODECS.AAC) ? FMP4_CODECS.OPUS : FMP4_CODECS.AAC;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Create the audio filter and encoder for the transcode target.
|
|
1163
|
+
*
|
|
1164
|
+
* AAC is resampled to 44.1 kHz stereo. Opus is resampled to 48 kHz, the only
|
|
1165
|
+
* rate it transmits, keeps mono input mono and uses 20 ms frames.
|
|
1166
|
+
*
|
|
1167
|
+
* @param decoder - Input audio decoder, absent for a frame source
|
|
1168
|
+
*
|
|
1169
|
+
* @param channels - Input channel count, 0 when unknown
|
|
1170
|
+
*
|
|
1171
|
+
* @internal
|
|
1172
|
+
*/
|
|
1173
|
+
async createAudioEncoder(decoder, channels = 0) {
|
|
1174
|
+
if (this.audioTranscodeTarget() === FMP4_CODECS.OPUS) {
|
|
1175
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain()
|
|
1176
|
+
.aformat(AV_SAMPLE_FMT_FLT, 48000, channels === 1 ? 'mono' : 'stereo')
|
|
1177
|
+
.build());
|
|
1178
|
+
this.audioEncoder = await Encoder.create(FF_ENCODER_LIBOPUS, {
|
|
1179
|
+
decoder,
|
|
1180
|
+
filter: this.audioFilter,
|
|
1181
|
+
options: { frame_duration: 20, ...this.options.audio.encoderOptions },
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
this.audioFilter = FilterAPI.create(FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build());
|
|
1186
|
+
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
1187
|
+
decoder,
|
|
1188
|
+
filter: this.audioFilter,
|
|
1189
|
+
options: this.options.audio.encoderOptions,
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
762
1192
|
/**
|
|
763
1193
|
* Check if audio codec is supported.
|
|
764
1194
|
*
|
|
@@ -783,6 +1213,18 @@ export class FMP4Stream {
|
|
|
783
1213
|
/**
|
|
784
1214
|
* Process buffer in box mode - buffers until complete boxes are available.
|
|
785
1215
|
*
|
|
1216
|
+
* The muxer splits writes at arbitrary positions, so a chunk may end
|
|
1217
|
+
* anywhere - including inside a box header. Any trailing partial box
|
|
1218
|
+
* (header or body) is buffered for the next chunk; the parser never
|
|
1219
|
+
* loses bytes regardless of where the split falls.
|
|
1220
|
+
*
|
|
1221
|
+
* Emission is fragment-aligned: a `moof` is held back until its `mdat` has
|
|
1222
|
+
* arrived, then both are emitted as ONE onData call. The avio buffer flushes
|
|
1223
|
+
* at arbitrary points (a fragment larger than the buffer always splits), so
|
|
1224
|
+
* without this a consumer would see a bare `moof` followed by a detached
|
|
1225
|
+
* `mdat` - unplayable framing for fMP4 consumers.
|
|
1226
|
+
* Non-fragment boxes (ftyp, moov, mfra, ...) are emitted as they complete.
|
|
1227
|
+
*
|
|
786
1228
|
* @param chunk - Incoming data chunk from FFmpeg
|
|
787
1229
|
*
|
|
788
1230
|
* @internal
|
|
@@ -794,60 +1236,132 @@ export class FMP4Stream {
|
|
|
794
1236
|
this.incompleteBoxBuffer = null;
|
|
795
1237
|
}
|
|
796
1238
|
let offset = 0;
|
|
797
|
-
|
|
798
|
-
|
|
1239
|
+
let immediate = [];
|
|
1240
|
+
const flushImmediate = () => {
|
|
1241
|
+
if (immediate.length > 0) {
|
|
1242
|
+
this.emitBoxGroup(immediate);
|
|
1243
|
+
immediate = [];
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
while (offset < chunk.length) {
|
|
1247
|
+
const remaining = chunk.length - offset;
|
|
1248
|
+
// Partial 32-bit header - buffer the tail for the next chunk. Dropping
|
|
1249
|
+
// it would desynchronize the parser for the rest of the stream.
|
|
1250
|
+
if (remaining < 8) {
|
|
1251
|
+
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
799
1254
|
// Read box header
|
|
800
|
-
const
|
|
801
|
-
|
|
1255
|
+
const size32 = chunk.readUInt32BE(offset);
|
|
1256
|
+
let headerSize = 8;
|
|
1257
|
+
let boxSize = size32;
|
|
1258
|
+
if (size32 === 1) {
|
|
1259
|
+
// 64-bit box: the actual size follows the type field as a 64-bit
|
|
1260
|
+
// largesize (16-byte header total). movenc emits this for mdat > 4 GB.
|
|
1261
|
+
if (remaining < 16) {
|
|
1262
|
+
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
1263
|
+
break;
|
|
1264
|
+
}
|
|
1265
|
+
headerSize = 16;
|
|
1266
|
+
boxSize = Number(chunk.readBigUInt64BE(offset + 8));
|
|
1267
|
+
}
|
|
1268
|
+
// Safety check: invalid box size. size==0 ("box extends to end of file")
|
|
1269
|
+
// cannot be framed in a live stream and any size below the header size
|
|
1270
|
+
// is corrupt - recovery is impossible, so drop the rest of this chunk
|
|
1271
|
+
// rather than emit garbage framing downstream.
|
|
1272
|
+
if (boxSize < headerSize) {
|
|
1273
|
+
// Emitted samples can no longer be counted - stop judging packet ages
|
|
1274
|
+
// instead of mistaking the lost framing for a stalled muxer.
|
|
1275
|
+
this.packetAgeWatchdog?.onFramingLost();
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
802
1278
|
// Check if we have the complete box
|
|
803
1279
|
if (offset + boxSize > chunk.length) {
|
|
804
|
-
// Box is incomplete - save for next chunk
|
|
1280
|
+
// Box body is incomplete - save the whole box for the next chunk
|
|
805
1281
|
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
806
1282
|
break;
|
|
807
1283
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
type: boxType,
|
|
1284
|
+
const parsed = {
|
|
1285
|
+
type: chunk.toString('ascii', offset + 4, offset + 8),
|
|
811
1286
|
size: boxSize,
|
|
812
|
-
|
|
813
|
-
|
|
1287
|
+
headerSize,
|
|
1288
|
+
raw: chunk.subarray(offset, offset + boxSize),
|
|
814
1289
|
};
|
|
815
|
-
|
|
1290
|
+
if (parsed.type === 'moof' || parsed.type === 'moov') {
|
|
1291
|
+
this.packetAgeWatchdog?.consumeBox(parsed.raw);
|
|
1292
|
+
}
|
|
1293
|
+
if (parsed.type === 'moof') {
|
|
1294
|
+
flushImmediate();
|
|
1295
|
+
if (this.pendingFragment.length > 0) {
|
|
1296
|
+
// a moof without a following mdat never happens with movenc, emit
|
|
1297
|
+
// the stale fragment as-is rather than silently dropping bytes
|
|
1298
|
+
this.emitBoxGroup(this.pendingFragment);
|
|
1299
|
+
this.pendingFragment = [];
|
|
1300
|
+
}
|
|
1301
|
+
this.pendingFragment.push(parsed);
|
|
1302
|
+
}
|
|
1303
|
+
else if (this.pendingFragment.length > 0) {
|
|
1304
|
+
this.pendingFragment.push(parsed);
|
|
1305
|
+
if (parsed.type === 'mdat') {
|
|
1306
|
+
this.emitBoxGroup(this.pendingFragment);
|
|
1307
|
+
this.pendingFragment = [];
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
else {
|
|
1311
|
+
immediate.push(parsed);
|
|
1312
|
+
}
|
|
816
1313
|
// Move to next box
|
|
817
1314
|
offset += boxSize;
|
|
818
|
-
// Safety check: invalid box size
|
|
819
|
-
if (boxSize < 8) {
|
|
820
|
-
break;
|
|
821
|
-
}
|
|
822
1315
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1316
|
+
flushImmediate();
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Emit a group of complete boxes as one onData call.
|
|
1320
|
+
*
|
|
1321
|
+
* Concatenates the raw box bytes, rebuilds the box list with offsets
|
|
1322
|
+
* relative to the emitted buffer, tracks the init segment, and feeds the
|
|
1323
|
+
* fragments() queue when the group is a media fragment.
|
|
1324
|
+
*
|
|
1325
|
+
* @param entries - Complete boxes in stream order
|
|
1326
|
+
*
|
|
1327
|
+
* @internal
|
|
1328
|
+
*/
|
|
1329
|
+
emitBoxGroup(entries) {
|
|
1330
|
+
const data = entries.length === 1 ? entries[0].raw : Buffer.concat(entries.map((e) => e.raw));
|
|
1331
|
+
let position = 0;
|
|
1332
|
+
const boxes = entries.map((entry) => {
|
|
1333
|
+
const box = {
|
|
1334
|
+
type: entry.type,
|
|
1335
|
+
size: entry.size,
|
|
1336
|
+
data: data.subarray(position + entry.headerSize, position + entry.size),
|
|
1337
|
+
offset: position,
|
|
1338
|
+
};
|
|
1339
|
+
position += entry.size;
|
|
1340
|
+
return box;
|
|
1341
|
+
});
|
|
1342
|
+
const info = { isComplete: true, boxes };
|
|
1343
|
+
this.options.onData(data, info);
|
|
1344
|
+
// Init segment tracking: collect ftyp and moov data
|
|
1345
|
+
if (!this._initSegment) {
|
|
1346
|
+
for (const box of boxes) {
|
|
1347
|
+
if (box.type === 'ftyp' && !this._ftypData) {
|
|
1348
|
+
this._ftypData = data.subarray(box.offset, box.offset + box.size);
|
|
838
1349
|
}
|
|
839
|
-
if (
|
|
840
|
-
this.
|
|
841
|
-
this._initSegmentResolve?.(this._initSegment);
|
|
842
|
-
this._initSegmentResolve = null;
|
|
1350
|
+
if (box.type === 'moov' && !this._moovData) {
|
|
1351
|
+
this._moovData = data.subarray(box.offset, box.offset + box.size);
|
|
843
1352
|
}
|
|
844
1353
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
this.
|
|
1354
|
+
if (this._ftypData && this._moovData) {
|
|
1355
|
+
this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
|
|
1356
|
+
this._initSegmentResolve?.(this._initSegment);
|
|
1357
|
+
this._initSegmentResolve = null;
|
|
1358
|
+
this._initSegmentReject = null;
|
|
849
1359
|
}
|
|
850
1360
|
}
|
|
1361
|
+
// fragments() generator gets only media fragments (moof+mdat)
|
|
1362
|
+
if (boxes.some((b) => b.type === 'moof')) {
|
|
1363
|
+
this.pushFragment(data, info);
|
|
1364
|
+
}
|
|
851
1365
|
}
|
|
852
1366
|
/**
|
|
853
1367
|
* Push a fragment to the fragment queue for the async generator.
|
|
@@ -868,8 +1382,34 @@ export class FMP4Stream {
|
|
|
868
1382
|
}
|
|
869
1383
|
else {
|
|
870
1384
|
this.fragmentQueue.queue.push(fragment);
|
|
1385
|
+
// Live sources keep producing regardless of consumer speed - bound the
|
|
1386
|
+
// backlog by dropping the oldest media fragments. The init segment never
|
|
1387
|
+
// enters this queue in box mode (kept separately via initSegment).
|
|
1388
|
+
while (this.fragmentQueue.queue.length > this.options.maxQueuedFragments) {
|
|
1389
|
+
this.fragmentQueue.queue.shift();
|
|
1390
|
+
this._droppedFragments++;
|
|
1391
|
+
}
|
|
871
1392
|
}
|
|
872
1393
|
}
|
|
1394
|
+
/**
|
|
1395
|
+
* Reject a pending initSegment promise so consumers never hang.
|
|
1396
|
+
*
|
|
1397
|
+
* No-op when the promise was never requested or has already settled. The
|
|
1398
|
+
* promise is marked as handled to avoid an unhandled rejection when no
|
|
1399
|
+
* consumer is currently awaiting it.
|
|
1400
|
+
*
|
|
1401
|
+
* @param error - Rejection reason (pipeline error or stop-before-init)
|
|
1402
|
+
*
|
|
1403
|
+
* @internal
|
|
1404
|
+
*/
|
|
1405
|
+
rejectInitSegment(error) {
|
|
1406
|
+
if (!this._initSegmentReject)
|
|
1407
|
+
return;
|
|
1408
|
+
this._initSegmentPromise?.catch(() => { });
|
|
1409
|
+
this._initSegmentReject(error);
|
|
1410
|
+
this._initSegmentReject = null;
|
|
1411
|
+
this._initSegmentResolve = null;
|
|
1412
|
+
}
|
|
873
1413
|
/**
|
|
874
1414
|
* Signal the fragment queue that no more fragments will arrive.
|
|
875
1415
|
*
|