node-av-lgpl 0.0.0 → 7.1.0-beta.1
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/LICENSE.md +22 -0
- package/README.md +711 -0
- package/dist/api/audio-frame-buffer.d.ts +636 -0
- package/dist/api/audio-frame-buffer.js +1027 -0
- package/dist/api/audio-frame-buffer.js.map +1 -0
- package/dist/api/bitstream-filter.d.ts +938 -0
- package/dist/api/bitstream-filter.js +1427 -0
- package/dist/api/bitstream-filter.js.map +1 -0
- package/dist/api/constants.d.ts +44 -0
- package/dist/api/constants.js +45 -0
- package/dist/api/constants.js.map +1 -0
- package/dist/api/data/test_av1.ivf +0 -0
- package/dist/api/data/test_h264.h264 +0 -0
- package/dist/api/data/test_hevc.h265 +0 -0
- package/dist/api/data/test_mjpeg.mjpeg +0 -0
- package/dist/api/data/test_vp8.ivf +0 -0
- package/dist/api/data/test_vp9.ivf +0 -0
- package/dist/api/decoder.d.ts +1413 -0
- package/dist/api/decoder.js +2367 -0
- package/dist/api/decoder.js.map +1 -0
- package/dist/api/demuxer.d.ts +1295 -0
- package/dist/api/demuxer.js +2344 -0
- package/dist/api/demuxer.js.map +1 -0
- package/dist/api/device.d.ts +589 -0
- package/dist/api/device.js +964 -0
- package/dist/api/device.js.map +1 -0
- package/dist/api/encoder-pool.d.ts +262 -0
- package/dist/api/encoder-pool.js +397 -0
- package/dist/api/encoder-pool.js.map +1 -0
- package/dist/api/encoder.d.ts +1509 -0
- package/dist/api/encoder.js +2619 -0
- package/dist/api/encoder.js.map +1 -0
- package/dist/api/filter-complex.d.ts +828 -0
- package/dist/api/filter-complex.js +1661 -0
- package/dist/api/filter-complex.js.map +1 -0
- package/dist/api/filter-presets.d.ts +800 -0
- package/dist/api/filter-presets.js +1554 -0
- package/dist/api/filter-presets.js.map +1 -0
- package/dist/api/filter.d.ts +1339 -0
- package/dist/api/filter.js +2075 -0
- package/dist/api/filter.js.map +1 -0
- package/dist/api/fmp4-stream.d.ts +797 -0
- package/dist/api/fmp4-stream.js +1428 -0
- package/dist/api/fmp4-stream.js.map +1 -0
- package/dist/api/hardware.d.ts +677 -0
- package/dist/api/hardware.js +1300 -0
- package/dist/api/hardware.js.map +1 -0
- package/dist/api/index.d.ts +21 -0
- package/dist/api/index.js +38 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/io-stream.d.ts +308 -0
- package/dist/api/io-stream.js +358 -0
- package/dist/api/io-stream.js.map +1 -0
- package/dist/api/muxer.d.ts +1311 -0
- package/dist/api/muxer.js +2672 -0
- package/dist/api/muxer.js.map +1 -0
- package/dist/api/pipeline.d.ts +801 -0
- package/dist/api/pipeline.js +1344 -0
- package/dist/api/pipeline.js.map +1 -0
- package/dist/api/probe.d.ts +128 -0
- package/dist/api/probe.js +227 -0
- package/dist/api/probe.js.map +1 -0
- package/dist/api/rtp-stream.d.ts +497 -0
- package/dist/api/rtp-stream.js +1131 -0
- package/dist/api/rtp-stream.js.map +1 -0
- package/dist/api/scaler.d.ts +561 -0
- package/dist/api/scaler.js +918 -0
- package/dist/api/scaler.js.map +1 -0
- 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 +149 -0
- package/dist/api/utilities/async-queue.js +254 -0
- package/dist/api/utilities/async-queue.js.map +1 -0
- package/dist/api/utilities/audio-sample.d.ts +117 -0
- package/dist/api/utilities/audio-sample.js +112 -0
- package/dist/api/utilities/audio-sample.js.map +1 -0
- package/dist/api/utilities/audio-transcode.d.ts +20 -0
- package/dist/api/utilities/audio-transcode.js +27 -0
- package/dist/api/utilities/audio-transcode.js.map +1 -0
- package/dist/api/utilities/channel-layout.d.ts +76 -0
- package/dist/api/utilities/channel-layout.js +80 -0
- package/dist/api/utilities/channel-layout.js.map +1 -0
- package/dist/api/utilities/codec-format.d.ts +87 -0
- package/dist/api/utilities/codec-format.js +117 -0
- package/dist/api/utilities/codec-format.js.map +1 -0
- package/dist/api/utilities/context-options.d.ts +25 -0
- package/dist/api/utilities/context-options.js +23 -0
- package/dist/api/utilities/context-options.js.map +1 -0
- package/dist/api/utilities/electron-shared-texture.d.ts +368 -0
- package/dist/api/utilities/electron-shared-texture.js +540 -0
- package/dist/api/utilities/electron-shared-texture.js.map +1 -0
- package/dist/api/utilities/image.d.ts +207 -0
- package/dist/api/utilities/image.js +213 -0
- package/dist/api/utilities/image.js.map +1 -0
- package/dist/api/utilities/index.d.ts +14 -0
- package/dist/api/utilities/index.js +27 -0
- package/dist/api/utilities/index.js.map +1 -0
- package/dist/api/utilities/live-decoding.d.ts +15 -0
- package/dist/api/utilities/live-decoding.js +32 -0
- package/dist/api/utilities/live-decoding.js.map +1 -0
- package/dist/api/utilities/media-type.d.ts +49 -0
- package/dist/api/utilities/media-type.js +53 -0
- package/dist/api/utilities/media-type.js.map +1 -0
- package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
- package/dist/api/utilities/packet-age-watchdog.js +594 -0
- package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
- package/dist/api/utilities/pixel-format.d.ts +89 -0
- package/dist/api/utilities/pixel-format.js +97 -0
- package/dist/api/utilities/pixel-format.js.map +1 -0
- package/dist/api/utilities/sample-format.d.ts +129 -0
- package/dist/api/utilities/sample-format.js +141 -0
- package/dist/api/utilities/sample-format.js.map +1 -0
- package/dist/api/utilities/scale-size.d.ts +51 -0
- package/dist/api/utilities/scale-size.js +55 -0
- package/dist/api/utilities/scale-size.js.map +1 -0
- package/dist/api/utilities/scheduler.d.ts +138 -0
- package/dist/api/utilities/scheduler.js +98 -0
- package/dist/api/utilities/scheduler.js.map +1 -0
- package/dist/api/utilities/streaming.d.ts +186 -0
- package/dist/api/utilities/streaming.js +309 -0
- package/dist/api/utilities/streaming.js.map +1 -0
- package/dist/api/utilities/timestamp.d.ts +193 -0
- package/dist/api/utilities/timestamp.js +206 -0
- package/dist/api/utilities/timestamp.js.map +1 -0
- package/dist/api/utilities/video-transcode.d.ts +218 -0
- package/dist/api/utilities/video-transcode.js +624 -0
- package/dist/api/utilities/video-transcode.js.map +1 -0
- package/dist/api/utilities/whisper-model.d.ts +330 -0
- package/dist/api/utilities/whisper-model.js +587 -0
- package/dist/api/utilities/whisper-model.js.map +1 -0
- package/dist/api/utils.d.ts +19 -0
- package/dist/api/utils.js +39 -0
- package/dist/api/utils.js.map +1 -0
- package/dist/api/webrtc-stream.d.ts +294 -0
- package/dist/api/webrtc-stream.js +546 -0
- package/dist/api/webrtc-stream.js.map +1 -0
- package/dist/api/whisper.d.ts +326 -0
- package/dist/api/whisper.js +402 -0
- package/dist/api/whisper.js.map +1 -0
- package/dist/constants/bsf-options.d.ts +364 -0
- package/dist/constants/bsf-options.js +7 -0
- package/dist/constants/bsf-options.js.map +1 -0
- package/dist/constants/channel-layouts.d.ts +53 -0
- package/dist/constants/channel-layouts.js +57 -0
- package/dist/constants/channel-layouts.js.map +1 -0
- package/dist/constants/constants.d.ts +2435 -0
- package/dist/constants/constants.js +1989 -0
- package/dist/constants/constants.js.map +1 -0
- package/dist/constants/decoders.d.ts +651 -0
- package/dist/constants/decoders.js +641 -0
- package/dist/constants/decoders.js.map +1 -0
- package/dist/constants/encoders.d.ts +314 -0
- package/dist/constants/encoders.js +309 -0
- package/dist/constants/encoders.js.map +1 -0
- package/dist/constants/filter-options.d.ts +12099 -0
- package/dist/constants/filter-options.js +7 -0
- package/dist/constants/filter-options.js.map +1 -0
- package/dist/constants/format-options.d.ts +4319 -0
- package/dist/constants/format-options.js +7 -0
- package/dist/constants/format-options.js.map +1 -0
- package/dist/constants/formats.d.ts +18 -0
- package/dist/constants/formats.js +7 -0
- package/dist/constants/formats.js.map +1 -0
- package/dist/constants/hardware.d.ts +26 -0
- package/dist/constants/hardware.js +27 -0
- package/dist/constants/hardware.js.map +1 -0
- package/dist/constants/index.d.ts +10 -0
- package/dist/constants/index.js +6 -0
- package/dist/constants/index.js.map +1 -0
- package/dist/constants/options.d.ts +6217 -0
- package/dist/constants/options.js +7 -0
- package/dist/constants/options.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +101 -0
- package/dist/ffmpeg/index.js +161 -0
- package/dist/ffmpeg/index.js.map +1 -0
- package/dist/ffmpeg/version.d.ts +6 -0
- package/dist/ffmpeg/version.js +7 -0
- package/dist/ffmpeg/version.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/audio-fifo.d.ts +399 -0
- package/dist/lib/audio-fifo.js +431 -0
- package/dist/lib/audio-fifo.js.map +1 -0
- package/dist/lib/binding.d.ts +242 -0
- package/dist/lib/binding.js +102 -0
- package/dist/lib/binding.js.map +1 -0
- package/dist/lib/bitstream-filter-context.d.ts +381 -0
- package/dist/lib/bitstream-filter-context.js +443 -0
- package/dist/lib/bitstream-filter-context.js.map +1 -0
- package/dist/lib/bitstream-filter.d.ts +140 -0
- package/dist/lib/bitstream-filter.js +154 -0
- package/dist/lib/bitstream-filter.js.map +1 -0
- package/dist/lib/codec-context.d.ts +1069 -0
- package/dist/lib/codec-context.js +1336 -0
- package/dist/lib/codec-context.js.map +1 -0
- package/dist/lib/codec-parameters.d.ts +664 -0
- package/dist/lib/codec-parameters.js +813 -0
- package/dist/lib/codec-parameters.js.map +1 -0
- package/dist/lib/codec-parser.d.ts +201 -0
- package/dist/lib/codec-parser.js +213 -0
- package/dist/lib/codec-parser.js.map +1 -0
- package/dist/lib/codec.d.ts +617 -0
- package/dist/lib/codec.js +746 -0
- package/dist/lib/codec.js.map +1 -0
- package/dist/lib/device.d.ts +294 -0
- package/dist/lib/device.js +326 -0
- package/dist/lib/device.js.map +1 -0
- package/dist/lib/dictionary.d.ts +333 -0
- package/dist/lib/dictionary.js +372 -0
- package/dist/lib/dictionary.js.map +1 -0
- package/dist/lib/error.d.ts +311 -0
- package/dist/lib/error.js +395 -0
- package/dist/lib/error.js.map +1 -0
- package/dist/lib/fifo.d.ts +416 -0
- package/dist/lib/fifo.js +453 -0
- package/dist/lib/fifo.js.map +1 -0
- package/dist/lib/filter-context.d.ts +714 -0
- package/dist/lib/filter-context.js +783 -0
- package/dist/lib/filter-context.js.map +1 -0
- package/dist/lib/filter-graph-segment.d.ts +161 -0
- package/dist/lib/filter-graph-segment.js +172 -0
- package/dist/lib/filter-graph-segment.js.map +1 -0
- package/dist/lib/filter-graph.d.ts +641 -0
- package/dist/lib/filter-graph.js +704 -0
- package/dist/lib/filter-graph.js.map +1 -0
- package/dist/lib/filter-inout.d.ts +202 -0
- package/dist/lib/filter-inout.js +261 -0
- package/dist/lib/filter-inout.js.map +1 -0
- package/dist/lib/filter.d.ts +243 -0
- package/dist/lib/filter.js +272 -0
- package/dist/lib/filter.js.map +1 -0
- package/dist/lib/format-context.d.ts +1335 -0
- package/dist/lib/format-context.js +1455 -0
- package/dist/lib/format-context.js.map +1 -0
- package/dist/lib/frame-utils.d.ts +116 -0
- package/dist/lib/frame-utils.js +98 -0
- package/dist/lib/frame-utils.js.map +1 -0
- package/dist/lib/frame.d.ts +1315 -0
- package/dist/lib/frame.js +1535 -0
- package/dist/lib/frame.js.map +1 -0
- package/dist/lib/hardware-device-context.d.ts +362 -0
- package/dist/lib/hardware-device-context.js +383 -0
- package/dist/lib/hardware-device-context.js.map +1 -0
- package/dist/lib/hardware-frames-context.d.ts +418 -0
- package/dist/lib/hardware-frames-context.js +471 -0
- package/dist/lib/hardware-frames-context.js.map +1 -0
- package/dist/lib/index.d.ts +35 -0
- package/dist/lib/index.js +60 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/input-format.d.ts +249 -0
- package/dist/lib/input-format.js +306 -0
- package/dist/lib/input-format.js.map +1 -0
- package/dist/lib/io-context.d.ts +698 -0
- package/dist/lib/io-context.js +771 -0
- package/dist/lib/io-context.js.map +1 -0
- package/dist/lib/log.d.ts +174 -0
- package/dist/lib/log.js +184 -0
- package/dist/lib/log.js.map +1 -0
- package/dist/lib/native-types.d.ts +1024 -0
- package/dist/lib/native-types.js +2 -0
- package/dist/lib/native-types.js.map +1 -0
- package/dist/lib/option.d.ts +953 -0
- package/dist/lib/option.js +1607 -0
- package/dist/lib/option.js.map +1 -0
- package/dist/lib/output-format.d.ts +222 -0
- package/dist/lib/output-format.js +259 -0
- package/dist/lib/output-format.js.map +1 -0
- package/dist/lib/packet.d.ts +521 -0
- package/dist/lib/packet.js +613 -0
- package/dist/lib/packet.js.map +1 -0
- package/dist/lib/rational.d.ts +251 -0
- package/dist/lib/rational.js +278 -0
- package/dist/lib/rational.js.map +1 -0
- package/dist/lib/software-resample-context.d.ts +552 -0
- package/dist/lib/software-resample-context.js +592 -0
- package/dist/lib/software-resample-context.js.map +1 -0
- package/dist/lib/software-scale-context.d.ts +344 -0
- package/dist/lib/software-scale-context.js +366 -0
- package/dist/lib/software-scale-context.js.map +1 -0
- package/dist/lib/stream.d.ts +383 -0
- package/dist/lib/stream.js +524 -0
- package/dist/lib/stream.js.map +1 -0
- package/dist/lib/sync-queue.d.ts +179 -0
- package/dist/lib/sync-queue.js +197 -0
- package/dist/lib/sync-queue.js.map +1 -0
- package/dist/lib/types.d.ts +34 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/utilities.d.ts +1198 -0
- package/dist/lib/utilities.js +1302 -0
- package/dist/lib/utilities.js.map +1 -0
- package/dist/utils/electron.d.ts +49 -0
- package/dist/utils/electron.js +63 -0
- package/dist/utils/electron.js.map +1 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/libc.d.ts +11 -0
- package/dist/utils/libc.js +35 -0
- package/dist/utils/libc.js.map +1 -0
- package/dist/utils/variant.d.ts +26 -0
- package/dist/utils/variant.js +31 -0
- package/dist/utils/variant.js.map +1 -0
- package/dist/webrtc/index.d.ts +4 -0
- package/dist/webrtc/index.js +7 -0
- package/dist/webrtc/index.js.map +1 -0
- package/install/check.js +124 -0
- package/install/ffmpeg.js +66 -0
- package/package.json +165 -3
|
@@ -0,0 +1,1428 @@
|
|
|
1
|
+
import { AV_CODEC_ID_AAC, AV_CODEC_ID_AV1, AV_CODEC_ID_FLAC, AV_CODEC_ID_H264, AV_CODEC_ID_HEVC, AV_CODEC_ID_OPUS, AV_HWDEVICE_TYPE_NONE, AV_SAMPLE_FMT_FLT, AV_SAMPLE_FMT_FLTP, } from '../constants/constants.js';
|
|
2
|
+
import { FF_ENCODER_AAC, FF_ENCODER_LIBOPUS, FF_ENCODER_LIBX264 } from '../constants/encoders.js';
|
|
3
|
+
import { Codec } from '../lib/codec.js';
|
|
4
|
+
import { Rational } from '../lib/rational.js';
|
|
5
|
+
import { avGetCodecString, avGetMediaTypeString } from '../lib/utilities.js';
|
|
6
|
+
import { Demuxer } from './demuxer.js';
|
|
7
|
+
import { Encoder } from './encoder.js';
|
|
8
|
+
import { FilterPreset } from './filter-presets.js';
|
|
9
|
+
import { FilterAPI } from './filter.js';
|
|
10
|
+
import { HardwareContext } from './hardware.js';
|
|
11
|
+
import { Muxer } from './muxer.js';
|
|
12
|
+
import { consumeStreamInParallel, interruptibleFrameSource, pipeline, PipelineControlImpl } from './pipeline.js';
|
|
13
|
+
import { openAudioDecoder } from './utilities/audio-transcode.js';
|
|
14
|
+
import { configureLiveDecoderThreading } from './utilities/live-decoding.js';
|
|
15
|
+
import { PacketAgeWatchdog } from './utilities/packet-age-watchdog.js';
|
|
16
|
+
import { transcodeScale } from './utilities/scale-size.js';
|
|
17
|
+
import { applyVideoBitrate, createVideoTranscodeFilter, findUsableHardwareEncoder, openTranscodeDecoder } from './utilities/video-transcode.js';
|
|
18
|
+
/**
|
|
19
|
+
* Target codec strings for fMP4 streaming.
|
|
20
|
+
*/
|
|
21
|
+
export const FMP4_CODECS = {
|
|
22
|
+
H264: 'avc1.640029',
|
|
23
|
+
H265: 'hvc1.1.6.L153.B0',
|
|
24
|
+
AV1: 'av01.0.00M.08',
|
|
25
|
+
AAC: 'mp4a.40.2',
|
|
26
|
+
FLAC: 'flac',
|
|
27
|
+
OPUS: 'opus',
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Shortest fragDuration in seconds the transcoded video GOP follows.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const MIN_FRAGMENT_GOP = 0.5;
|
|
35
|
+
/**
|
|
36
|
+
* High-level fMP4 streaming with automatic codec detection and transcoding.
|
|
37
|
+
*
|
|
38
|
+
* Provides fragmented MP4 streaming for clients.
|
|
39
|
+
* Automatically transcodes video to H.264 and audio to AAC (Opus when the client
|
|
40
|
+
* lists Opus but not AAC) if not supported by client.
|
|
41
|
+
* Client sends supported codecs, server transcodes accordingly.
|
|
42
|
+
* Essential component for building adaptive streaming servers.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* import { FMP4Stream } from 'node-av/api';
|
|
47
|
+
*
|
|
48
|
+
* // Client sends supported codecs
|
|
49
|
+
* const supportedCodecs = 'avc1.640029,hvc1.1.6.L153.B0,mp4a.40.2,flac';
|
|
50
|
+
*
|
|
51
|
+
* // Create stream with codec negotiation
|
|
52
|
+
* const stream = FMP4Stream.create('rtsp://camera.local/stream', {
|
|
53
|
+
* supportedCodecs,
|
|
54
|
+
* onData: (data) => ws.send(data.data)
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* // Start streaming (auto-transcodes if needed)
|
|
58
|
+
* await stream.start();
|
|
59
|
+
*
|
|
60
|
+
* // Stop when done
|
|
61
|
+
* await stream.stop();
|
|
62
|
+
* ```
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* // Stream with hardware acceleration
|
|
67
|
+
* const stream = FMP4Stream.create('input.mp4', {
|
|
68
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2',
|
|
69
|
+
* hardware: 'auto',
|
|
70
|
+
* fragDuration: 1,
|
|
71
|
+
* onData: (data) => sendToClient(data.data)
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* await stream.start();
|
|
75
|
+
* await stream.stop();
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export class FMP4Stream {
|
|
79
|
+
options;
|
|
80
|
+
inputUrl;
|
|
81
|
+
inputOptions;
|
|
82
|
+
liveInput;
|
|
83
|
+
input;
|
|
84
|
+
source;
|
|
85
|
+
output;
|
|
86
|
+
hardwareContext;
|
|
87
|
+
videoDecoder;
|
|
88
|
+
videoFilter;
|
|
89
|
+
videoEncoder;
|
|
90
|
+
audioDecoder;
|
|
91
|
+
audioFilter;
|
|
92
|
+
audioEncoder;
|
|
93
|
+
pipeline;
|
|
94
|
+
startPromise;
|
|
95
|
+
signal;
|
|
96
|
+
supportedCodecs;
|
|
97
|
+
incompleteBoxBuffer = null;
|
|
98
|
+
pendingFragment = [];
|
|
99
|
+
abortHandler;
|
|
100
|
+
stopRequested = false;
|
|
101
|
+
discardOutput = false;
|
|
102
|
+
stopPromise;
|
|
103
|
+
startAbort;
|
|
104
|
+
stopSignal = Promise.withResolvers();
|
|
105
|
+
fragmentQueue = null;
|
|
106
|
+
packetAgeWatchdog;
|
|
107
|
+
_droppedFragments = 0;
|
|
108
|
+
_initSegment = null;
|
|
109
|
+
_initSegmentResolve = null;
|
|
110
|
+
_initSegmentReject = null;
|
|
111
|
+
_initSegmentPromise = null;
|
|
112
|
+
_ftypData = null;
|
|
113
|
+
_moovData = null;
|
|
114
|
+
/**
|
|
115
|
+
* @param input - Media input URL or pre-opened Demuxer
|
|
116
|
+
*
|
|
117
|
+
* @param options - Stream configuration options
|
|
118
|
+
*
|
|
119
|
+
* Use {@link create} factory method
|
|
120
|
+
*
|
|
121
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
122
|
+
*
|
|
123
|
+
* @internal
|
|
124
|
+
*/
|
|
125
|
+
constructor(input, options) {
|
|
126
|
+
const isLiveInput = typeof input === 'string' && /^(rtsps?|rtmp(?:e|s|t|te|ts)?|s?rtp|udp|srt|tcp|tls|sctp):/i.test(input);
|
|
127
|
+
this.liveInput = isLiveInput;
|
|
128
|
+
const dtsBackwardThreshold = options.dtsBackwardThreshold ?? 10;
|
|
129
|
+
// Only a source read in real time pairs a gap with the matching wait.
|
|
130
|
+
const dtsForwardThreshold = options.dtsForwardThreshold ?? (isLiveInput ? 10 : 0);
|
|
131
|
+
const maxPacketAge = options.maxPacketAge ?? 60;
|
|
132
|
+
for (const [name, value] of [
|
|
133
|
+
['dtsBackwardThreshold', dtsBackwardThreshold],
|
|
134
|
+
['dtsForwardThreshold', dtsForwardThreshold],
|
|
135
|
+
['maxPacketAge', maxPacketAge],
|
|
136
|
+
]) {
|
|
137
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
138
|
+
throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${value}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (typeof input === 'string') {
|
|
142
|
+
this.inputUrl = input;
|
|
143
|
+
}
|
|
144
|
+
else if (input instanceof Demuxer) {
|
|
145
|
+
this.inputUrl = null;
|
|
146
|
+
this.input = input;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
this.inputUrl = null;
|
|
150
|
+
this.source = input;
|
|
151
|
+
}
|
|
152
|
+
const inputUrl = this.inputUrl ?? '';
|
|
153
|
+
this.inputOptions = {
|
|
154
|
+
...options.inputOptions,
|
|
155
|
+
options: {
|
|
156
|
+
...(isLiveInput ? { flags: 'low_delay', fflags: 'nobuffer' } : {}),
|
|
157
|
+
timeout: 10000000,
|
|
158
|
+
rtsp_transport: inputUrl.toLowerCase().startsWith('rtsp') ? 'tcp' : undefined,
|
|
159
|
+
...options.inputOptions?.options,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
this.options = {
|
|
163
|
+
onData: options.onData ?? (() => { }),
|
|
164
|
+
onClose: options.onClose ?? (() => { }),
|
|
165
|
+
supportedCodecs: options.supportedCodecs ?? '',
|
|
166
|
+
fragDuration: options.fragDuration ?? 1,
|
|
167
|
+
hardware: options.hardware ?? { deviceType: AV_HWDEVICE_TYPE_NONE },
|
|
168
|
+
inputOptions: options.inputOptions,
|
|
169
|
+
video: {
|
|
170
|
+
fps: options.video?.fps,
|
|
171
|
+
width: options.video?.width,
|
|
172
|
+
height: options.video?.height,
|
|
173
|
+
bitrate: options.video?.bitrate,
|
|
174
|
+
transcode: options.video?.transcode,
|
|
175
|
+
tag: options.video?.tag,
|
|
176
|
+
encoderOptions: options.video?.encoderOptions ?? {},
|
|
177
|
+
},
|
|
178
|
+
audio: {
|
|
179
|
+
encoderOptions: options.audio?.encoderOptions ?? {},
|
|
180
|
+
},
|
|
181
|
+
bufferSize: options.bufferSize ?? 2 * 1024 * 1024,
|
|
182
|
+
boxMode: options.boxMode ?? false,
|
|
183
|
+
maxQueuedFragments: options.maxQueuedFragments ?? 16,
|
|
184
|
+
movFlags: options.movFlags ?? '+frag_keyframe+separate_moof+default_base_moof+empty_moov',
|
|
185
|
+
dtsBackwardThreshold,
|
|
186
|
+
dtsForwardThreshold,
|
|
187
|
+
maxPacketAge,
|
|
188
|
+
};
|
|
189
|
+
this.signal = options.signal;
|
|
190
|
+
// Parse supported codecs
|
|
191
|
+
this.supportedCodecs = new Set(this.options.supportedCodecs
|
|
192
|
+
.split(',')
|
|
193
|
+
.map((c) => c.trim())
|
|
194
|
+
.filter(Boolean));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Create a fMP4 stream from a media source.
|
|
198
|
+
*
|
|
199
|
+
* Configures the stream with input URL and options. The input is not opened
|
|
200
|
+
* until start() is called, allowing the stream to be reused after stop().
|
|
201
|
+
*
|
|
202
|
+
* @param input - Media source URL (RTSP, file path, HTTP, etc.) or a pre-opened {@link Demuxer}
|
|
203
|
+
*
|
|
204
|
+
* @param options - Stream configuration options with supported codecs
|
|
205
|
+
*
|
|
206
|
+
* @returns Configured fMP4 stream instance
|
|
207
|
+
*
|
|
208
|
+
* @throws {RangeError} If dtsBackwardThreshold, dtsForwardThreshold or maxPacketAge is not a finite number >= 0
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* // Stream from file with codec negotiation
|
|
213
|
+
* const stream = FMP4Stream.create('video.mp4', {
|
|
214
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2',
|
|
215
|
+
* onData: (data) => ws.send(data.data)
|
|
216
|
+
* });
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```typescript
|
|
221
|
+
* // Stream from RTSP with auto hardware acceleration
|
|
222
|
+
* const stream = FMP4Stream.create('rtsp://camera.local/stream', {
|
|
223
|
+
* supportedCodecs: 'avc1.640029,hvc1.1.6.L153.B0,mp4a.40.2',
|
|
224
|
+
* hardware: 'auto',
|
|
225
|
+
* fragDuration: 0.5
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
static create(input, options = {}) {
|
|
230
|
+
return new FMP4Stream(input, options);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Promise that resolves with the init segment (ftyp+moov).
|
|
234
|
+
*
|
|
235
|
+
* Only available when boxMode is enabled.
|
|
236
|
+
* Resolves once the first ftyp and moov boxes have been received.
|
|
237
|
+
* Rejects if the stream fails or is stopped before the init segment
|
|
238
|
+
* was produced, so awaiting it never hangs.
|
|
239
|
+
*
|
|
240
|
+
* @throws {Error} If boxMode is not enabled
|
|
241
|
+
*/
|
|
242
|
+
get initSegment() {
|
|
243
|
+
if (!this.options.boxMode) {
|
|
244
|
+
throw new Error('initSegment is only available in box mode');
|
|
245
|
+
}
|
|
246
|
+
this._initSegmentPromise ??= new Promise((resolve, reject) => {
|
|
247
|
+
if (this._initSegment) {
|
|
248
|
+
resolve(this._initSegment);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
this._initSegmentResolve = resolve;
|
|
252
|
+
this._initSegmentReject = reject;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return this._initSegmentPromise;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Number of media fragments dropped due to consumer backpressure.
|
|
259
|
+
*
|
|
260
|
+
* Incremented whenever the {@link fragments} backlog exceeds
|
|
261
|
+
* `maxQueuedFragments` and the oldest fragment is discarded.
|
|
262
|
+
* Reset to 0 when the stream is (re)started.
|
|
263
|
+
*
|
|
264
|
+
* @returns Total dropped fragments for the current run
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```typescript
|
|
268
|
+
* for await (const fragment of stream.fragments()) {
|
|
269
|
+
* await sendToClient(fragment.data);
|
|
270
|
+
* if (stream.droppedFragments > 0) {
|
|
271
|
+
* console.warn(`Consumer too slow: ${stream.droppedFragments} fragments dropped`);
|
|
272
|
+
* }
|
|
273
|
+
* }
|
|
274
|
+
* ```
|
|
275
|
+
*/
|
|
276
|
+
get droppedFragments() {
|
|
277
|
+
return this._droppedFragments;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get the demuxer instance.
|
|
281
|
+
*
|
|
282
|
+
* Used for accessing the underlying demuxer.
|
|
283
|
+
* Only available after start() is called.
|
|
284
|
+
*
|
|
285
|
+
* @returns Demuxer instance or undefined if not started
|
|
286
|
+
*
|
|
287
|
+
* @example
|
|
288
|
+
* ```typescript
|
|
289
|
+
* const stream = FMP4Stream.create('input.mp4', {
|
|
290
|
+
* const input = stream.getInput();
|
|
291
|
+
* console.log('Bitrate:', input?.bitRate);
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
getInput() {
|
|
295
|
+
return this.input;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get the codec string that will be used by client.
|
|
299
|
+
*
|
|
300
|
+
* Returns the MIME type codec string based on input codecs and transcoding decisions.
|
|
301
|
+
* Call this after start() is called to know what codec string to use for addSourceBuffer().
|
|
302
|
+
*
|
|
303
|
+
* @returns MIME type codec string (e.g., "avc1.640029,mp4a.40.2")
|
|
304
|
+
*
|
|
305
|
+
* @throws {Error} If called before start() is called
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```typescript
|
|
309
|
+
* const stream = await FMP4Stream.create('input.mp4', {
|
|
310
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2'
|
|
311
|
+
* });
|
|
312
|
+
*
|
|
313
|
+
* await stream.start(); // Must start first
|
|
314
|
+
* const codecString = stream.getCodecString();
|
|
315
|
+
* console.log(codecString); // "avc1.640029,mp4a.40.2"
|
|
316
|
+
* // Use this for: sourceBuffer = mediaSource.addSourceBuffer(`video/mp4; codecs="${codecString}"`);
|
|
317
|
+
* ```
|
|
318
|
+
*/
|
|
319
|
+
getCodecString() {
|
|
320
|
+
// Frame-source path: there is no input codec - frames are always encoded
|
|
321
|
+
// to the fMP4 target codecs.
|
|
322
|
+
if (this.source) {
|
|
323
|
+
const parts = [];
|
|
324
|
+
if (this.source.video) {
|
|
325
|
+
parts.push(FMP4_CODECS.H264);
|
|
326
|
+
}
|
|
327
|
+
if (this.source.audio) {
|
|
328
|
+
parts.push(this.audioTranscodeTarget());
|
|
329
|
+
}
|
|
330
|
+
return parts.join(',');
|
|
331
|
+
}
|
|
332
|
+
if (!this.input) {
|
|
333
|
+
throw new Error('Input not opened. Call start() first to open the input.');
|
|
334
|
+
}
|
|
335
|
+
const videoStream = this.input.video();
|
|
336
|
+
const audioStream = this.input.audio();
|
|
337
|
+
const videoCodecId = videoStream?.codecpar.codecId;
|
|
338
|
+
const audioCodecId = audioStream?.codecpar.codecId;
|
|
339
|
+
// Determine video codec string
|
|
340
|
+
let videoCodec = null;
|
|
341
|
+
if (videoCodecId) {
|
|
342
|
+
const needsVideoTranscode = this.transcodesVideo(videoCodecId);
|
|
343
|
+
if (needsVideoTranscode) {
|
|
344
|
+
// Transcoding to H.264
|
|
345
|
+
videoCodec = FMP4_CODECS.H264;
|
|
346
|
+
}
|
|
347
|
+
else if (videoCodecId === AV_CODEC_ID_H264) {
|
|
348
|
+
// H.264 - use RFC 6381 codec string from input
|
|
349
|
+
const codecString = avGetCodecString(videoStream.codecpar);
|
|
350
|
+
videoCodec = codecString ?? FMP4_CODECS.H264;
|
|
351
|
+
}
|
|
352
|
+
else if (videoCodecId === AV_CODEC_ID_HEVC) {
|
|
353
|
+
// H.265 - use RFC 6381 codec string from input
|
|
354
|
+
const codecString = avGetCodecString(videoStream.codecpar);
|
|
355
|
+
videoCodec = codecString ?? FMP4_CODECS.H265;
|
|
356
|
+
}
|
|
357
|
+
else if (videoCodecId === AV_CODEC_ID_AV1) {
|
|
358
|
+
// AV1 - use RFC 6381 codec string from input
|
|
359
|
+
const codecString = avGetCodecString(videoStream.codecpar);
|
|
360
|
+
videoCodec = codecString ?? FMP4_CODECS.AV1;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
// Fallback to H.264 (should not happen as we transcode unsupported codecs)
|
|
364
|
+
videoCodec = FMP4_CODECS.H264;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Determine audio codec string
|
|
368
|
+
let audioCodec = null;
|
|
369
|
+
if (audioCodecId) {
|
|
370
|
+
const needsAudioTranscode = !this.isAudioCodecSupported(audioCodecId);
|
|
371
|
+
if (needsAudioTranscode) {
|
|
372
|
+
audioCodec = this.audioTranscodeTarget();
|
|
373
|
+
}
|
|
374
|
+
else if (audioCodecId === AV_CODEC_ID_AAC) {
|
|
375
|
+
// AAC - use fixed codec string
|
|
376
|
+
audioCodec = FMP4_CODECS.AAC;
|
|
377
|
+
}
|
|
378
|
+
else if (audioCodecId === AV_CODEC_ID_FLAC) {
|
|
379
|
+
// FLAC
|
|
380
|
+
audioCodec = FMP4_CODECS.FLAC;
|
|
381
|
+
}
|
|
382
|
+
else if (audioCodecId === AV_CODEC_ID_OPUS) {
|
|
383
|
+
// Opus
|
|
384
|
+
audioCodec = FMP4_CODECS.OPUS;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// Fallback to AAC (should not happen as we transcode unsupported codecs)
|
|
388
|
+
audioCodec = FMP4_CODECS.AAC;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Combine video and audio codec strings
|
|
392
|
+
return [videoCodec, audioCodec].filter(Boolean).join(',');
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Video description of the muxed fMP4 output (output codec, RFC 6381 codec string, resolution, fps).
|
|
396
|
+
*
|
|
397
|
+
* Available after {@link start} has opened the input.
|
|
398
|
+
*
|
|
399
|
+
* @returns The output video info, or `undefined` when there is no video track.
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* ```typescript
|
|
403
|
+
* const videoInfo = stream.getVideoInfo();
|
|
404
|
+
* if (videoInfo) {
|
|
405
|
+
* console.log(`Codec: ${videoInfo.codec}, Resolution: ${videoInfo.width}x${videoInfo.height}, FPS: ${videoInfo.fps}`);
|
|
406
|
+
* }
|
|
407
|
+
* ```
|
|
408
|
+
*/
|
|
409
|
+
getVideoInfo() {
|
|
410
|
+
if (this.source) {
|
|
411
|
+
if (!this.source.video) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
codec: 'h264',
|
|
416
|
+
codecString: FMP4_CODECS.H264,
|
|
417
|
+
width: this.options.video?.width ?? 0,
|
|
418
|
+
height: this.options.video?.height ?? 0,
|
|
419
|
+
fps: this.options.video?.fps ?? 0,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const videoStream = this.input?.video();
|
|
423
|
+
if (!videoStream) {
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
const { codecpar } = videoStream;
|
|
427
|
+
const codecId = codecpar.codecId;
|
|
428
|
+
const transcode = this.transcodesVideo(codecId);
|
|
429
|
+
const codecString = transcode ? FMP4_CODECS.H264 : (avGetCodecString(codecpar) ?? FMP4_CODECS.H264);
|
|
430
|
+
const codec = transcode || codecId === AV_CODEC_ID_H264 ? 'h264' : codecId === AV_CODEC_ID_HEVC ? 'hevc' : codecId === AV_CODEC_ID_AV1 ? 'av1' : 'unknown';
|
|
431
|
+
const size = transcode ? transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height).output : undefined;
|
|
432
|
+
const rate = videoStream.avgFrameRate;
|
|
433
|
+
return {
|
|
434
|
+
codec,
|
|
435
|
+
codecString,
|
|
436
|
+
width: size?.width ?? codecpar.width,
|
|
437
|
+
height: size?.height ?? codecpar.height,
|
|
438
|
+
fps: transcode && this.options.video?.fps ? this.options.video.fps : rate.den ? rate.num / rate.den : 0,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Start streaming media to fMP4 chunks.
|
|
443
|
+
*
|
|
444
|
+
* Begins the media processing pipeline, reading packets from input,
|
|
445
|
+
* transcoding based on supported codecs, and generating fMP4 chunks.
|
|
446
|
+
* Video transcodes to H.264 if H.264/H.265 not supported.
|
|
447
|
+
* Audio transcodes to AAC (Opus when only Opus is listed) if the input codec is not supported.
|
|
448
|
+
* This method returns immediately after starting the pipeline.
|
|
449
|
+
*
|
|
450
|
+
* @returns Promise that resolves when pipeline is started
|
|
451
|
+
*
|
|
452
|
+
* @throws {FFmpegError} If setup fails
|
|
453
|
+
*
|
|
454
|
+
* @example
|
|
455
|
+
* ```typescript
|
|
456
|
+
* const stream = await FMP4Stream.create('input.mp4', {
|
|
457
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2',
|
|
458
|
+
* onData: (data) => sendToClient(data.data)
|
|
459
|
+
* });
|
|
460
|
+
*
|
|
461
|
+
* // Start streaming (returns immediately)
|
|
462
|
+
* await stream.start();
|
|
463
|
+
*
|
|
464
|
+
* // Later: stop streaming
|
|
465
|
+
* await stream.stop();
|
|
466
|
+
* ```
|
|
467
|
+
*/
|
|
468
|
+
async start() {
|
|
469
|
+
this.startPromise ??= this.doStart().catch((error) => {
|
|
470
|
+
this.startPromise = undefined;
|
|
471
|
+
// A pending initSegment consumer must not hang when startup fails.
|
|
472
|
+
this.rejectInitSegment(error instanceof Error ? error : new Error(String(error)));
|
|
473
|
+
throw error;
|
|
474
|
+
});
|
|
475
|
+
await this.startPromise;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Perform the actual startup (see {@link start}).
|
|
479
|
+
*
|
|
480
|
+
* @internal
|
|
481
|
+
*/
|
|
482
|
+
async doStart() {
|
|
483
|
+
// A start racing an in-flight stop() waits for the teardown to finish,
|
|
484
|
+
// otherwise it would build the pipeline on top of freed resources.
|
|
485
|
+
if (this.stopPromise) {
|
|
486
|
+
await this.stopPromise;
|
|
487
|
+
}
|
|
488
|
+
if (this.pipeline) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.signal?.throwIfAborted();
|
|
492
|
+
// Store the handler so stop() can remove it - otherwise listeners would
|
|
493
|
+
// accumulate across restarts. Teardown errors are swallowed here: they are
|
|
494
|
+
// already surfaced via onClose(error) by the completion handler.
|
|
495
|
+
this.abortHandler = () => {
|
|
496
|
+
this.stop().catch(() => { });
|
|
497
|
+
};
|
|
498
|
+
this.signal?.addEventListener('abort', this.abortHandler, { once: true });
|
|
499
|
+
this._droppedFragments = 0;
|
|
500
|
+
this.discardOutput = false;
|
|
501
|
+
// Re-armed per start so a restart is not torn down by the previous run.
|
|
502
|
+
this.stopSignal = Promise.withResolvers();
|
|
503
|
+
// Lets stop() interrupt a startup that is blocked in the input open
|
|
504
|
+
// (an RTSP connect can take seconds).
|
|
505
|
+
this.startAbort = new AbortController();
|
|
506
|
+
// Frame-source path: encode pre-composited frames directly (no demuxer/decoder).
|
|
507
|
+
if (this.source) {
|
|
508
|
+
await this.startFromFrames();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Open input if not already open
|
|
512
|
+
if (!this.input) {
|
|
513
|
+
if (!this.inputUrl) {
|
|
514
|
+
throw new Error('No input URL or Demuxer provided');
|
|
515
|
+
}
|
|
516
|
+
const openSignal = this.signal ? AbortSignal.any([this.signal, this.startAbort.signal]) : this.startAbort.signal;
|
|
517
|
+
this.input = await Demuxer.open(this.inputUrl, { ...this.inputOptions, signal: openSignal });
|
|
518
|
+
}
|
|
519
|
+
this.throwIfStopRequested();
|
|
520
|
+
const videoStream = this.input.video();
|
|
521
|
+
const audioStream = this.input.audio();
|
|
522
|
+
// Check if video needs transcoding
|
|
523
|
+
const needsVideoTranscode = videoStream && this.transcodesVideo(videoStream.codecpar.codecId);
|
|
524
|
+
if (needsVideoTranscode) {
|
|
525
|
+
// Check if we need hardware acceleration
|
|
526
|
+
if (this.options.hardware === 'auto') {
|
|
527
|
+
this.hardwareContext = HardwareContext.auto();
|
|
528
|
+
}
|
|
529
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
530
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
531
|
+
}
|
|
532
|
+
// Transcode to H.264
|
|
533
|
+
const currentFps = videoStream.avgFrameRate.num / videoStream.avgFrameRate.den;
|
|
534
|
+
const needsFps = this.options.video.fps !== undefined && isFinite(currentFps) && this.options.video.fps !== currentFps;
|
|
535
|
+
const { codecpar } = videoStream;
|
|
536
|
+
const scale = transcodeScale(codecpar.width, codecpar.height, this.options.video.width, this.options.video.height);
|
|
537
|
+
// a GPU encodes up to a size of its own, test it with the size it will get
|
|
538
|
+
const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions, scale.output)) ?? this.softwareH264Encoder();
|
|
539
|
+
this.throwIfStopRequested();
|
|
540
|
+
this.videoDecoder = await openTranscodeDecoder(videoStream, this.hardwareContext, {
|
|
541
|
+
exitOnError: false,
|
|
542
|
+
// Frame threading holds back one frame per thread before the first output;
|
|
543
|
+
// files keep the full thread count for throughput
|
|
544
|
+
...(this.liveInput ? { configure: configureLiveDecoderThreading } : {}),
|
|
545
|
+
});
|
|
546
|
+
this.throwIfStopRequested();
|
|
547
|
+
// The graph follows the frames: whether they come from the device and in
|
|
548
|
+
// which format shows only once they are decoded
|
|
549
|
+
this.videoFilter = createVideoTranscodeFilter({
|
|
550
|
+
...scale.target,
|
|
551
|
+
fps: needsFps ? this.options.video.fps : undefined,
|
|
552
|
+
encoder: encoderCodec,
|
|
553
|
+
hardware: this.hardwareContext,
|
|
554
|
+
});
|
|
555
|
+
let encoderOptions = {};
|
|
556
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
557
|
+
encoderOptions.preset = 'ultrafast';
|
|
558
|
+
encoderOptions.tune = 'zerolatency';
|
|
559
|
+
}
|
|
560
|
+
encoderOptions = {
|
|
561
|
+
...encoderOptions,
|
|
562
|
+
...this.options.video.encoderOptions,
|
|
563
|
+
};
|
|
564
|
+
const effectiveFps = this.options.video.fps ?? currentFps;
|
|
565
|
+
const fpsForGop = isFinite(effectiveFps) && effectiveFps > 0 ? effectiveFps : 30;
|
|
566
|
+
const bitrate = this.options.video.bitrate;
|
|
567
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
568
|
+
decoder: this.videoDecoder,
|
|
569
|
+
filter: this.videoFilter,
|
|
570
|
+
// Hardware encoders default to B-frames, which delay every fragment and
|
|
571
|
+
// fail on some D3D12 drivers that report support for them
|
|
572
|
+
context: { maxBFrames: 0 },
|
|
573
|
+
options: encoderOptions,
|
|
574
|
+
configure: (ctx) => {
|
|
575
|
+
ctx.gopSize = this.gopSize(fpsForGop);
|
|
576
|
+
applyVideoBitrate(ctx, bitrate);
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
this.throwIfStopRequested();
|
|
580
|
+
}
|
|
581
|
+
// Check if audio needs transcoding
|
|
582
|
+
const needsAudioTranscode = audioStream && !this.isAudioCodecSupported(audioStream.codecpar.codecId);
|
|
583
|
+
if (needsAudioTranscode) {
|
|
584
|
+
this.audioDecoder = await openAudioDecoder(audioStream, {
|
|
585
|
+
exitOnError: false,
|
|
586
|
+
});
|
|
587
|
+
await this.createAudioEncoder(this.audioDecoder, audioStream.codecpar.channels);
|
|
588
|
+
this.throwIfStopRequested();
|
|
589
|
+
}
|
|
590
|
+
// Setup output with callback
|
|
591
|
+
this.output = await this.createOutput();
|
|
592
|
+
this.throwIfStopRequested();
|
|
593
|
+
this.attachCompletion(this.runPipeline());
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Abort an in-flight startup once stop() was requested.
|
|
597
|
+
*
|
|
598
|
+
* Checked after every await in the startup path: the resources created so
|
|
599
|
+
* far are all assigned to instance fields, so the stop() that is waiting on
|
|
600
|
+
* this startup tears them down the moment it unwinds.
|
|
601
|
+
*
|
|
602
|
+
* @throws {Error} If stop() was called while starting
|
|
603
|
+
*
|
|
604
|
+
* @internal
|
|
605
|
+
*/
|
|
606
|
+
throwIfStopRequested() {
|
|
607
|
+
if (this.stopRequested) {
|
|
608
|
+
throw new Error('FMP4Stream stopped during start');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* libx264, the encoder for H.264 output when no hardware encoder is usable.
|
|
613
|
+
*
|
|
614
|
+
* @returns The libx264 encoder
|
|
615
|
+
*
|
|
616
|
+
* @throws {Error} If the FFmpeg build has no libx264 (node-av-lgpl)
|
|
617
|
+
*
|
|
618
|
+
* @internal
|
|
619
|
+
*/
|
|
620
|
+
softwareH264Encoder() {
|
|
621
|
+
const codec = Codec.findEncoderByName(FF_ENCODER_LIBX264);
|
|
622
|
+
if (!codec) {
|
|
623
|
+
throw new Error('No H.264 encoder available: no usable hardware encoder and this FFmpeg build has no libx264 (node-av-lgpl encodes H.264 only in hardware)');
|
|
624
|
+
}
|
|
625
|
+
return codec;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* GOP size for the video encoder that starts every fragment with a keyframe.
|
|
629
|
+
*
|
|
630
|
+
* frag_duration flushes a fragment once it is that long, mid-GOP on a
|
|
631
|
+
* P-frame unless the GOP has the same length, and strict consumers reject
|
|
632
|
+
* such fragments. Shorter fragments (the 1 µs default flushes after every
|
|
633
|
+
* frame) are not matched: a GOP of a few frames multiplies the bitrate, so
|
|
634
|
+
* below MIN_FRAGMENT_GOP the GOP is 2 s, as with a fragDuration of 0.
|
|
635
|
+
*
|
|
636
|
+
* @param fps - Output frame rate
|
|
637
|
+
*
|
|
638
|
+
* @returns GOP size in frames
|
|
639
|
+
*
|
|
640
|
+
* @internal
|
|
641
|
+
*/
|
|
642
|
+
gopSize(fps) {
|
|
643
|
+
const fragSeconds = this.options.fragDuration / 1_000_000;
|
|
644
|
+
const seconds = fragSeconds >= MIN_FRAGMENT_GOP ? fragSeconds : 2;
|
|
645
|
+
return Math.max(1, Math.round(fps * seconds));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Create the fMP4 output muxer with the fragment-emitting write callback.
|
|
649
|
+
*
|
|
650
|
+
* Uses `this.input` when present (demuxer path) and no input for the
|
|
651
|
+
* frame-source path. Attaches a fresh packet age watchdog unless
|
|
652
|
+
* `maxPacketAge` is 0.
|
|
653
|
+
*
|
|
654
|
+
* @returns The configured muxer
|
|
655
|
+
*
|
|
656
|
+
* @internal
|
|
657
|
+
*/
|
|
658
|
+
async createOutput() {
|
|
659
|
+
const cb = {
|
|
660
|
+
write: (buffer) => {
|
|
661
|
+
// Swallows what the teardown of a failed session flushes (see attachCompletion).
|
|
662
|
+
if (this.discardOutput) {
|
|
663
|
+
return buffer.length;
|
|
664
|
+
}
|
|
665
|
+
if (this.options.boxMode) {
|
|
666
|
+
// Box mode: buffer until we have complete boxes
|
|
667
|
+
this.processBoxMode(buffer);
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
// Chunk mode: send raw data immediately
|
|
671
|
+
this.packetAgeWatchdog?.consumeChunk(buffer);
|
|
672
|
+
const info = { isComplete: false, boxes: [] };
|
|
673
|
+
this.options.onData(buffer, info);
|
|
674
|
+
this.pushFragment(buffer, info);
|
|
675
|
+
}
|
|
676
|
+
return buffer.length;
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
const output = await Muxer.open(cb, {
|
|
680
|
+
input: this.input,
|
|
681
|
+
format: 'mp4',
|
|
682
|
+
bufferSize: this.options.bufferSize,
|
|
683
|
+
exitOnError: false,
|
|
684
|
+
dtsBackwardThreshold: this.options.dtsBackwardThreshold,
|
|
685
|
+
dtsForwardThreshold: this.options.dtsForwardThreshold,
|
|
686
|
+
configure: (fmt) => {
|
|
687
|
+
const tag = this.options.video?.tag;
|
|
688
|
+
if (!tag) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
for (const stream of fmt.streams) {
|
|
692
|
+
if (stream.codecpar.codecId === AV_CODEC_ID_HEVC) {
|
|
693
|
+
stream.codecpar.codecTag = tag;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
options: {
|
|
698
|
+
movflags: this.options.movFlags,
|
|
699
|
+
frag_duration: this.options.fragDuration,
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
// Per session: a restart must not inherit the previous session's backlog.
|
|
703
|
+
if (this.options.maxPacketAge > 0) {
|
|
704
|
+
this.packetAgeWatchdog = new PacketAgeWatchdog(this.options.maxPacketAge, (index) => {
|
|
705
|
+
const codecType = output.getStream(index)?.codecpar.codecType;
|
|
706
|
+
return codecType === undefined ? '' : (avGetMediaTypeString(codecType) ?? '');
|
|
707
|
+
});
|
|
708
|
+
output.setPacketObserver(this.packetAgeWatchdog);
|
|
709
|
+
}
|
|
710
|
+
return output;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Wire the pipeline completion to the fragment/close callbacks.
|
|
714
|
+
*
|
|
715
|
+
* On success ends the fragment iterator and invokes `onClose()`; on error
|
|
716
|
+
* stops the stream, discarding the output the muxer still holds, and invokes
|
|
717
|
+
* `onClose(error)`. Either way `onClose` runs exactly once and the detached
|
|
718
|
+
* chain never rejects. Runs in the background.
|
|
719
|
+
*
|
|
720
|
+
* @param completion - The pipeline completion promise
|
|
721
|
+
*
|
|
722
|
+
* @internal
|
|
723
|
+
*/
|
|
724
|
+
attachCompletion(completion) {
|
|
725
|
+
// One then() with both handlers: a throwing onClose() in the success path
|
|
726
|
+
// must not fall through into the error path and fire onClose a second time.
|
|
727
|
+
completion.then(() => {
|
|
728
|
+
this.endFragments();
|
|
729
|
+
// No-op in the normal case (init segment resolved long ago) - only
|
|
730
|
+
// fires if the stream ended without ever emitting ftyp+moov.
|
|
731
|
+
this.rejectInitSegment(new Error('FMP4Stream ended before init segment was produced'));
|
|
732
|
+
this.emitClose();
|
|
733
|
+
}, async (reason) => {
|
|
734
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
735
|
+
this.endFragments();
|
|
736
|
+
// Reject with the pipeline error so an initSegment consumer sees the
|
|
737
|
+
// actual failure instead of the generic "stopped" error from stop().
|
|
738
|
+
this.rejectInitSegment(error);
|
|
739
|
+
// A stop() in flight or an aborted signal means the owner is tearing
|
|
740
|
+
// the stream down - the pipeline unwinding with an AbortError is the
|
|
741
|
+
// expected shutdown path, not a failure. Decided before the stop()
|
|
742
|
+
// below, which would make every failure look deliberate.
|
|
743
|
+
const deliberate = this.isDeliberateStop(reason);
|
|
744
|
+
// Closing the output flushes the muxer's backlog and the trailer. After
|
|
745
|
+
// a failure that backlog is suspect: when a backward jump ends the
|
|
746
|
+
// session, the packets the timeline had jumped ahead to are still
|
|
747
|
+
// queued and would leave as a fragment placed that far ahead; after a
|
|
748
|
+
// stall it holds up to maxPacketAge of retained samples. Nothing is
|
|
749
|
+
// emitted past the failure, through onData or fragments(). A
|
|
750
|
+
// deliberate stop keeps its tail.
|
|
751
|
+
if (!deliberate) {
|
|
752
|
+
this.discardOutput = true;
|
|
753
|
+
}
|
|
754
|
+
// Release the input (e.g. the RTSP connection) before reporting, so an
|
|
755
|
+
// owner that restarts from onClose never overlaps a live session. A
|
|
756
|
+
// teardown failure must not swallow the onClose call: without it the
|
|
757
|
+
// owner never learns the stream is gone.
|
|
758
|
+
try {
|
|
759
|
+
await this.stop();
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// doStop() already released whatever it could.
|
|
763
|
+
}
|
|
764
|
+
this.emitClose(deliberate ? undefined : error);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Invoke the owner's onClose callback.
|
|
769
|
+
*
|
|
770
|
+
* The completion chain runs detached, so an exception from the callback
|
|
771
|
+
* could only surface as an unhandled rejection - it is logged instead.
|
|
772
|
+
*
|
|
773
|
+
* @param error - Pipeline error, or undefined for a clean end
|
|
774
|
+
*
|
|
775
|
+
* @internal
|
|
776
|
+
*/
|
|
777
|
+
emitClose(error) {
|
|
778
|
+
try {
|
|
779
|
+
this.options.onClose(error);
|
|
780
|
+
}
|
|
781
|
+
catch (callbackError) {
|
|
782
|
+
// The stream is already closed; logged so a broken restart path in the
|
|
783
|
+
// owner does not vanish silently.
|
|
784
|
+
console.error('[FMP4Stream] onClose callback threw:', callbackError);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Whether a pipeline rejection was caused by an intentional shutdown.
|
|
789
|
+
*
|
|
790
|
+
* @param error - The pipeline rejection reason
|
|
791
|
+
*
|
|
792
|
+
* @returns True when stop() is in flight or the owner's signal aborted
|
|
793
|
+
*
|
|
794
|
+
* @internal
|
|
795
|
+
*/
|
|
796
|
+
isDeliberateStop(error) {
|
|
797
|
+
return this.stopRequested || this.stopPromise !== undefined || this.signal?.aborted === true || (error instanceof Error && error.name === 'AbortError');
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Set up encoders and the fMP4 muxer for a pre-composited frame source and
|
|
801
|
+
* start encoding. No demuxer or decoder is involved - the provided frames are
|
|
802
|
+
* encoded straight into the fragmented container. Both streams share the ONE
|
|
803
|
+
* muxer, so neither may close it on its own: each source is encoded to packets
|
|
804
|
+
* and written concurrently (the muxer interleaves), and the muxer is closed
|
|
805
|
+
* once all streams have finished.
|
|
806
|
+
*
|
|
807
|
+
* @throws {Error} If the source has neither video nor audio frames
|
|
808
|
+
*
|
|
809
|
+
* @internal
|
|
810
|
+
*/
|
|
811
|
+
async startFromFrames() {
|
|
812
|
+
const source = this.source;
|
|
813
|
+
if (!source.video && !source.audio) {
|
|
814
|
+
throw new Error('Frame source must provide video and/or audio frames');
|
|
815
|
+
}
|
|
816
|
+
// Video encoder - the encoder adopts width/height/pixelFormat/timeBase from
|
|
817
|
+
// the first composited frame.
|
|
818
|
+
if (source.video) {
|
|
819
|
+
if (this.options.hardware === 'auto') {
|
|
820
|
+
this.hardwareContext = HardwareContext.auto();
|
|
821
|
+
}
|
|
822
|
+
else if (this.options.hardware.deviceType !== AV_HWDEVICE_TYPE_NONE) {
|
|
823
|
+
this.hardwareContext = HardwareContext.create(this.options.hardware.deviceType, this.options.hardware.device, this.options.hardware.options);
|
|
824
|
+
}
|
|
825
|
+
const encoderCodec = (await findUsableHardwareEncoder(this.hardwareContext, 'h264', this.options.video.encoderOptions)) ?? this.softwareH264Encoder();
|
|
826
|
+
this.throwIfStopRequested();
|
|
827
|
+
let encoderOptions = {};
|
|
828
|
+
if (encoderCodec.name === FF_ENCODER_LIBX264) {
|
|
829
|
+
encoderOptions.preset = 'ultrafast';
|
|
830
|
+
encoderOptions.tune = 'zerolatency';
|
|
831
|
+
}
|
|
832
|
+
encoderOptions = { ...encoderOptions, ...this.options.video.encoderOptions };
|
|
833
|
+
let fps = this.options.video.fps ?? 30;
|
|
834
|
+
if (!isFinite(fps) || fps <= 0 || isNaN(fps)) {
|
|
835
|
+
fps = 30;
|
|
836
|
+
}
|
|
837
|
+
// Set the framerate explicitly - without a decoder the encoder would infer
|
|
838
|
+
// it from the frame timebase and pick an absurd level.
|
|
839
|
+
const bitrate = this.options.video.bitrate;
|
|
840
|
+
this.videoEncoder = await Encoder.create(encoderCodec, {
|
|
841
|
+
context: { maxBFrames: 0 },
|
|
842
|
+
options: encoderOptions,
|
|
843
|
+
configure: (ctx) => {
|
|
844
|
+
ctx.framerate = new Rational(Math.round(fps), 1);
|
|
845
|
+
ctx.gopSize = this.gopSize(fps);
|
|
846
|
+
applyVideoBitrate(ctx, bitrate);
|
|
847
|
+
},
|
|
848
|
+
});
|
|
849
|
+
// Source frames may sit on another device or in a format the encoder rejects
|
|
850
|
+
this.videoFilter = createVideoTranscodeFilter({ width: -1, height: -1, encoder: encoderCodec, hardware: this.hardwareContext });
|
|
851
|
+
}
|
|
852
|
+
if (source.audio) {
|
|
853
|
+
await this.createAudioEncoder();
|
|
854
|
+
}
|
|
855
|
+
this.throwIfStopRequested();
|
|
856
|
+
this.output = await this.createOutput();
|
|
857
|
+
this.throwIfStopRequested();
|
|
858
|
+
// eslint-disable-next-line prefer-const
|
|
859
|
+
let control;
|
|
860
|
+
const shouldStop = () => control?.isStopped() ?? false;
|
|
861
|
+
const tasks = [];
|
|
862
|
+
// A frame source has no demuxer for stop() to interrupt, so wrap it to make
|
|
863
|
+
// a silent source releasable instead of blocking teardown forever.
|
|
864
|
+
const stopped = this.stopSignal.promise;
|
|
865
|
+
if (source.video && this.videoFilter && this.videoEncoder) {
|
|
866
|
+
const streamIndex = this.output.addStream(this.videoEncoder);
|
|
867
|
+
const video = interruptibleFrameSource(source.video, stopped);
|
|
868
|
+
tasks.push(consumeStreamInParallel(this.videoEncoder.packets(this.videoFilter.frames(video)), this.output, streamIndex, shouldStop));
|
|
869
|
+
}
|
|
870
|
+
if (source.audio && this.audioFilter && this.audioEncoder) {
|
|
871
|
+
const streamIndex = this.output.addStream(this.audioEncoder);
|
|
872
|
+
const audio = interruptibleFrameSource(source.audio, stopped);
|
|
873
|
+
tasks.push(consumeStreamInParallel(this.audioEncoder.packets(this.audioFilter.frames(audio)), this.output, streamIndex, shouldStop));
|
|
874
|
+
}
|
|
875
|
+
// Close the shared muxer only after ALL streams are done (flushes the trailer).
|
|
876
|
+
const run = Promise.all(tasks).then(async () => {
|
|
877
|
+
await this.output?.close();
|
|
878
|
+
});
|
|
879
|
+
control = new PipelineControlImpl(run, this.signal);
|
|
880
|
+
this.pipeline = control;
|
|
881
|
+
this.attachCompletion(this.pipeline.completion);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Stop streaming gracefully and clean up all resources.
|
|
885
|
+
*
|
|
886
|
+
* Stops the pipeline, closes output, and releases all FFmpeg resources.
|
|
887
|
+
* Safe to call multiple times. After stopping, you can call start() again
|
|
888
|
+
* to restart the stream. Resolves even if the pipeline had failed; that
|
|
889
|
+
* error is reported through `onClose(error)`.
|
|
890
|
+
*
|
|
891
|
+
* @example
|
|
892
|
+
* ```typescript
|
|
893
|
+
* const stream = await FMP4Stream.create('input.mp4', {
|
|
894
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2'
|
|
895
|
+
* });
|
|
896
|
+
* await stream.start();
|
|
897
|
+
*
|
|
898
|
+
* // Stop after 10 seconds
|
|
899
|
+
* setTimeout(async () => await stream.stop(), 10000);
|
|
900
|
+
* ```
|
|
901
|
+
*/
|
|
902
|
+
async stop() {
|
|
903
|
+
// Memoized: concurrent stop() calls share one teardown instead of
|
|
904
|
+
// double-closing native resources.
|
|
905
|
+
this.stopPromise ??= this.doStop().finally(() => {
|
|
906
|
+
this.stopPromise = undefined;
|
|
907
|
+
});
|
|
908
|
+
await this.stopPromise;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Perform the actual teardown (see {@link stop}).
|
|
912
|
+
*
|
|
913
|
+
* @internal
|
|
914
|
+
*/
|
|
915
|
+
async doStop() {
|
|
916
|
+
// An in-flight start() must unwind before teardown - otherwise it would
|
|
917
|
+
// keep building the pipeline on top of freed resources and leave an
|
|
918
|
+
// orphaned transcode running forever. The flag makes doStart() bail at its
|
|
919
|
+
// next checkpoint, the abort unblocks a startup stuck in the input open.
|
|
920
|
+
this.stopRequested = true;
|
|
921
|
+
this.startAbort?.abort();
|
|
922
|
+
this.startAbort = undefined;
|
|
923
|
+
// Unblocks a frame source that went silent, so the completion barrier below
|
|
924
|
+
// cannot wait on a pull that will never settle.
|
|
925
|
+
this.stopSignal.resolve();
|
|
926
|
+
const inflightStart = this.startPromise;
|
|
927
|
+
// Allow a fresh start() after stopping.
|
|
928
|
+
this.startPromise = undefined;
|
|
929
|
+
if (inflightStart) {
|
|
930
|
+
try {
|
|
931
|
+
await inflightStart;
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
// Aborted or failed startup - resources are torn down below either way.
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
try {
|
|
938
|
+
this.endFragments();
|
|
939
|
+
// A consumer awaiting the init segment must not hang forever.
|
|
940
|
+
this.rejectInitSegment(new Error('FMP4Stream stopped before init segment was produced'));
|
|
941
|
+
// Remove the abort listener so it does not accumulate across restarts.
|
|
942
|
+
if (this.abortHandler) {
|
|
943
|
+
this.signal?.removeEventListener('abort', this.abortHandler);
|
|
944
|
+
this.abortHandler = undefined;
|
|
945
|
+
}
|
|
946
|
+
// Stop pipeline if running and wait for completion. Swallow a rejected
|
|
947
|
+
// completion - the error is already surfaced via the onClose(error) path;
|
|
948
|
+
// we just need cleanup to proceed.
|
|
949
|
+
const pipeline = this.pipeline;
|
|
950
|
+
if (pipeline) {
|
|
951
|
+
if (!pipeline.isStopped()) {
|
|
952
|
+
pipeline.stop();
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
await pipeline.completion;
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
// Pipeline errored - proceed with teardown regardless.
|
|
959
|
+
}
|
|
960
|
+
if (this.pipeline === pipeline) {
|
|
961
|
+
this.pipeline = undefined;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// Close all resources. The output goes FIRST: its async write worker may
|
|
965
|
+
// still be draining (header init reads the input's stream parameters),
|
|
966
|
+
// so freeing the input before the muxer has fully settled is a
|
|
967
|
+
// use-after-free on the worker thread.
|
|
968
|
+
// After a failure nothing the output emits is used (see attachCompletion),
|
|
969
|
+
// so the trailer is skipped instead of flushed into the void.
|
|
970
|
+
if (this.discardOutput) {
|
|
971
|
+
this.output?.discardOnClose();
|
|
972
|
+
}
|
|
973
|
+
// close() rethrows a failed background write after it freed the muxer.
|
|
974
|
+
// That error already ended the pipeline and reaches the owner through
|
|
975
|
+
// onClose(error), so it must not abort the teardown here and leak the
|
|
976
|
+
// codecs and the input (an RTSP connection) below.
|
|
977
|
+
try {
|
|
978
|
+
await this.output?.close();
|
|
979
|
+
}
|
|
980
|
+
catch {
|
|
981
|
+
// Surfaced through the pipeline completion.
|
|
982
|
+
}
|
|
983
|
+
this.output = undefined;
|
|
984
|
+
this.packetAgeWatchdog = undefined;
|
|
985
|
+
// A codec that refuses to close (still busy) must not leave the input,
|
|
986
|
+
// an RTSP connection, open.
|
|
987
|
+
try {
|
|
988
|
+
this.videoDecoder?.close();
|
|
989
|
+
this.videoDecoder = undefined;
|
|
990
|
+
this.videoFilter?.close();
|
|
991
|
+
this.videoFilter = undefined;
|
|
992
|
+
this.videoEncoder?.close();
|
|
993
|
+
this.videoEncoder = undefined;
|
|
994
|
+
this.audioDecoder?.close();
|
|
995
|
+
this.audioDecoder = undefined;
|
|
996
|
+
this.audioFilter?.close();
|
|
997
|
+
this.audioFilter = undefined;
|
|
998
|
+
this.audioEncoder?.close();
|
|
999
|
+
this.audioEncoder = undefined;
|
|
1000
|
+
this.hardwareContext?.dispose();
|
|
1001
|
+
this.hardwareContext = undefined;
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
await this.input?.close();
|
|
1005
|
+
this.input = undefined;
|
|
1006
|
+
}
|
|
1007
|
+
// Reset parser and init segment state so a restart parses a clean stream.
|
|
1008
|
+
this.incompleteBoxBuffer = null;
|
|
1009
|
+
this.pendingFragment = [];
|
|
1010
|
+
this._initSegment = null;
|
|
1011
|
+
this._initSegmentResolve = null;
|
|
1012
|
+
this._initSegmentReject = null;
|
|
1013
|
+
this._initSegmentPromise = null;
|
|
1014
|
+
this._ftypData = null;
|
|
1015
|
+
this._moovData = null;
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
this.stopRequested = false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Async generator that yields media fragments (moof+mdat chunks).
|
|
1023
|
+
*
|
|
1024
|
+
* In box mode, yields only media fragments (chunks containing moof boxes),
|
|
1025
|
+
* NOT the init segment. Use {@link initSegment} to get the ftyp+moov data.
|
|
1026
|
+
*
|
|
1027
|
+
* In chunk mode (boxMode disabled), yields every chunk as-is since
|
|
1028
|
+
* box-level filtering is not possible without box parsing.
|
|
1029
|
+
*
|
|
1030
|
+
* The generator completes when the stream stops or the pipeline ends.
|
|
1031
|
+
*
|
|
1032
|
+
* Only a single consumer is supported: a second concurrent fragments() call
|
|
1033
|
+
* would silently strand the first consumer, so it throws instead. The
|
|
1034
|
+
* backlog is bounded by `maxQueuedFragments` - if the consumer falls behind
|
|
1035
|
+
* a live source, the oldest fragments are dropped (see
|
|
1036
|
+
* {@link droppedFragments}).
|
|
1037
|
+
*
|
|
1038
|
+
* @yields {FMP4Fragment} Media fragment with data buffer and box info
|
|
1039
|
+
*
|
|
1040
|
+
* @throws {Error} If another fragments() iterator is already active
|
|
1041
|
+
*
|
|
1042
|
+
* @example
|
|
1043
|
+
* ```typescript
|
|
1044
|
+
* const stream = FMP4Stream.create('rtsp://camera/stream', {
|
|
1045
|
+
* supportedCodecs: 'avc1.640029,mp4a.40.2',
|
|
1046
|
+
* boxMode: true,
|
|
1047
|
+
* });
|
|
1048
|
+
*
|
|
1049
|
+
* await stream.start();
|
|
1050
|
+
* const init = await stream.initSegment;
|
|
1051
|
+
*
|
|
1052
|
+
* for await (const fragment of stream.fragments()) {
|
|
1053
|
+
* sendToClient(fragment.data);
|
|
1054
|
+
* }
|
|
1055
|
+
* ```
|
|
1056
|
+
*/
|
|
1057
|
+
async *fragments() {
|
|
1058
|
+
if (this.fragmentQueue) {
|
|
1059
|
+
throw new Error('fragments() supports only a single consumer; the previous iterator is still active');
|
|
1060
|
+
}
|
|
1061
|
+
this.fragmentQueue = { queue: [], resolve: null, done: false };
|
|
1062
|
+
try {
|
|
1063
|
+
while (true) {
|
|
1064
|
+
const result = await new Promise((resolve) => {
|
|
1065
|
+
if (this.fragmentQueue.queue.length > 0) {
|
|
1066
|
+
resolve({ value: this.fragmentQueue.queue.shift(), done: false });
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (this.fragmentQueue.done) {
|
|
1070
|
+
resolve({ value: undefined, done: true });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
this.fragmentQueue.resolve = resolve;
|
|
1074
|
+
});
|
|
1075
|
+
if (result.done)
|
|
1076
|
+
break;
|
|
1077
|
+
yield result.value;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
finally {
|
|
1081
|
+
this.fragmentQueue = null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Run the streaming pipeline until completion or stopped.
|
|
1086
|
+
*
|
|
1087
|
+
* @internal
|
|
1088
|
+
*/
|
|
1089
|
+
async runPipeline() {
|
|
1090
|
+
if (!this.input || !this.output) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
const hasVideo = this.input?.video() !== undefined;
|
|
1094
|
+
const hasAudio = this.input?.audio() !== undefined;
|
|
1095
|
+
const opts = this.signal ? { signal: this.signal } : undefined;
|
|
1096
|
+
if (hasAudio && hasVideo) {
|
|
1097
|
+
this.pipeline = pipeline(this.input, {
|
|
1098
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
|
|
1099
|
+
audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
|
|
1100
|
+
}, this.output, opts);
|
|
1101
|
+
}
|
|
1102
|
+
else if (hasVideo) {
|
|
1103
|
+
this.pipeline = pipeline(this.input, {
|
|
1104
|
+
video: [this.videoDecoder, this.videoFilter, this.videoEncoder],
|
|
1105
|
+
}, this.output, opts);
|
|
1106
|
+
}
|
|
1107
|
+
else if (hasAudio) {
|
|
1108
|
+
this.pipeline = pipeline(this.input, {
|
|
1109
|
+
audio: [this.audioDecoder, this.audioFilter, this.audioEncoder],
|
|
1110
|
+
}, this.output, opts);
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
throw new Error('No audio or video streams found in input');
|
|
1114
|
+
}
|
|
1115
|
+
await this.pipeline.completion;
|
|
1116
|
+
this.pipeline = undefined;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Check if the video is encoded again instead of passed through.
|
|
1120
|
+
*
|
|
1121
|
+
* @param codecId - Codec ID of the source video
|
|
1122
|
+
*
|
|
1123
|
+
* @returns True if `video.transcode` is set or the codec is not in supported codecs
|
|
1124
|
+
*
|
|
1125
|
+
* @internal
|
|
1126
|
+
*/
|
|
1127
|
+
transcodesVideo(codecId) {
|
|
1128
|
+
return this.options.video.transcode === true || !this.isVideoCodecSupported(codecId);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Check if video codec is supported.
|
|
1132
|
+
*
|
|
1133
|
+
* @param codecId - Codec ID
|
|
1134
|
+
*
|
|
1135
|
+
* @returns True if H.264, H.265, or AV1 is in supported codecs
|
|
1136
|
+
*
|
|
1137
|
+
* @internal
|
|
1138
|
+
*/
|
|
1139
|
+
isVideoCodecSupported(codecId) {
|
|
1140
|
+
if (codecId === AV_CODEC_ID_H264 && (this.supportedCodecs.has(FMP4_CODECS.H264) || this.supportedCodecs.has('avc1'))) {
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
if (codecId === AV_CODEC_ID_HEVC && (this.supportedCodecs.has(FMP4_CODECS.H265) || this.supportedCodecs.has('hvc1') || this.supportedCodecs.has('hev1'))) {
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
if (codecId === AV_CODEC_ID_AV1 && (this.supportedCodecs.has(FMP4_CODECS.AV1) || this.supportedCodecs.has('av01'))) {
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
return false;
|
|
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
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Check if audio codec is supported.
|
|
1194
|
+
*
|
|
1195
|
+
* @param codecId - Codec ID
|
|
1196
|
+
*
|
|
1197
|
+
* @returns True if AAC, FLAC, or Opus is in supported codecs
|
|
1198
|
+
*
|
|
1199
|
+
* @internal
|
|
1200
|
+
*/
|
|
1201
|
+
isAudioCodecSupported(codecId) {
|
|
1202
|
+
if (codecId === AV_CODEC_ID_AAC && this.supportedCodecs.has(FMP4_CODECS.AAC)) {
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
if (codecId === AV_CODEC_ID_FLAC && this.supportedCodecs.has(FMP4_CODECS.FLAC)) {
|
|
1206
|
+
return true;
|
|
1207
|
+
}
|
|
1208
|
+
if (codecId === AV_CODEC_ID_OPUS && this.supportedCodecs.has(FMP4_CODECS.OPUS)) {
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Process buffer in box mode - buffers until complete boxes are available.
|
|
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
|
+
*
|
|
1228
|
+
* @param chunk - Incoming data chunk from FFmpeg
|
|
1229
|
+
*
|
|
1230
|
+
* @internal
|
|
1231
|
+
*/
|
|
1232
|
+
processBoxMode(chunk) {
|
|
1233
|
+
// If we have an incomplete box from previous chunk, append to it
|
|
1234
|
+
if (this.incompleteBoxBuffer) {
|
|
1235
|
+
chunk = Buffer.concat([this.incompleteBoxBuffer, chunk]);
|
|
1236
|
+
this.incompleteBoxBuffer = null;
|
|
1237
|
+
}
|
|
1238
|
+
let offset = 0;
|
|
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
|
+
}
|
|
1254
|
+
// Read box header
|
|
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
|
+
}
|
|
1278
|
+
// Check if we have the complete box
|
|
1279
|
+
if (offset + boxSize > chunk.length) {
|
|
1280
|
+
// Box body is incomplete - save the whole box for the next chunk
|
|
1281
|
+
this.incompleteBoxBuffer = chunk.subarray(offset);
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
const parsed = {
|
|
1285
|
+
type: chunk.toString('ascii', offset + 4, offset + 8),
|
|
1286
|
+
size: boxSize,
|
|
1287
|
+
headerSize,
|
|
1288
|
+
raw: chunk.subarray(offset, offset + boxSize),
|
|
1289
|
+
};
|
|
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
|
+
}
|
|
1313
|
+
// Move to next box
|
|
1314
|
+
offset += boxSize;
|
|
1315
|
+
}
|
|
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);
|
|
1349
|
+
}
|
|
1350
|
+
if (box.type === 'moov' && !this._moovData) {
|
|
1351
|
+
this._moovData = data.subarray(box.offset, box.offset + box.size);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
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;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
// fragments() generator gets only media fragments (moof+mdat)
|
|
1362
|
+
if (boxes.some((b) => b.type === 'moof')) {
|
|
1363
|
+
this.pushFragment(data, info);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Push a fragment to the fragment queue for the async generator.
|
|
1368
|
+
*
|
|
1369
|
+
* @param data - fMP4 data buffer
|
|
1370
|
+
*
|
|
1371
|
+
* @param info - Parsed box information
|
|
1372
|
+
*
|
|
1373
|
+
* @internal
|
|
1374
|
+
*/
|
|
1375
|
+
pushFragment(data, info) {
|
|
1376
|
+
if (!this.fragmentQueue)
|
|
1377
|
+
return;
|
|
1378
|
+
const fragment = { data, info };
|
|
1379
|
+
if (this.fragmentQueue.resolve) {
|
|
1380
|
+
this.fragmentQueue.resolve({ value: fragment, done: false });
|
|
1381
|
+
this.fragmentQueue.resolve = null;
|
|
1382
|
+
}
|
|
1383
|
+
else {
|
|
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
|
+
}
|
|
1392
|
+
}
|
|
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
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Signal the fragment queue that no more fragments will arrive.
|
|
1415
|
+
*
|
|
1416
|
+
* @internal
|
|
1417
|
+
*/
|
|
1418
|
+
endFragments() {
|
|
1419
|
+
if (!this.fragmentQueue)
|
|
1420
|
+
return;
|
|
1421
|
+
this.fragmentQueue.done = true;
|
|
1422
|
+
if (this.fragmentQueue.resolve) {
|
|
1423
|
+
this.fragmentQueue.resolve({ value: undefined, done: true });
|
|
1424
|
+
this.fragmentQueue.resolve = null;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
//# sourceMappingURL=fmp4-stream.js.map
|