node-av 6.1.1 → 6.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/api/audio-frame-buffer.d.ts +73 -1
- package/dist/api/audio-frame-buffer.js +127 -1
- package/dist/api/audio-frame-buffer.js.map +1 -1
- package/dist/api/bitstream-filter.d.ts +82 -4
- package/dist/api/bitstream-filter.js +197 -100
- package/dist/api/bitstream-filter.js.map +1 -1
- package/dist/api/decoder.d.ts +5 -0
- package/dist/api/decoder.js +12 -5
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +10 -1
- package/dist/api/demuxer.js +114 -31
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.d.ts +43 -1
- package/dist/api/encoder-pool.js +113 -9
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +2 -0
- package/dist/api/encoder.js +105 -41
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter-complex.d.ts +16 -10
- package/dist/api/filter-complex.js +129 -81
- package/dist/api/filter-complex.js.map +1 -1
- package/dist/api/filter-presets.d.ts +15 -1
- package/dist/api/filter-presets.js +23 -5
- package/dist/api/filter-presets.js.map +1 -1
- package/dist/api/filter.js +15 -6
- package/dist/api/filter.js.map +1 -1
- package/dist/api/fmp4-stream.d.ts +211 -4
- package/dist/api/fmp4-stream.js +550 -84
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.js +4 -3
- package/dist/api/hardware.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/io-stream.js +61 -5
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +13 -0
- package/dist/api/muxer.js +82 -19
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +122 -0
- package/dist/api/pipeline.js +25 -7
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +146 -1
- package/dist/api/rtp-stream.js +533 -125
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +102 -16
- package/dist/api/scaler.js +316 -106
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/types.d.ts +17 -0
- package/dist/api/types.js +2 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/utilities/async-queue.d.ts +3 -0
- package/dist/api/utilities/async-queue.js +8 -0
- package/dist/api/utilities/async-queue.js.map +1 -1
- package/dist/api/webrtc-stream.d.ts +17 -10
- package/dist/api/webrtc-stream.js +117 -24
- package/dist/api/webrtc-stream.js.map +1 -1
- package/dist/api/whisper.d.ts +2 -0
- package/dist/api/whisper.js +63 -23
- package/dist/api/whisper.js.map +1 -1
- package/dist/ffmpeg/install.js +115 -29
- package/dist/ffmpeg/install.js.map +1 -1
- package/dist/lib/binding.d.ts +1 -0
- package/dist/lib/binding.js.map +1 -1
- package/dist/lib/codec-context.d.ts +0 -2
- package/dist/lib/codec-context.js +4 -22
- package/dist/lib/codec-context.js.map +1 -1
- package/dist/lib/filter-context.d.ts +0 -1
- package/dist/lib/filter-context.js +2 -11
- package/dist/lib/filter-context.js.map +1 -1
- package/dist/lib/format-context.d.ts +45 -4
- package/dist/lib/format-context.js +50 -16
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +12 -12
- package/dist/lib/frame.js +13 -20
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/hardware-frames-context.d.ts +0 -1
- package/dist/lib/hardware-frames-context.js +2 -8
- package/dist/lib/hardware-frames-context.js.map +1 -1
- package/dist/lib/native-types.d.ts +2 -2
- package/dist/lib/option.d.ts +26 -0
- package/dist/lib/option.js +42 -18
- package/dist/lib/option.js.map +1 -1
- package/dist/lib/packet.d.ts +7 -1
- package/dist/lib/packet.js +9 -2
- package/dist/lib/packet.js.map +1 -1
- package/dist/lib/stream.d.ts +3 -4
- package/dist/lib/stream.js +17 -24
- package/dist/lib/stream.js.map +1 -1
- package/dist/webrtc/index.d.ts +1 -0
- package/dist/webrtc/index.js.map +1 -1
- package/install/check.js +26 -63
- package/package.json +36 -19
- package/build_mac_local.sh +0 -69
package/dist/api/fmp4-stream.js
CHANGED
|
@@ -1,7 +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';
|
|
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_FLTP, } from '../constants/constants.js';
|
|
2
2
|
import { FF_ENCODER_AAC, FF_ENCODER_LIBX264 } from '../constants/encoders.js';
|
|
3
3
|
import { Codec } from '../lib/codec.js';
|
|
4
|
-
import {
|
|
4
|
+
import { Rational } from '../lib/rational.js';
|
|
5
|
+
import { avGetCodecString, avGetPixFmtName } from '../lib/utilities.js';
|
|
5
6
|
import { Decoder } from './decoder.js';
|
|
6
7
|
import { Demuxer } from './demuxer.js';
|
|
7
8
|
import { Encoder } from './encoder.js';
|
|
@@ -9,7 +10,8 @@ import { FilterPreset } from './filter-presets.js';
|
|
|
9
10
|
import { FilterAPI } from './filter.js';
|
|
10
11
|
import { HardwareContext } from './hardware.js';
|
|
11
12
|
import { Muxer } from './muxer.js';
|
|
12
|
-
import { pipeline } from './pipeline.js';
|
|
13
|
+
import { consumeStreamInParallel, pipeline, PipelineControlImpl } from './pipeline.js';
|
|
14
|
+
import { pickSupportedPixelFormat } from './utilities/codec-format.js';
|
|
13
15
|
/**
|
|
14
16
|
* Target codec strings for fMP4 streaming.
|
|
15
17
|
*/
|
|
@@ -68,6 +70,7 @@ export class FMP4Stream {
|
|
|
68
70
|
inputUrl;
|
|
69
71
|
inputOptions;
|
|
70
72
|
input;
|
|
73
|
+
source;
|
|
71
74
|
output;
|
|
72
75
|
hardwareContext;
|
|
73
76
|
videoDecoder;
|
|
@@ -77,12 +80,20 @@ export class FMP4Stream {
|
|
|
77
80
|
audioFilter;
|
|
78
81
|
audioEncoder;
|
|
79
82
|
pipeline;
|
|
83
|
+
startPromise;
|
|
80
84
|
signal;
|
|
81
85
|
supportedCodecs;
|
|
82
86
|
incompleteBoxBuffer = null;
|
|
87
|
+
pendingFragment = [];
|
|
88
|
+
abortHandler;
|
|
89
|
+
stopRequested = false;
|
|
90
|
+
stopPromise;
|
|
91
|
+
startAbort;
|
|
83
92
|
fragmentQueue = null;
|
|
93
|
+
_droppedFragments = 0;
|
|
84
94
|
_initSegment = null;
|
|
85
95
|
_initSegmentResolve = null;
|
|
96
|
+
_initSegmentReject = null;
|
|
86
97
|
_initSegmentPromise = null;
|
|
87
98
|
_ftypData = null;
|
|
88
99
|
_moovData = null;
|
|
@@ -99,18 +110,20 @@ export class FMP4Stream {
|
|
|
99
110
|
if (typeof input === 'string') {
|
|
100
111
|
this.inputUrl = input;
|
|
101
112
|
}
|
|
102
|
-
else {
|
|
113
|
+
else if (input instanceof Demuxer) {
|
|
103
114
|
this.inputUrl = null;
|
|
104
115
|
this.input = input;
|
|
105
116
|
}
|
|
117
|
+
else {
|
|
118
|
+
this.inputUrl = null;
|
|
119
|
+
this.source = input;
|
|
120
|
+
}
|
|
106
121
|
const inputUrl = this.inputUrl ?? '';
|
|
122
|
+
const isLiveInput = /^(rtsp|rtmp|rtp|udp|srt|tcp|sctp):/i.test(inputUrl);
|
|
107
123
|
this.inputOptions = {
|
|
108
124
|
...options.inputOptions,
|
|
109
125
|
options: {
|
|
110
|
-
flags: 'low_delay',
|
|
111
|
-
fflags: 'nobuffer',
|
|
112
|
-
// analyzeduration: 0,
|
|
113
|
-
// probesize: 32,
|
|
126
|
+
...(isLiveInput ? { flags: 'low_delay', fflags: 'nobuffer' } : {}),
|
|
114
127
|
timeout: 10000000,
|
|
115
128
|
rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
|
|
116
129
|
...options.inputOptions?.options,
|
|
@@ -127,6 +140,7 @@ export class FMP4Stream {
|
|
|
127
140
|
fps: options.video?.fps,
|
|
128
141
|
width: options.video?.width,
|
|
129
142
|
height: options.video?.height,
|
|
143
|
+
bitrate: options.video?.bitrate,
|
|
130
144
|
encoderOptions: options.video?.encoderOptions ?? {},
|
|
131
145
|
},
|
|
132
146
|
audio: {
|
|
@@ -134,6 +148,7 @@ export class FMP4Stream {
|
|
|
134
148
|
},
|
|
135
149
|
bufferSize: options.bufferSize ?? 2 * 1024 * 1024,
|
|
136
150
|
boxMode: options.boxMode ?? false,
|
|
151
|
+
maxQueuedFragments: options.maxQueuedFragments ?? 16,
|
|
137
152
|
movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
|
|
138
153
|
};
|
|
139
154
|
this.signal = options.signal;
|
|
@@ -182,6 +197,8 @@ export class FMP4Stream {
|
|
|
182
197
|
*
|
|
183
198
|
* Only available when boxMode is enabled.
|
|
184
199
|
* Resolves once the first ftyp and moov boxes have been received.
|
|
200
|
+
* Rejects if the stream fails or is stopped before the init segment
|
|
201
|
+
* was produced, so awaiting it never hangs.
|
|
185
202
|
*
|
|
186
203
|
* @throws {Error} If boxMode is not enabled
|
|
187
204
|
*/
|
|
@@ -189,16 +206,39 @@ export class FMP4Stream {
|
|
|
189
206
|
if (!this.options.boxMode) {
|
|
190
207
|
throw new Error('initSegment is only available in box mode');
|
|
191
208
|
}
|
|
192
|
-
this._initSegmentPromise ??= new Promise((resolve) => {
|
|
209
|
+
this._initSegmentPromise ??= new Promise((resolve, reject) => {
|
|
193
210
|
if (this._initSegment) {
|
|
194
211
|
resolve(this._initSegment);
|
|
195
212
|
}
|
|
196
213
|
else {
|
|
197
214
|
this._initSegmentResolve = resolve;
|
|
215
|
+
this._initSegmentReject = reject;
|
|
198
216
|
}
|
|
199
217
|
});
|
|
200
218
|
return this._initSegmentPromise;
|
|
201
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Number of media fragments dropped due to consumer backpressure.
|
|
222
|
+
*
|
|
223
|
+
* Incremented whenever the {@link fragments} backlog exceeds
|
|
224
|
+
* `maxQueuedFragments` and the oldest fragment is discarded.
|
|
225
|
+
* Reset to 0 when the stream is (re)started.
|
|
226
|
+
*
|
|
227
|
+
* @returns Total dropped fragments for the current run
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```typescript
|
|
231
|
+
* for await (const fragment of stream.fragments()) {
|
|
232
|
+
* await sendToClient(fragment.data);
|
|
233
|
+
* if (stream.droppedFragments > 0) {
|
|
234
|
+
* console.warn(`Consumer too slow: ${stream.droppedFragments} fragments dropped`);
|
|
235
|
+
* }
|
|
236
|
+
* }
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
get droppedFragments() {
|
|
240
|
+
return this._droppedFragments;
|
|
241
|
+
}
|
|
202
242
|
/**
|
|
203
243
|
* Get the demuxer instance.
|
|
204
244
|
*
|
|
@@ -240,6 +280,18 @@ export class FMP4Stream {
|
|
|
240
280
|
* ```
|
|
241
281
|
*/
|
|
242
282
|
getCodecString() {
|
|
283
|
+
// Frame-source path: there is no input codec - frames are always encoded
|
|
284
|
+
// to the fMP4 target codecs.
|
|
285
|
+
if (this.source) {
|
|
286
|
+
const parts = [];
|
|
287
|
+
if (this.source.video) {
|
|
288
|
+
parts.push(FMP4_CODECS.H264);
|
|
289
|
+
}
|
|
290
|
+
if (this.source.audio) {
|
|
291
|
+
parts.push(FMP4_CODECS.AAC);
|
|
292
|
+
}
|
|
293
|
+
return parts.join(',');
|
|
294
|
+
}
|
|
243
295
|
if (!this.input) {
|
|
244
296
|
throw new Error('Input not opened. Call start() first to open the input.');
|
|
245
297
|
}
|
|
@@ -331,18 +383,54 @@ export class FMP4Stream {
|
|
|
331
383
|
* ```
|
|
332
384
|
*/
|
|
333
385
|
async start() {
|
|
386
|
+
this.startPromise ??= this.doStart().catch((error) => {
|
|
387
|
+
this.startPromise = undefined;
|
|
388
|
+
// A pending initSegment consumer must not hang when startup fails.
|
|
389
|
+
this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
|
|
390
|
+
throw error;
|
|
391
|
+
});
|
|
392
|
+
await this.startPromise;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Perform the actual startup (see {@link start}).
|
|
396
|
+
*
|
|
397
|
+
* @internal
|
|
398
|
+
*/
|
|
399
|
+
async doStart() {
|
|
400
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
401
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
402
|
+
if (this.stopPromise) {
|
|
403
|
+
await this.stopPromise;
|
|
404
|
+
}
|
|
334
405
|
if (this.pipeline) {
|
|
335
406
|
return;
|
|
336
407
|
}
|
|
337
408
|
this.signal?.throwIfAborted();
|
|
338
|
-
|
|
409
|
+
// Store the handler so stop() can remove it - otherwise listeners would
|
|
410
|
+
// accumulate across restarts. Teardown errors are swallowed here: they are
|
|
411
|
+
// already surfaced via onClose(error) by the completion handler.
|
|
412
|
+
this.abortHandler = () => {
|
|
413
|
+
this.stop().catch(() => { });
|
|
414
|
+
};
|
|
415
|
+
this.signal?.addEventListener('abort', this.abortHandler, { once: true });
|
|
416
|
+
this._droppedFragments = 0;
|
|
417
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
418
|
+
// (an RTSP connect can take seconds).
|
|
419
|
+
this.startAbort = new AbortController();
|
|
420
|
+
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
421
|
+
if (this.source) {
|
|
422
|
+
await this.startFromFrames();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
339
425
|
// Open input if not already open
|
|
340
426
|
if (!this.input) {
|
|
341
427
|
if (!this.inputUrl) {
|
|
342
428
|
throw new Error('No input URL or Demuxer provided');
|
|
343
429
|
}
|
|
344
|
-
this.
|
|
430
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
431
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
345
432
|
}
|
|
433
|
+
this.throwIfStopRequested();
|
|
346
434
|
const videoStream = this.input.video();
|
|
347
435
|
const audioStream = this.input.audio();
|
|
348
436
|
// Check if video needs transcoding
|
|
@@ -360,6 +448,7 @@ export class FMP4Stream {
|
|
|
360
448
|
hardware: this.hardwareContext,
|
|
361
449
|
exitOnError: false,
|
|
362
450
|
});
|
|
451
|
+
this.throwIfStopRequested();
|
|
363
452
|
// Determine if we need filters by comparing with current stream properties
|
|
364
453
|
const currentWidth = videoStream.codecpar.width;
|
|
365
454
|
const currentHeight = videoStream.codecpar.height;
|
|
@@ -367,6 +456,7 @@ export class FMP4Stream {
|
|
|
367
456
|
const needsScale = (this.options.video.width !== undefined && this.options.video.width !== currentWidth) ||
|
|
368
457
|
(this.options.video.height !== undefined && this.options.video.height !== currentHeight);
|
|
369
458
|
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
459
|
+
const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
370
460
|
// Create filter chain only if needed
|
|
371
461
|
if (needsScale || needsFps) {
|
|
372
462
|
const filterChain = FilterPreset.chain(this.hardwareContext);
|
|
@@ -380,13 +470,28 @@ export class FMP4Stream {
|
|
|
380
470
|
if (needsFps) {
|
|
381
471
|
filterChain.filter('fps', { fps: this.options.video.fps });
|
|
382
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
|
+
}
|
|
383
489
|
this.videoFilter = FilterAPI.create(filterChain.build(), {
|
|
384
490
|
hardware: this.hardwareContext,
|
|
385
491
|
});
|
|
386
492
|
}
|
|
387
|
-
const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
388
493
|
let encoderOptions = {};
|
|
389
|
-
if (encoderCodec.name === FF_ENCODER_LIBX264
|
|
494
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
390
495
|
encoderOptions.preset = 'ultrafast';
|
|
391
496
|
encoderOptions.tune = 'zerolatency';
|
|
392
497
|
}
|
|
@@ -394,10 +499,19 @@ export class FMP4Stream {
|
|
|
394
499
|
...encoderOptions,
|
|
395
500
|
...this.options.video.encoderOptions,
|
|
396
501
|
};
|
|
502
|
+
const effectiveFps = this.options.video.fps ?? currentFps;
|
|
503
|
+
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
|
+
const bitrate = this.options.video.bitrate;
|
|
397
506
|
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
398
507
|
decoder: this.videoDecoder,
|
|
399
508
|
options: encoderOptions,
|
|
509
|
+
configure: (ctx) => {
|
|
510
|
+
ctx.gopSize = Math.max(1, Math.round(fpsForGop * fragSeconds));
|
|
511
|
+
this.applyBitrate(ctx, bitrate);
|
|
512
|
+
},
|
|
400
513
|
});
|
|
514
|
+
this.throwIfStopRequested();
|
|
401
515
|
}
|
|
402
516
|
// Check if audio needs transcoding
|
|
403
517
|
const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
|
|
@@ -414,8 +528,65 @@ export class FMP4Stream {
|
|
|
414
528
|
filter: this.audioFilter,
|
|
415
529
|
options: this.options.audio.encoderOptions,
|
|
416
530
|
});
|
|
531
|
+
this.throwIfStopRequested();
|
|
417
532
|
}
|
|
418
533
|
// Setup output with callback
|
|
534
|
+
this.output = await this.createOutput();
|
|
535
|
+
this.throwIfStopRequested();
|
|
536
|
+
this.attachCompletion(this.runPipeline());
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Abort an in-flight startup once stop() was requested.
|
|
540
|
+
*
|
|
541
|
+
* Checked after every await in the startup path: the resources created so
|
|
542
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
543
|
+
* this startup tears them down the moment it unwinds.
|
|
544
|
+
*
|
|
545
|
+
* @throws {Error} If stop() was called while starting
|
|
546
|
+
*
|
|
547
|
+
* @internal
|
|
548
|
+
*/
|
|
549
|
+
throwIfStopRequested() {
|
|
550
|
+
if (this.stopRequested) {
|
|
551
|
+
throw new Error('FMP4Stream stopped during start');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Cap the video bitrate on the encoder context.
|
|
556
|
+
*
|
|
557
|
+
* Applies a VBV rate cap (maxrate + a 1s buffer) WITHOUT setting a target
|
|
558
|
+
* bitrate, so the encoder keeps its default constant-quality mode but can
|
|
559
|
+
* never exceed the negotiated ceiling. Setting the bitrate as an ABR target
|
|
560
|
+
* instead would push a normally-lighter stream UP to fill it, overshooting on
|
|
561
|
+
* bursts - the opposite of what strict rate-capped consumers want.
|
|
562
|
+
* No-op when no bitrate was requested.
|
|
563
|
+
*
|
|
564
|
+
* @param ctx - Encoder codec context
|
|
565
|
+
*
|
|
566
|
+
* @param bitrate - Maximum bitrate in bits per second, or undefined
|
|
567
|
+
*
|
|
568
|
+
* @internal
|
|
569
|
+
*/
|
|
570
|
+
applyBitrate(ctx, bitrate) {
|
|
571
|
+
if (!bitrate || bitrate <= 0) {
|
|
572
|
+
return;
|
|
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);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Create the fMP4 output muxer with the fragment-emitting write callback.
|
|
581
|
+
*
|
|
582
|
+
* Uses `this.input` when present (demuxer path) and no input for the
|
|
583
|
+
* frame-source path.
|
|
584
|
+
*
|
|
585
|
+
* @returns The configured muxer
|
|
586
|
+
*
|
|
587
|
+
* @internal
|
|
588
|
+
*/
|
|
589
|
+
async createOutput() {
|
|
419
590
|
const cb = {
|
|
420
591
|
write: (buffer) => {
|
|
421
592
|
if (this.options.boxMode) {
|
|
@@ -431,7 +602,7 @@ export class FMP4Stream {
|
|
|
431
602
|
return buffer.length;
|
|
432
603
|
},
|
|
433
604
|
};
|
|
434
|
-
|
|
605
|
+
return await Muxer.open(cb, {
|
|
435
606
|
input: this.input,
|
|
436
607
|
format: 'mp4',
|
|
437
608
|
bufferSize: this.options.bufferSize,
|
|
@@ -441,17 +612,140 @@ export class FMP4Stream {
|
|
|
441
612
|
frag_duration: this.options.fragDuration,
|
|
442
613
|
},
|
|
443
614
|
});
|
|
444
|
-
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Wire the pipeline completion to the fragment/close callbacks.
|
|
618
|
+
*
|
|
619
|
+
* On success ends the fragment iterator and invokes `onClose()`; on error
|
|
620
|
+
* stops the stream and invokes `onClose(error)`. Runs in the background.
|
|
621
|
+
*
|
|
622
|
+
* @param completion - The pipeline completion promise
|
|
623
|
+
*
|
|
624
|
+
* @internal
|
|
625
|
+
*/
|
|
626
|
+
attachCompletion(completion) {
|
|
627
|
+
completion
|
|
445
628
|
.then(() => {
|
|
446
629
|
this.endFragments();
|
|
630
|
+
// No-op in the normal case (init segment resolved long ago) - only
|
|
631
|
+
// fires if the stream ended without ever emitting ftyp+moov.
|
|
632
|
+
this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
|
|
447
633
|
this.options.onClose?.();
|
|
448
634
|
})
|
|
449
635
|
.catch(async (error) => {
|
|
450
636
|
this.endFragments();
|
|
637
|
+
// Reject with the pipeline error so an initSegment consumer sees the
|
|
638
|
+
// actual failure instead of the generic "stopped" error from stop().
|
|
639
|
+
this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
|
|
640
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
641
|
+
// the stream down - the pipeline unwinding with an AbortError is the
|
|
642
|
+
// expected shutdown path, not a failure.
|
|
643
|
+
if (this.isDeliberateStop(error)) {
|
|
644
|
+
await this.stop().catch(() => { });
|
|
645
|
+
this.options.onClose?.();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
451
648
|
await this.stop();
|
|
452
649
|
this.options.onClose?.(error);
|
|
453
650
|
});
|
|
454
651
|
}
|
|
652
|
+
/**
|
|
653
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
654
|
+
*
|
|
655
|
+
* @param error - The pipeline rejection reason
|
|
656
|
+
*
|
|
657
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
658
|
+
*
|
|
659
|
+
* @internal
|
|
660
|
+
*/
|
|
661
|
+
isDeliberateStop(error) {
|
|
662
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Set up encoders and the fMP4 muxer for a pre-composited frame source and
|
|
666
|
+
* start encoding. No demuxer or decoder is involved - the provided frames are
|
|
667
|
+
* encoded straight into the fragmented container. Both streams share the ONE
|
|
668
|
+
* muxer, so neither may close it on its own: each source is encoded to packets
|
|
669
|
+
* and written concurrently (the muxer interleaves), and the muxer is closed
|
|
670
|
+
* once all streams have finished.
|
|
671
|
+
*
|
|
672
|
+
* @throws {Error} If the source has neither video nor audio frames
|
|
673
|
+
*
|
|
674
|
+
* @internal
|
|
675
|
+
*/
|
|
676
|
+
async startFromFrames() {
|
|
677
|
+
const source = this.source;
|
|
678
|
+
if (!source.video && !source.audio) {
|
|
679
|
+
throw new Error('Frame source must provide video and/or audio frames');
|
|
680
|
+
}
|
|
681
|
+
// Video encoder - the encoder adopts width/height/pixelFormat/timeBase from
|
|
682
|
+
// the first composited frame.
|
|
683
|
+
if (source.video) {
|
|
684
|
+
if (this.options.hardware === 'auto') {
|
|
685
|
+
this.hardwareContext = HardwareContext.auto();
|
|
686
|
+
}
|
|
687
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
688
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
689
|
+
}
|
|
690
|
+
const encoderCodec = this.hardwareContext?.getEncoderCodec('h264') ?? Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
691
|
+
let encoderOptions = {};
|
|
692
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
693
|
+
encoderOptions.preset = 'ultrafast';
|
|
694
|
+
encoderOptions.tune = 'zerolatency';
|
|
695
|
+
}
|
|
696
|
+
encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
|
|
697
|
+
let fps = this.options.video.fps ?? 30;
|
|
698
|
+
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
699
|
+
fps = 30;
|
|
700
|
+
}
|
|
701
|
+
// Set the framerate explicitly - without a decoder the encoder would infer
|
|
702
|
+
// it from the frame timebase and pick an absurd level. Bound the GOP to the
|
|
703
|
+
// fragment duration (2s default) so every fMP4 fragment starts with a
|
|
704
|
+
// keyframe; otherwise frag_duration flushes fragments mid-GOP on a P-frame
|
|
705
|
+
// and strict consumers reject them.
|
|
706
|
+
const fragSeconds = this.options.fragDuration && this.options.fragDuration > 0 ? this.options.fragDuration / 1_000_000 : 2;
|
|
707
|
+
const bitrate = this.options.video.bitrate;
|
|
708
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
709
|
+
options: encoderOptions,
|
|
710
|
+
configure: (ctx) => {
|
|
711
|
+
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
712
|
+
ctx.gopSize = Math.max(1, Math.round(fps * fragSeconds));
|
|
713
|
+
this.applyBitrate(ctx, bitrate);
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// Audio encoder (AAC), resampling via aformat like the demuxer path.
|
|
718
|
+
if (source.audio) {
|
|
719
|
+
const filterChain = FilterPreset.chain().aformat(AV_SAMPLE_FMT_FLTP, 44100, 'stereo').build();
|
|
720
|
+
this.audioFilter = FilterAPI.create(filterChain);
|
|
721
|
+
this.audioEncoder = await Encoder.create(FF_ENCODER_AAC, {
|
|
722
|
+
filter: this.audioFilter,
|
|
723
|
+
options: this.options.audio.encoderOptions,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
this.throwIfStopRequested();
|
|
727
|
+
this.output = await this.createOutput();
|
|
728
|
+
this.throwIfStopRequested();
|
|
729
|
+
// eslint-disable-next-line prefer-const
|
|
730
|
+
let control;
|
|
731
|
+
const shouldStop = () => control?.isStopped() ?? false;
|
|
732
|
+
const tasks = [];
|
|
733
|
+
if (source.video && this.videoEncoder) {
|
|
734
|
+
const streamIndex = this.output.addStream(this.videoEncoder);
|
|
735
|
+
tasks.push(consumeStreamInParallel(this.videoEncoder.packets(source.video), this.output, streamIndex, shouldStop));
|
|
736
|
+
}
|
|
737
|
+
if (source.audio && this.audioFilter && this.audioEncoder) {
|
|
738
|
+
const streamIndex = this.output.addStream(this.audioEncoder);
|
|
739
|
+
tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(source.audio)), this.output, streamIndex, shouldStop));
|
|
740
|
+
}
|
|
741
|
+
// Close the shared muxer only after ALL streams are done (flushes the trailer).
|
|
742
|
+
const run = Promise.all(tasks).then(async () => {
|
|
743
|
+
await this.output?.close();
|
|
744
|
+
});
|
|
745
|
+
control = new PipelineControlImpl(run, this.signal);
|
|
746
|
+
this.pipeline = control;
|
|
747
|
+
this.attachCompletion(this.pipeline.completion);
|
|
748
|
+
}
|
|
455
749
|
/**
|
|
456
750
|
* Stop streaming gracefully and clean up all resources.
|
|
457
751
|
*
|
|
@@ -471,37 +765,94 @@ export class FMP4Stream {
|
|
|
471
765
|
* ```
|
|
472
766
|
*/
|
|
473
767
|
async stop() {
|
|
474
|
-
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
this.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
this.
|
|
492
|
-
this.
|
|
493
|
-
this.
|
|
494
|
-
this.
|
|
495
|
-
|
|
496
|
-
this.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
768
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
769
|
+
// double-closing native resources.
|
|
770
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
771
|
+
this.stopPromise = undefined;
|
|
772
|
+
});
|
|
773
|
+
await this.stopPromise;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Perform the actual teardown (see {@link stop}).
|
|
777
|
+
*
|
|
778
|
+
* @internal
|
|
779
|
+
*/
|
|
780
|
+
async doStop() {
|
|
781
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
782
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
783
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
784
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
785
|
+
this.stopRequested = true;
|
|
786
|
+
this.startAbort?.abort();
|
|
787
|
+
this.startAbort = undefined;
|
|
788
|
+
const inflightStart = this.startPromise;
|
|
789
|
+
// Allow a fresh start() after stopping.
|
|
790
|
+
this.startPromise = undefined;
|
|
791
|
+
if (inflightStart) {
|
|
792
|
+
try {
|
|
793
|
+
await inflightStart;
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
this.endFragments();
|
|
801
|
+
// A consumer awaiting the init segment must not hang forever.
|
|
802
|
+
this.rejectInitSegment(new Error('FMP4Stream stopped before init segment was produced'));
|
|
803
|
+
// Remove the abort listener so it does not accumulate across restarts.
|
|
804
|
+
if (this.abortHandler) {
|
|
805
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
806
|
+
this.abortHandler = undefined;
|
|
807
|
+
}
|
|
808
|
+
// Stop pipeline if running and wait for completion. Swallow a rejected
|
|
809
|
+
// completion - the error is already surfaced via the onClose(error) path;
|
|
810
|
+
// we just need cleanup to proceed.
|
|
811
|
+
if (this.pipeline && !this.pipeline.isStopped()) {
|
|
812
|
+
this.pipeline.stop();
|
|
813
|
+
try {
|
|
814
|
+
await this.pipeline.completion;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
818
|
+
}
|
|
819
|
+
this.pipeline = undefined;
|
|
820
|
+
}
|
|
821
|
+
// Close all resources. The output goes FIRST: its async write worker may
|
|
822
|
+
// still be draining (header init reads the input's stream parameters),
|
|
823
|
+
// so freeing the input before the muxer has fully settled is a
|
|
824
|
+
// use-after-free on the worker thread.
|
|
825
|
+
await this.output?.close();
|
|
826
|
+
this.output = undefined;
|
|
827
|
+
this.videoDecoder?.close();
|
|
828
|
+
this.videoDecoder = undefined;
|
|
829
|
+
this.videoFilter?.close();
|
|
830
|
+
this.videoFilter = undefined;
|
|
831
|
+
this.videoEncoder?.close();
|
|
832
|
+
this.videoEncoder = undefined;
|
|
833
|
+
this.audioDecoder?.close();
|
|
834
|
+
this.audioDecoder = undefined;
|
|
835
|
+
this.audioFilter?.close();
|
|
836
|
+
this.audioFilter = undefined;
|
|
837
|
+
this.audioEncoder?.close();
|
|
838
|
+
this.audioEncoder = undefined;
|
|
839
|
+
this.hardwareContext?.dispose();
|
|
840
|
+
this.hardwareContext = undefined;
|
|
841
|
+
await this.input?.close();
|
|
842
|
+
this.input = undefined;
|
|
843
|
+
// Reset parser and init segment state so a restart parses a clean stream.
|
|
844
|
+
this.incompleteBoxBuffer = null;
|
|
845
|
+
this.pendingFragment = [];
|
|
846
|
+
this._initSegment = null;
|
|
847
|
+
this._initSegmentResolve = null;
|
|
848
|
+
this._initSegmentReject = null;
|
|
849
|
+
this._initSegmentPromise = null;
|
|
850
|
+
this._ftypData = null;
|
|
851
|
+
this._moovData = null;
|
|
852
|
+
}
|
|
853
|
+
finally {
|
|
854
|
+
this.stopRequested = false;
|
|
855
|
+
}
|
|
505
856
|
}
|
|
506
857
|
/**
|
|
507
858
|
* Async generator that yields media fragments (moof+mdat chunks).
|
|
@@ -514,8 +865,16 @@ export class FMP4Stream {
|
|
|
514
865
|
*
|
|
515
866
|
* The generator completes when the stream stops or the pipeline ends.
|
|
516
867
|
*
|
|
868
|
+
* Only a single consumer is supported: a second concurrent fragments() call
|
|
869
|
+
* would silently strand the first consumer, so it throws instead. The
|
|
870
|
+
* backlog is bounded by `maxQueuedFragments` - if the consumer falls behind
|
|
871
|
+
* a live source, the oldest fragments are dropped (see
|
|
872
|
+
* {@link droppedFragments}).
|
|
873
|
+
*
|
|
517
874
|
* @yields {FMP4Fragment} Media fragment with data buffer and box info
|
|
518
875
|
*
|
|
876
|
+
* @throws {Error} If another fragments() iterator is already active
|
|
877
|
+
*
|
|
519
878
|
* @example
|
|
520
879
|
* ```typescript
|
|
521
880
|
* const stream = FMP4Stream.create('rtsp://camera/stream', {
|
|
@@ -532,6 +891,9 @@ export class FMP4Stream {
|
|
|
532
891
|
* ```
|
|
533
892
|
*/
|
|
534
893
|
async *fragments() {
|
|
894
|
+
if (this.fragmentQueue) {
|
|
895
|
+
throw new Error('fragments() supports only a single consumer; the previous iterator is still active');
|
|
896
|
+
}
|
|
535
897
|
this.fragmentQueue = { queue: [], resolve: null, done: false };
|
|
536
898
|
try {
|
|
537
899
|
while (true) {
|
|
@@ -634,6 +996,18 @@ export class FMP4Stream {
|
|
|
634
996
|
/**
|
|
635
997
|
* Process buffer in box mode - buffers until complete boxes are available.
|
|
636
998
|
*
|
|
999
|
+
* The muxer splits writes at arbitrary positions, so a chunk may end
|
|
1000
|
+
* anywhere - including inside a box header. Any trailing partial box
|
|
1001
|
+
* (header or body) is buffered for the next chunk; the parser never
|
|
1002
|
+
* loses bytes regardless of where the split falls.
|
|
1003
|
+
*
|
|
1004
|
+
* Emission is fragment-aligned: a `moof` is held back until its `mdat` has
|
|
1005
|
+
* arrived, then both are emitted as ONE onData call. The avio buffer flushes
|
|
1006
|
+
* at arbitrary points (a fragment larger than the buffer always splits), so
|
|
1007
|
+
* without this a consumer would see a bare `moof` followed by a detached
|
|
1008
|
+
* `mdat` - unplayable framing for fMP4 consumers.
|
|
1009
|
+
* Non-fragment boxes (ftyp, moov, mfra, ...) are emitted as they complete.
|
|
1010
|
+
*
|
|
637
1011
|
* @param chunk - Incoming data chunk from FFmpeg
|
|
638
1012
|
*
|
|
639
1013
|
* @internal
|
|
@@ -645,60 +1019,126 @@ export class FMP4Stream {
|
|
|
645
1019
|
this.incompleteBoxBuffer = null;
|
|
646
1020
|
}
|
|
647
1021
|
let offset = 0;
|
|
648
|
-
|
|
649
|
-
|
|
1022
|
+
let immediate = [];
|
|
1023
|
+
const flushImmediate = () => {
|
|
1024
|
+
if (immediate.length > 0) {
|
|
1025
|
+
this.emitBoxGroup(immediate);
|
|
1026
|
+
immediate = [];
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
while (offset < chunk.length) {
|
|
1030
|
+
const remaining = chunk.length - offset;
|
|
1031
|
+
// Partial 32-bit header - buffer the tail for the next chunk. Dropping
|
|
1032
|
+
// it would desynchronize the parser for the rest of the stream.
|
|
1033
|
+
if (remaining < 8) {
|
|
1034
|
+
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
650
1037
|
// Read box header
|
|
651
|
-
const
|
|
652
|
-
|
|
1038
|
+
const size32 = chunk.readUInt32BE(offset);
|
|
1039
|
+
let headerSize = 8;
|
|
1040
|
+
let boxSize = size32;
|
|
1041
|
+
if (size32 === 1) {
|
|
1042
|
+
// 64-bit box: the actual size follows the type field as a 64-bit
|
|
1043
|
+
// largesize (16-byte header total). movenc emits this for mdat > 4 GB.
|
|
1044
|
+
if (remaining < 16) {
|
|
1045
|
+
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
headerSize = 16;
|
|
1049
|
+
boxSize = Number(chunk.readBigUInt64BE(offset + 8));
|
|
1050
|
+
}
|
|
1051
|
+
// Safety check: invalid box size. size==0 ("box extends to end of file")
|
|
1052
|
+
// cannot be framed in a live stream and any size below the header size
|
|
1053
|
+
// is corrupt - recovery is impossible, so drop the rest of this chunk
|
|
1054
|
+
// rather than emit garbage framing downstream.
|
|
1055
|
+
if (boxSize < headerSize) {
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
653
1058
|
// Check if we have the complete box
|
|
654
1059
|
if (offset + boxSize > chunk.length) {
|
|
655
|
-
// Box is incomplete - save for next chunk
|
|
1060
|
+
// Box body is incomplete - save the whole box for the next chunk
|
|
656
1061
|
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
657
1062
|
break;
|
|
658
1063
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
type: boxType,
|
|
1064
|
+
const parsed = {
|
|
1065
|
+
type: chunk.toString('ascii', offset + 4, offset + 8),
|
|
662
1066
|
size: boxSize,
|
|
663
|
-
|
|
664
|
-
|
|
1067
|
+
headerSize,
|
|
1068
|
+
raw: chunk.subarray(offset, offset + boxSize),
|
|
665
1069
|
};
|
|
666
|
-
|
|
1070
|
+
if (parsed.type === 'moof') {
|
|
1071
|
+
flushImmediate();
|
|
1072
|
+
if (this.pendingFragment.length > 0) {
|
|
1073
|
+
// a moof without a following mdat never happens with movenc, emit
|
|
1074
|
+
// the stale fragment as-is rather than silently dropping bytes
|
|
1075
|
+
this.emitBoxGroup(this.pendingFragment);
|
|
1076
|
+
this.pendingFragment = [];
|
|
1077
|
+
}
|
|
1078
|
+
this.pendingFragment.push(parsed);
|
|
1079
|
+
}
|
|
1080
|
+
else if (this.pendingFragment.length > 0) {
|
|
1081
|
+
this.pendingFragment.push(parsed);
|
|
1082
|
+
if (parsed.type === 'mdat') {
|
|
1083
|
+
this.emitBoxGroup(this.pendingFragment);
|
|
1084
|
+
this.pendingFragment = [];
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
immediate.push(parsed);
|
|
1089
|
+
}
|
|
667
1090
|
// Move to next box
|
|
668
1091
|
offset += boxSize;
|
|
669
|
-
// Safety check: invalid box size
|
|
670
|
-
if (boxSize < 8) {
|
|
671
|
-
break;
|
|
672
|
-
}
|
|
673
1092
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1093
|
+
flushImmediate();
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Emit a group of complete boxes as one onData call.
|
|
1097
|
+
*
|
|
1098
|
+
* Concatenates the raw box bytes, rebuilds the box list with offsets
|
|
1099
|
+
* relative to the emitted buffer, tracks the init segment, and feeds the
|
|
1100
|
+
* fragments() queue when the group is a media fragment.
|
|
1101
|
+
*
|
|
1102
|
+
* @param entries - Complete boxes in stream order
|
|
1103
|
+
*
|
|
1104
|
+
* @internal
|
|
1105
|
+
*/
|
|
1106
|
+
emitBoxGroup(entries) {
|
|
1107
|
+
const data = entries.length === 1 ? entries[0].raw : Buffer.concat(entries.map((e) => e.raw));
|
|
1108
|
+
let position = 0;
|
|
1109
|
+
const boxes = entries.map((entry) => {
|
|
1110
|
+
const box = {
|
|
1111
|
+
type: entry.type,
|
|
1112
|
+
size: entry.size,
|
|
1113
|
+
data: data.subarray(position + entry.headerSize, position + entry.size),
|
|
1114
|
+
offset: position,
|
|
1115
|
+
};
|
|
1116
|
+
position += entry.size;
|
|
1117
|
+
return box;
|
|
1118
|
+
});
|
|
1119
|
+
const info = { isComplete: true, boxes };
|
|
1120
|
+
this.options.onData(data, info);
|
|
1121
|
+
// Init segment tracking: collect ftyp and moov data
|
|
1122
|
+
if (!this._initSegment) {
|
|
1123
|
+
for (const box of boxes) {
|
|
1124
|
+
if (box.type === 'ftyp' && !this._ftypData) {
|
|
1125
|
+
this._ftypData = data.subarray(box.offset, box.offset + box.size);
|
|
689
1126
|
}
|
|
690
|
-
if (
|
|
691
|
-
this.
|
|
692
|
-
this._initSegmentResolve?.(this._initSegment);
|
|
693
|
-
this._initSegmentResolve = null;
|
|
1127
|
+
if (box.type === 'moov' && !this._moovData) {
|
|
1128
|
+
this._moovData = data.subarray(box.offset, box.offset + box.size);
|
|
694
1129
|
}
|
|
695
1130
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
this.
|
|
1131
|
+
if (this._ftypData && this._moovData) {
|
|
1132
|
+
this._initSegment = Buffer.concat([this._ftypData, this._moovData]);
|
|
1133
|
+
this._initSegmentResolve?.(this._initSegment);
|
|
1134
|
+
this._initSegmentResolve = null;
|
|
1135
|
+
this._initSegmentReject = null;
|
|
700
1136
|
}
|
|
701
1137
|
}
|
|
1138
|
+
// fragments() generator gets only media fragments (moof+mdat)
|
|
1139
|
+
if (boxes.some((b) => b.type === 'moof')) {
|
|
1140
|
+
this.pushFragment(data, info);
|
|
1141
|
+
}
|
|
702
1142
|
}
|
|
703
1143
|
/**
|
|
704
1144
|
* Push a fragment to the fragment queue for the async generator.
|
|
@@ -719,8 +1159,34 @@ export class FMP4Stream {
|
|
|
719
1159
|
}
|
|
720
1160
|
else {
|
|
721
1161
|
this.fragmentQueue.queue.push(fragment);
|
|
1162
|
+
// Live sources keep producing regardless of consumer speed - bound the
|
|
1163
|
+
// backlog by dropping the oldest media fragments. The init segment never
|
|
1164
|
+
// enters this queue in box mode (kept separately via initSegment).
|
|
1165
|
+
while (this.fragmentQueue.queue.length > this.options.maxQueuedFragments) {
|
|
1166
|
+
this.fragmentQueue.queue.shift();
|
|
1167
|
+
this._droppedFragments++;
|
|
1168
|
+
}
|
|
722
1169
|
}
|
|
723
1170
|
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Reject a pending initSegment promise so consumers never hang.
|
|
1173
|
+
*
|
|
1174
|
+
* No-op when the promise was never requested or has already settled. The
|
|
1175
|
+
* promise is marked as handled to avoid an unhandled rejection when no
|
|
1176
|
+
* consumer is currently awaiting it.
|
|
1177
|
+
*
|
|
1178
|
+
* @param error - Rejection reason (pipeline error or stop-before-init)
|
|
1179
|
+
*
|
|
1180
|
+
* @internal
|
|
1181
|
+
*/
|
|
1182
|
+
rejectInitSegment(error) {
|
|
1183
|
+
if (!this._initSegmentReject)
|
|
1184
|
+
return;
|
|
1185
|
+
this._initSegmentPromise?.catch(() => { });
|
|
1186
|
+
this._initSegmentReject(error);
|
|
1187
|
+
this._initSegmentReject = null;
|
|
1188
|
+
this._initSegmentResolve = null;
|
|
1189
|
+
}
|
|
724
1190
|
/**
|
|
725
1191
|
* Signal the fragment queue that no more fragments will arrive.
|
|
726
1192
|
*
|