node-av-light 0.0.0-stage → 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 +710 -2
- 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 +167 -4
|
@@ -0,0 +1,2672 @@
|
|
|
1
|
+
import { mkdirSync } from 'fs';
|
|
2
|
+
import { mkdir } from 'fs/promises';
|
|
3
|
+
import { Writable } from 'node:stream';
|
|
4
|
+
import { dirname, resolve } from 'path';
|
|
5
|
+
import { AV_CODEC_FLAG_GLOBAL_HEADER, AV_DISPOSITION_ATTACHED_PIC, AV_DISPOSITION_DEFAULT, AV_NOPTS_VALUE, AV_OPT_TYPE_BOOL, AV_OPT_TYPE_INT, AV_TIME_BASE_Q, AVERROR_EAGAIN, AVERROR_EOF, AVFMT_AVOID_NEG_TS_MAKE_ZERO, AVFMT_FLAG_CUSTOM_IO, AVFMT_GLOBALHEADER, AVFMT_NOFILE, AVFMT_TS_NONSTRICT, AVIO_FLAG_WRITE, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO, } from '../constants/constants.js';
|
|
6
|
+
import { Dictionary } from '../lib/dictionary.js';
|
|
7
|
+
import { FFmpegError } from '../lib/error.js';
|
|
8
|
+
import { FormatContext } from '../lib/format-context.js';
|
|
9
|
+
import { IOContext } from '../lib/io-context.js';
|
|
10
|
+
import { Packet } from '../lib/packet.js';
|
|
11
|
+
import { Rational } from '../lib/rational.js';
|
|
12
|
+
import { SyncQueue, SyncQueueType } from '../lib/sync-queue.js';
|
|
13
|
+
import { avAddQ, avCompareTs, avGetAudioFrameDuration2, avRescaleDelta, avRescaleQ } from '../lib/utilities.js';
|
|
14
|
+
import { MAX_MUXING_QUEUE_SIZE, MUXING_QUEUE_DATA_THRESHOLD, SYNC_BUFFER_DURATION } from './constants.js';
|
|
15
|
+
import { Encoder } from './encoder.js';
|
|
16
|
+
import { IOStream } from './io-stream.js';
|
|
17
|
+
import { AsyncQueue } from './utilities/async-queue.js';
|
|
18
|
+
import { applyContextOptions } from './utilities/context-options.js';
|
|
19
|
+
/**
|
|
20
|
+
* Output formats implemented by libavformat/movenc.c, whose sample durations are 32-bit.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const MOV_FAMILY_FORMATS = new Set(['mov', 'mp4', '3gp', '3g2', 'psp', 'ipod', 'ismv', 'f4v', 'avif']);
|
|
25
|
+
/**
|
|
26
|
+
* Smallest DTS step or packet duration (stream time base ticks) rejected for movenc outputs.
|
|
27
|
+
*
|
|
28
|
+
* movenc aborts the process via av_assert0(next_dts <= INT_MAX) in get_cluster_duration()
|
|
29
|
+
* when it writes a sample table. A step of exactly INT_MAX still trips it once movenc nudges
|
|
30
|
+
* a later DTS by one tick, so the limit itself is already rejected.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
const MOV_SAMPLE_DURATION_LIMIT = 0x7fffffffn;
|
|
35
|
+
/**
|
|
36
|
+
* Packets per bucket of the window of recent packets the dtsForwardThreshold check compares a packet with.
|
|
37
|
+
*
|
|
38
|
+
* Two buckets cover a stream's last 128 to 256 packets. After a gap in a live
|
|
39
|
+
* source, a parser or encoder first releases the frames it held back before
|
|
40
|
+
* the gap: those frames absorb the wait, and the first frame after the gap,
|
|
41
|
+
* though on time, steps forward by the whole gap against them. Against the
|
|
42
|
+
* frames before the gap it does not, and parsers and encoders hold back far
|
|
43
|
+
* fewer frames than the window spans.
|
|
44
|
+
*
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
const FORWARD_WINDOW_BUCKET = 128;
|
|
48
|
+
/**
|
|
49
|
+
* High-level muxer for writing and muxing media files.
|
|
50
|
+
*
|
|
51
|
+
* Provides simplified access to media muxing and file writing operations.
|
|
52
|
+
* Automatically manages header and trailer writing - header is written on first packet,
|
|
53
|
+
* trailer is written on close. Supports lazy initialization for both encoders and streams.
|
|
54
|
+
* Handles stream configuration, packet writing, and format management.
|
|
55
|
+
* Supports files, URLs, and custom I/O with automatic cleanup.
|
|
56
|
+
* Essential component for media encoding pipelines and transcoding.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import { Muxer } from 'node-av/api';
|
|
61
|
+
*
|
|
62
|
+
* // Create output file
|
|
63
|
+
* await using output = await Muxer.open('output.mp4');
|
|
64
|
+
*
|
|
65
|
+
* // Add streams from encoders
|
|
66
|
+
* const videoIdx = output.addStream(videoEncoder);
|
|
67
|
+
* const audioIdx = output.addStream(audioEncoder);
|
|
68
|
+
*
|
|
69
|
+
* // Write packets - header written automatically on first packet
|
|
70
|
+
* await output.writePacket(packet, videoIdx);
|
|
71
|
+
*
|
|
72
|
+
* // Close - trailer written automatically
|
|
73
|
+
* // (automatic with await using)
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* // Stream copy
|
|
79
|
+
* await using input = await Demuxer.open('input.mp4');
|
|
80
|
+
* await using output = await Muxer.open('output.mp4');
|
|
81
|
+
*
|
|
82
|
+
* // Copy stream configuration
|
|
83
|
+
* const videoIdx = output.addStream(input.video());
|
|
84
|
+
*
|
|
85
|
+
* // Process packets - header/trailer handled automatically
|
|
86
|
+
* for await (const packet of input.packets()) {
|
|
87
|
+
* await output.writePacket(packet, videoIdx);
|
|
88
|
+
* packet.free();
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* @see {@link Demuxer} For reading media files
|
|
93
|
+
* @see {@link Encoder} For encoding frames to packets
|
|
94
|
+
* @see {@link FormatContext} For low-level API
|
|
95
|
+
*/
|
|
96
|
+
export class Muxer {
|
|
97
|
+
formatContext;
|
|
98
|
+
options;
|
|
99
|
+
_streams = new Map();
|
|
100
|
+
ioContext;
|
|
101
|
+
customIO = false; // ioContext is callback-backed (Writable/IOOutputCallbacks) - freed, never closed
|
|
102
|
+
headerWritten = false;
|
|
103
|
+
headerWritePromise;
|
|
104
|
+
trailerWritten = false;
|
|
105
|
+
isClosed = false;
|
|
106
|
+
syncQueue; // FFmpeg's native sync queue for packet interleaving
|
|
107
|
+
sqPacket; // Reusable packet for sync queue receive
|
|
108
|
+
containerMetadataCopied = false; // Track if container metadata has been copied
|
|
109
|
+
writeQueue; // Optional async queue for serialized writes
|
|
110
|
+
writeWorkerPromise; // Background worker promise
|
|
111
|
+
writeWorkerError; // First error from the write worker - poisons subsequent writes and close()
|
|
112
|
+
signal;
|
|
113
|
+
movSampleLimit = false; // Output is a movenc format with 32-bit sample durations (set before the header write)
|
|
114
|
+
tsNonStrict = false; // Output allows equal consecutive DTS (AVFMT_TS_NONSTRICT, set before the header write)
|
|
115
|
+
movZeroStart = false; // movenc puts each track's first sample at the output's start (no edit list, set after the header write)
|
|
116
|
+
interleaveDeltaUs = 0n; // libavformat's max_interleave_delta in µs (set after the header write)
|
|
117
|
+
trailerDiscarded = false; // close() skips the trailer, see discardOnClose()
|
|
118
|
+
forwardThresholdMs = 0; // dtsForwardThreshold in ms, 0 = off
|
|
119
|
+
clock = () => performance.now(); // Monotonic ms clock for packet arrival times
|
|
120
|
+
packetObserver; // Follows the accepted packets (FMP4Stream's maxPacketAge)
|
|
121
|
+
/**
|
|
122
|
+
* @param options - Media output options
|
|
123
|
+
*
|
|
124
|
+
* @throws {RangeError} If dtsBackwardThreshold or dtsForwardThreshold is not a finite number >= 0
|
|
125
|
+
*
|
|
126
|
+
* @internal
|
|
127
|
+
*/
|
|
128
|
+
constructor(options) {
|
|
129
|
+
for (const [name, threshold] of [
|
|
130
|
+
['dtsBackwardThreshold', options?.dtsBackwardThreshold],
|
|
131
|
+
['dtsForwardThreshold', options?.dtsForwardThreshold],
|
|
132
|
+
]) {
|
|
133
|
+
if (threshold !== undefined && (!Number.isFinite(threshold) || threshold < 0)) {
|
|
134
|
+
throw new RangeError(`${name} must be a finite number of seconds >= 0, got ${threshold}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
this.options = {
|
|
138
|
+
copyInitialNonkeyframes: false,
|
|
139
|
+
exitOnError: true,
|
|
140
|
+
useSyncQueue: true,
|
|
141
|
+
useAsyncWrite: true,
|
|
142
|
+
...options,
|
|
143
|
+
};
|
|
144
|
+
this.forwardThresholdMs = (options?.dtsForwardThreshold ?? 0) * 1000;
|
|
145
|
+
this.formatContext = new FormatContext();
|
|
146
|
+
}
|
|
147
|
+
static async open(target, options) {
|
|
148
|
+
const output = new Muxer(options);
|
|
149
|
+
try {
|
|
150
|
+
if (typeof target === 'string') {
|
|
151
|
+
// File or stream URL - resolve relative paths and create directories
|
|
152
|
+
// Check if it's a URL (starts with protocol://) or a file path
|
|
153
|
+
const isUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(target);
|
|
154
|
+
const resolvedTarget = isUrl ? target : resolve(target);
|
|
155
|
+
// Create directory structure for local files (not URLs)
|
|
156
|
+
if (!isUrl && target !== '') {
|
|
157
|
+
const dir = dirname(resolvedTarget);
|
|
158
|
+
await mkdir(dir, { recursive: true });
|
|
159
|
+
}
|
|
160
|
+
// Allocate output context
|
|
161
|
+
const ret = output.formatContext.allocOutputContext2(null, options?.format ?? null, resolvedTarget === '' ? null : resolvedTarget);
|
|
162
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
163
|
+
// Set format options if provided
|
|
164
|
+
if (options?.options) {
|
|
165
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
166
|
+
const ret = output.formatContext.setOption(key, value);
|
|
167
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Check if we need to open IO
|
|
171
|
+
const oformat = output.formatContext.oformat;
|
|
172
|
+
if (resolvedTarget && oformat && !oformat.hasFlags(AVFMT_NOFILE)) {
|
|
173
|
+
// For file-based formats, we need to open the file using avio_open2
|
|
174
|
+
// FFmpeg will manage the AVIOContext internally
|
|
175
|
+
output.ioContext = new IOContext();
|
|
176
|
+
const openRet = await output.ioContext.open2(resolvedTarget, AVIO_FLAG_WRITE);
|
|
177
|
+
FFmpegError.throwIfError(openRet, `Failed to open output file: ${resolvedTarget}`);
|
|
178
|
+
output.formatContext.pb = output.ioContext;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else if (target instanceof Writable) {
|
|
182
|
+
// Writable stream - format is required
|
|
183
|
+
if (!options?.format) {
|
|
184
|
+
throw new Error('Format must be specified for Writable stream output');
|
|
185
|
+
}
|
|
186
|
+
const ret = output.formatContext.allocOutputContext2(null, options.format, null);
|
|
187
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
188
|
+
// Set format options if provided
|
|
189
|
+
if (options?.options) {
|
|
190
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
191
|
+
const ret = output.formatContext.setOption(key, value);
|
|
192
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
output.ioContext = IOStream.createOutput(target, options);
|
|
196
|
+
output.customIO = true;
|
|
197
|
+
output.formatContext.pb = output.ioContext;
|
|
198
|
+
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Custom IO with callbacks - format is required
|
|
202
|
+
if (!options?.format) {
|
|
203
|
+
throw new Error('Format must be specified for custom IO');
|
|
204
|
+
}
|
|
205
|
+
const ret = output.formatContext.allocOutputContext2(null, options.format, null);
|
|
206
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
207
|
+
// Set format options if provided
|
|
208
|
+
if (options?.options) {
|
|
209
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
210
|
+
const ret = output.formatContext.setOption(key, value);
|
|
211
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
output.ioContext = IOStream.createOutput(target, options);
|
|
215
|
+
output.customIO = true;
|
|
216
|
+
output.formatContext.pb = output.ioContext;
|
|
217
|
+
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
218
|
+
}
|
|
219
|
+
if (options?.signal) {
|
|
220
|
+
options.signal.throwIfAborted();
|
|
221
|
+
output.signal = options.signal;
|
|
222
|
+
}
|
|
223
|
+
return output;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// Cleanup on error
|
|
227
|
+
if (output.ioContext) {
|
|
228
|
+
try {
|
|
229
|
+
// Clear the pb reference first - freeContext() below closes a pb that
|
|
230
|
+
// is still set, which would double-free the context released here
|
|
231
|
+
output.formatContext.pb = null;
|
|
232
|
+
if (output.customIO) {
|
|
233
|
+
// For custom IO with callbacks, free the context
|
|
234
|
+
output.ioContext.freeContext();
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
// For file-based IO, close the file handle
|
|
238
|
+
await output.ioContext.closep();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Ignore errors
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (output.formatContext) {
|
|
246
|
+
try {
|
|
247
|
+
output.formatContext.freeContext();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Ignore errors
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
static openSync(target, options) {
|
|
257
|
+
const output = new Muxer(options);
|
|
258
|
+
try {
|
|
259
|
+
if (typeof target === 'string') {
|
|
260
|
+
// File or stream URL - resolve relative paths and create directories
|
|
261
|
+
// Check if it's a URL (starts with protocol://) or a file path
|
|
262
|
+
const isUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(target);
|
|
263
|
+
const resolvedTarget = isUrl ? target : resolve(target);
|
|
264
|
+
// Create directory structure for local files (not URLs)
|
|
265
|
+
if (!isUrl && target !== '') {
|
|
266
|
+
const dir = dirname(resolvedTarget);
|
|
267
|
+
mkdirSync(dir, { recursive: true });
|
|
268
|
+
}
|
|
269
|
+
// Allocate output context
|
|
270
|
+
const ret = output.formatContext.allocOutputContext2(null, options?.format ?? null, resolvedTarget === '' ? null : resolvedTarget);
|
|
271
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
272
|
+
// Set format options if provided
|
|
273
|
+
if (options?.options) {
|
|
274
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
275
|
+
const ret = output.formatContext.setOption(key, value);
|
|
276
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Check if we need to open IO
|
|
280
|
+
const oformat = output.formatContext.oformat;
|
|
281
|
+
if (resolvedTarget && oformat && !oformat.hasFlags(AVFMT_NOFILE)) {
|
|
282
|
+
// For file-based formats, we need to open the file using avio_open2
|
|
283
|
+
// FFmpeg will manage the AVIOContext internally
|
|
284
|
+
output.ioContext = new IOContext();
|
|
285
|
+
const openRet = output.ioContext.open2Sync(resolvedTarget, AVIO_FLAG_WRITE);
|
|
286
|
+
FFmpegError.throwIfError(openRet, `Failed to open output file: ${resolvedTarget}`);
|
|
287
|
+
output.formatContext.pb = output.ioContext;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
else if (target instanceof Writable) {
|
|
291
|
+
// Writable stream - format is required
|
|
292
|
+
if (!options?.format) {
|
|
293
|
+
throw new Error('Format must be specified for Writable stream output');
|
|
294
|
+
}
|
|
295
|
+
const ret = output.formatContext.allocOutputContext2(null, options.format, null);
|
|
296
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
297
|
+
// Set format options if provided
|
|
298
|
+
if (options?.options) {
|
|
299
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
300
|
+
const ret = output.formatContext.setOption(key, value);
|
|
301
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
output.ioContext = IOStream.createOutput(target, options);
|
|
305
|
+
output.customIO = true;
|
|
306
|
+
output.formatContext.pb = output.ioContext;
|
|
307
|
+
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// Custom IO with callbacks - format is required
|
|
311
|
+
if (!options?.format) {
|
|
312
|
+
throw new Error('Format must be specified for custom IO');
|
|
313
|
+
}
|
|
314
|
+
const ret = output.formatContext.allocOutputContext2(null, options.format, null);
|
|
315
|
+
FFmpegError.throwIfError(ret, 'Failed to allocate output context');
|
|
316
|
+
// Set format options if provided
|
|
317
|
+
if (options?.options) {
|
|
318
|
+
for (const [key, value] of Object.entries(options.options)) {
|
|
319
|
+
const ret = output.formatContext.setOption(key, value);
|
|
320
|
+
FFmpegError.throwIfError(ret, `Failed to set muxer option '${key}'`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
output.ioContext = IOStream.createOutput(target, options);
|
|
324
|
+
output.customIO = true;
|
|
325
|
+
output.formatContext.pb = output.ioContext;
|
|
326
|
+
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
327
|
+
}
|
|
328
|
+
if (options?.signal) {
|
|
329
|
+
options.signal.throwIfAborted();
|
|
330
|
+
output.signal = options.signal;
|
|
331
|
+
}
|
|
332
|
+
return output;
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
// Cleanup on error
|
|
336
|
+
if (output.ioContext) {
|
|
337
|
+
try {
|
|
338
|
+
// Clear the pb reference first - freeContext() below closes a pb that
|
|
339
|
+
// is still set, which would double-free the context released here
|
|
340
|
+
output.formatContext.pb = null;
|
|
341
|
+
if (output.customIO) {
|
|
342
|
+
// For custom IO with callbacks, free the context
|
|
343
|
+
output.ioContext.freeContext();
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// For file-based IO, close the file handle
|
|
347
|
+
output.ioContext.closepSync();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Ignore errors
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (output.formatContext) {
|
|
355
|
+
try {
|
|
356
|
+
output.formatContext.freeContext();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Ignore errors
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Check if output is open.
|
|
367
|
+
*
|
|
368
|
+
* @example
|
|
369
|
+
* ```typescript
|
|
370
|
+
* if (!output.isOutputOpen) {
|
|
371
|
+
* console.log('Output is not open');
|
|
372
|
+
* }
|
|
373
|
+
* ```
|
|
374
|
+
*/
|
|
375
|
+
get isOpen() {
|
|
376
|
+
return !this.isClosed;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Check if output is initialized.
|
|
380
|
+
*
|
|
381
|
+
* All streams have been initialized.
|
|
382
|
+
* This occurs after the first packet has been written to each stream.
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* ```typescript
|
|
386
|
+
* if (!output.isOutputInitialized) {
|
|
387
|
+
* console.log('Output is not initialized');
|
|
388
|
+
* }
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
get streamsInitialized() {
|
|
392
|
+
if (this._streams.size === 0) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
if (this.isClosed) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
return Array.from(this._streams).every(([_, stream]) => stream.initialized);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Get all streams in the media.
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```typescript
|
|
405
|
+
* for (const stream of output.streams) {
|
|
406
|
+
* console.log(`Stream ${stream.index}: ${stream.codecpar.codecType}`);
|
|
407
|
+
* }
|
|
408
|
+
* ```
|
|
409
|
+
*/
|
|
410
|
+
get streams() {
|
|
411
|
+
return this.formatContext.streams;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Get format name.
|
|
415
|
+
*
|
|
416
|
+
* Returns 'unknown' if output is closed or format is not available.
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```typescript
|
|
420
|
+
* console.log(`Format: ${output.formatName}`); // "mov,mp4,m4a,3gp,3g2,mj2"
|
|
421
|
+
* ```
|
|
422
|
+
*/
|
|
423
|
+
get formatName() {
|
|
424
|
+
if (this.isClosed) {
|
|
425
|
+
return 'unknown';
|
|
426
|
+
}
|
|
427
|
+
return this.formatContext.oformat?.name ?? 'unknown';
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Get format long name.
|
|
431
|
+
*
|
|
432
|
+
* Returns 'Unknown Format' if output is closed or format is not available.
|
|
433
|
+
*
|
|
434
|
+
* @example
|
|
435
|
+
* ```typescript
|
|
436
|
+
* console.log(`Format: ${output.formatLongName}`); // "QuickTime / MOV"
|
|
437
|
+
* ```
|
|
438
|
+
*/
|
|
439
|
+
get formatLongName() {
|
|
440
|
+
if (this.isClosed) {
|
|
441
|
+
return 'Unknown Format';
|
|
442
|
+
}
|
|
443
|
+
return this.formatContext.oformat?.longName ?? 'Unknown Format';
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get MIME type of the output format.
|
|
447
|
+
*
|
|
448
|
+
* Returns format's native MIME type.
|
|
449
|
+
* Returns null if output is closed or format is not available.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* ```typescript
|
|
453
|
+
* console.log(mp4Output.mimeType); // "video/mp4"
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
get mimeType() {
|
|
457
|
+
if (this.isClosed) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
return this.formatContext.oformat?.mimeType ?? null;
|
|
461
|
+
}
|
|
462
|
+
addStream(streamOrEncoder, options) {
|
|
463
|
+
if (this.isClosed) {
|
|
464
|
+
throw new Error('Muxer is closed');
|
|
465
|
+
}
|
|
466
|
+
if (this.headerWritten) {
|
|
467
|
+
throw new Error('Cannot add streams after packets have been written');
|
|
468
|
+
}
|
|
469
|
+
const outStream = this.formatContext.newStream(null);
|
|
470
|
+
if (!outStream) {
|
|
471
|
+
throw new Error('Failed to create new stream');
|
|
472
|
+
}
|
|
473
|
+
// Determine if first parameter is Encoder or Stream
|
|
474
|
+
const isEncoderFirst = streamOrEncoder instanceof Encoder;
|
|
475
|
+
let stream;
|
|
476
|
+
let encoder;
|
|
477
|
+
if (isEncoderFirst) {
|
|
478
|
+
// First parameter is Encoder
|
|
479
|
+
encoder = streamOrEncoder;
|
|
480
|
+
stream = options?.inputStream;
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
// First parameter is Stream
|
|
484
|
+
stream = streamOrEncoder;
|
|
485
|
+
encoder = options?.encoder;
|
|
486
|
+
}
|
|
487
|
+
const isStreamCopy = !encoder;
|
|
488
|
+
// Auto-set GLOBAL_HEADER flag if format requires it
|
|
489
|
+
if (encoder) {
|
|
490
|
+
const oformat = this.formatContext.oformat;
|
|
491
|
+
if (oformat?.hasFlags(AVFMT_GLOBALHEADER)) {
|
|
492
|
+
encoder.setCodecFlags(AV_CODEC_FLAG_GLOBAL_HEADER);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// For stream copy, initialize immediately since we have all the info
|
|
496
|
+
if (isStreamCopy) {
|
|
497
|
+
if (!stream) {
|
|
498
|
+
throw new Error('Stream copy mode requires an input stream');
|
|
499
|
+
}
|
|
500
|
+
const ret = stream.codecpar.copy(outStream.codecpar);
|
|
501
|
+
FFmpegError.throwIfError(ret, 'Failed to copy codec parameters');
|
|
502
|
+
// Keep the source codec tag only where the output format accepts it, as
|
|
503
|
+
// FFmpeg CLI's streamcopy_init() does; otherwise let the muxer pick one.
|
|
504
|
+
// E.g. mp4's 'mp4a' for AAC fails the Matroska header as invalid data.
|
|
505
|
+
const oformat = this.formatContext.oformat;
|
|
506
|
+
const { codecTag, codecId } = outStream.codecpar;
|
|
507
|
+
if (codecTag && oformat && oformat.getCodecId(codecTag) !== codecId && oformat.getCodecTag(codecId) !== null) {
|
|
508
|
+
outStream.codecpar.codecTag = 0;
|
|
509
|
+
}
|
|
510
|
+
// Set the timebases
|
|
511
|
+
const sourceTimeBase = stream.timeBase;
|
|
512
|
+
outStream.timeBase = new Rational(stream.timeBase.num, stream.timeBase.den);
|
|
513
|
+
// Copy frame rates and aspect ratios
|
|
514
|
+
outStream.avgFrameRate = stream.avgFrameRate;
|
|
515
|
+
if (stream.sampleAspectRatio.num > 0) {
|
|
516
|
+
outStream.sampleAspectRatio = stream.sampleAspectRatio;
|
|
517
|
+
}
|
|
518
|
+
outStream.rFrameRate = stream.rFrameRate;
|
|
519
|
+
// Copy duration
|
|
520
|
+
if (stream.duration > 0n) {
|
|
521
|
+
outStream.duration = stream.duration;
|
|
522
|
+
}
|
|
523
|
+
// Copy metadata
|
|
524
|
+
const metadata = stream.metadata;
|
|
525
|
+
if (metadata) {
|
|
526
|
+
outStream.metadata = metadata;
|
|
527
|
+
}
|
|
528
|
+
// Copy disposition
|
|
529
|
+
outStream.disposition = stream.disposition;
|
|
530
|
+
// Copy coded_side_data (HDR/Dolby Vision)
|
|
531
|
+
// Iterate over all side_data entries and copy them
|
|
532
|
+
const allSideData = stream.codecpar.getAllCodedSideData();
|
|
533
|
+
for (const sd of allSideData) {
|
|
534
|
+
outStream.codecpar.addCodedSideData(sd.type, sd.data);
|
|
535
|
+
}
|
|
536
|
+
this._streams.set(outStream.index, {
|
|
537
|
+
initialized: true,
|
|
538
|
+
outputStream: outStream,
|
|
539
|
+
encoder: undefined,
|
|
540
|
+
sourceTimeBase,
|
|
541
|
+
isStreamCopy: true,
|
|
542
|
+
sqIdxMux: -1, // Will be set if sync queue is needed
|
|
543
|
+
preMuxQueue: [],
|
|
544
|
+
preMuxArrivals: [],
|
|
545
|
+
sqArrivals: [],
|
|
546
|
+
preMuxQueueDataSize: 0,
|
|
547
|
+
eofReceived: false,
|
|
548
|
+
lastMuxDts: AV_NOPTS_VALUE,
|
|
549
|
+
tsRescaleDeltaLast: { value: AV_NOPTS_VALUE },
|
|
550
|
+
streamcopyStarted: false,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
// Encoding path - lazy initialization
|
|
555
|
+
// stream is optional here - if provided, we copy metadata/disposition
|
|
556
|
+
// If not provided (encoder-only mode), stream will be initialized from first encoded frame
|
|
557
|
+
this._streams.set(outStream.index, {
|
|
558
|
+
initialized: false,
|
|
559
|
+
outputStream: outStream,
|
|
560
|
+
input: stream
|
|
561
|
+
? {
|
|
562
|
+
metadata: stream.metadata,
|
|
563
|
+
disposition: stream.disposition,
|
|
564
|
+
codedSideData: stream.codecpar.getAllCodedSideData(),
|
|
565
|
+
duration: stream.duration,
|
|
566
|
+
timeBase: stream.timeBase,
|
|
567
|
+
}
|
|
568
|
+
: undefined,
|
|
569
|
+
encoder,
|
|
570
|
+
bsf: options?.bsf,
|
|
571
|
+
sourceTimeBase: undefined, // Will be set on initialization
|
|
572
|
+
isStreamCopy: false,
|
|
573
|
+
sqIdxMux: -1, // Will be set if sync queue is needed
|
|
574
|
+
preMuxQueue: [],
|
|
575
|
+
preMuxArrivals: [],
|
|
576
|
+
sqArrivals: [],
|
|
577
|
+
preMuxQueueDataSize: 0,
|
|
578
|
+
eofReceived: false,
|
|
579
|
+
lastMuxDts: AV_NOPTS_VALUE,
|
|
580
|
+
tsRescaleDeltaLast: { value: AV_NOPTS_VALUE },
|
|
581
|
+
streamcopyStarted: false,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return outStream.index;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Get output stream by index.
|
|
588
|
+
*
|
|
589
|
+
* Returns the stream at the specified index.
|
|
590
|
+
* Use the stream index returned by addStream().
|
|
591
|
+
*
|
|
592
|
+
* @param index - Stream index (returned by addStream)
|
|
593
|
+
*
|
|
594
|
+
* @returns Stream or undefined if index is invalid
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```typescript
|
|
598
|
+
* const output = await Muxer.open('output.mp4');
|
|
599
|
+
* const videoIdx = output.addStream(encoder);
|
|
600
|
+
*
|
|
601
|
+
* // Get the output stream to inspect codec parameters
|
|
602
|
+
* const stream = output.getStream(videoIdx);
|
|
603
|
+
* if (stream) {
|
|
604
|
+
* console.log(`Output codec: ${stream.codecpar.codecId}`);
|
|
605
|
+
* }
|
|
606
|
+
* ```
|
|
607
|
+
*
|
|
608
|
+
* @see {@link addStream} For adding streams
|
|
609
|
+
* @see {@link video} For getting video streams
|
|
610
|
+
* @see {@link audio} For getting audio streams
|
|
611
|
+
*/
|
|
612
|
+
getStream(index) {
|
|
613
|
+
const streams = this.formatContext.streams;
|
|
614
|
+
if (!streams || index < 0 || index >= streams.length) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
return streams[index];
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Get video stream by index.
|
|
621
|
+
*
|
|
622
|
+
* Returns the nth video stream (0-based index).
|
|
623
|
+
* Returns undefined if stream doesn't exist.
|
|
624
|
+
*
|
|
625
|
+
* @param index - Video stream index (default: 0)
|
|
626
|
+
*
|
|
627
|
+
* @returns Video stream or undefined
|
|
628
|
+
*
|
|
629
|
+
* @example
|
|
630
|
+
* ```typescript
|
|
631
|
+
* const output = await Muxer.open('output.mp4');
|
|
632
|
+
* output.addStream(videoEncoder);
|
|
633
|
+
*
|
|
634
|
+
* // Get first video stream
|
|
635
|
+
* const videoStream = output.video();
|
|
636
|
+
* if (videoStream) {
|
|
637
|
+
* console.log(`Video output: ${videoStream.codecpar.width}x${videoStream.codecpar.height}`);
|
|
638
|
+
* }
|
|
639
|
+
* ```
|
|
640
|
+
*
|
|
641
|
+
* @see {@link audio} For audio streams
|
|
642
|
+
* @see {@link getStream} For direct stream access
|
|
643
|
+
*/
|
|
644
|
+
video(index = 0) {
|
|
645
|
+
const streams = this.formatContext.streams;
|
|
646
|
+
if (!streams)
|
|
647
|
+
return undefined;
|
|
648
|
+
const videoStreams = streams.filter((s) => s.codecpar.codecType === AVMEDIA_TYPE_VIDEO);
|
|
649
|
+
return videoStreams[index];
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Get audio stream by index.
|
|
653
|
+
*
|
|
654
|
+
* Returns the nth audio stream (0-based index).
|
|
655
|
+
* Returns undefined if stream doesn't exist.
|
|
656
|
+
*
|
|
657
|
+
* @param index - Audio stream index (default: 0)
|
|
658
|
+
*
|
|
659
|
+
* @returns Audio stream or undefined
|
|
660
|
+
*
|
|
661
|
+
* @example
|
|
662
|
+
* ```typescript
|
|
663
|
+
* const output = await Muxer.open('output.mp4');
|
|
664
|
+
* output.addStream(audioEncoder);
|
|
665
|
+
*
|
|
666
|
+
* // Get first audio stream
|
|
667
|
+
* const audioStream = output.audio();
|
|
668
|
+
* if (audioStream) {
|
|
669
|
+
* console.log(`Audio output: ${audioStream.codecpar.sampleRate}Hz`);
|
|
670
|
+
* }
|
|
671
|
+
* ```
|
|
672
|
+
*
|
|
673
|
+
* @see {@link video} For video streams
|
|
674
|
+
* @see {@link getStream} For direct stream access
|
|
675
|
+
*/
|
|
676
|
+
audio(index = 0) {
|
|
677
|
+
const streams = this.formatContext.streams;
|
|
678
|
+
if (!streams)
|
|
679
|
+
return undefined;
|
|
680
|
+
const audioStreams = streams.filter((s) => s.codecpar.codecType === AVMEDIA_TYPE_AUDIO);
|
|
681
|
+
return audioStreams[index];
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Get output format.
|
|
685
|
+
*
|
|
686
|
+
* Returns the output format used for muxing.
|
|
687
|
+
* May be null if format context not initialized.
|
|
688
|
+
*
|
|
689
|
+
* @returns Output format or null
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* ```typescript
|
|
693
|
+
* const output = await Muxer.open('output.mp4');
|
|
694
|
+
* const format = output.outputFormat();
|
|
695
|
+
* if (format) {
|
|
696
|
+
* console.log(`Output format: ${format.name}`);
|
|
697
|
+
* }
|
|
698
|
+
* ```
|
|
699
|
+
*
|
|
700
|
+
* @see {@link OutputFormat} For format details
|
|
701
|
+
*/
|
|
702
|
+
outputFormat() {
|
|
703
|
+
return this.formatContext.oformat;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Write a packet to the output.
|
|
707
|
+
*
|
|
708
|
+
* Writes muxed packet to the specified stream.
|
|
709
|
+
* Automatically handles:
|
|
710
|
+
* - Stream initialization on first packet (lazy initialization)
|
|
711
|
+
* - Codec parameter configuration from encoder or input stream
|
|
712
|
+
* - Header writing on first packet
|
|
713
|
+
* - Timestamp rescaling between source and output timebases
|
|
714
|
+
* - Sync queue for proper interleaving
|
|
715
|
+
*
|
|
716
|
+
* For encoder sources, the encoder must have processed at least one frame
|
|
717
|
+
* before packets can be written (encoder must be initialized).
|
|
718
|
+
*
|
|
719
|
+
* Uses FFmpeg CLI's sync queue pattern: buffers packets per stream and writes
|
|
720
|
+
* them in DTS order using av_compare_ts for timebase-aware comparison.
|
|
721
|
+
*
|
|
722
|
+
* To signal EOF for a stream, pass null as the packet.
|
|
723
|
+
* This tells the muxer that no more packets will be sent for this stream.
|
|
724
|
+
* The trailer is written only when close() is called.
|
|
725
|
+
*
|
|
726
|
+
* Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
|
|
727
|
+
*
|
|
728
|
+
* @param packet - Packet to write (or null to signal EOF for the stream)
|
|
729
|
+
*
|
|
730
|
+
* @param streamIndex - Target stream index
|
|
731
|
+
*
|
|
732
|
+
* @throws {Error} If stream invalid or encoder not initialized
|
|
733
|
+
*
|
|
734
|
+
* @throws {Error} On a timestamp discontinuity the output cannot take (see {@link MuxerOptions.dtsBackwardThreshold}).
|
|
735
|
+
* With the background write queue (more than one stream) it surfaces from a later writePacket() or close().
|
|
736
|
+
*
|
|
737
|
+
* @throws {FFmpegError} If write fails
|
|
738
|
+
*
|
|
739
|
+
* @example
|
|
740
|
+
* ```typescript
|
|
741
|
+
* // Write encoded packet - header written automatically on first packet
|
|
742
|
+
* const packet = await encoder.encode(frame);
|
|
743
|
+
* if (packet) {
|
|
744
|
+
* await output.writePacket(packet, videoIdx);
|
|
745
|
+
* packet.free();
|
|
746
|
+
* }
|
|
747
|
+
* ```
|
|
748
|
+
*
|
|
749
|
+
* @example
|
|
750
|
+
* ```typescript
|
|
751
|
+
* // Stream copy with packet processing
|
|
752
|
+
* for await (const packet of input.packets()) {
|
|
753
|
+
* if (packet.streamIndex === inputVideoIdx) {
|
|
754
|
+
* await output.writePacket(packet, outputVideoIdx);
|
|
755
|
+
* }
|
|
756
|
+
* packet.free();
|
|
757
|
+
* }
|
|
758
|
+
* ```
|
|
759
|
+
*
|
|
760
|
+
* @see {@link addStream} For adding streams
|
|
761
|
+
*/
|
|
762
|
+
async writePacket(packet, streamIndex) {
|
|
763
|
+
this.signal?.throwIfAborted();
|
|
764
|
+
// A previous background write failed - surface it instead of buffering more packets
|
|
765
|
+
if (this.writeWorkerError) {
|
|
766
|
+
throw this.writeWorkerError;
|
|
767
|
+
}
|
|
768
|
+
if (this.isClosed) {
|
|
769
|
+
throw new Error('Muxer is closed');
|
|
770
|
+
}
|
|
771
|
+
if (this.trailerWritten) {
|
|
772
|
+
throw new Error('Cannot write packets after output is finalized');
|
|
773
|
+
}
|
|
774
|
+
if (!this._streams.get(streamIndex)) {
|
|
775
|
+
throw new Error(`Invalid stream index: ${streamIndex}`);
|
|
776
|
+
}
|
|
777
|
+
// Initialize any encoder streams that are ready
|
|
778
|
+
for (const streamInfo of this._streams.values()) {
|
|
779
|
+
if (!streamInfo.initialized && streamInfo.encoder) {
|
|
780
|
+
const encoder = streamInfo.encoder;
|
|
781
|
+
const codecContext = encoder.getCodecContext();
|
|
782
|
+
// Skip if encoder not ready yet
|
|
783
|
+
if (!encoder.isEncoderInitialized || !codecContext) {
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
// If a trailing bitstream filter is present, wait until it is initialized
|
|
787
|
+
// so its output parameters (e.g. modified extradata) are available.
|
|
788
|
+
if (streamInfo.bsf && !streamInfo.bsf.isInitialized) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
// This encoder is ready, initialize it now
|
|
792
|
+
// Read codecType from codecContext, not from stream (which is still uninitialized)
|
|
793
|
+
// const codecType = codecContext.codecType;
|
|
794
|
+
// 1. Set stream timebase
|
|
795
|
+
if (streamInfo.outputStream.timeBase.num <= 0 || streamInfo.outputStream.timeBase.den <= 0) {
|
|
796
|
+
const tb = avAddQ(codecContext.timeBase, { num: 0, den: 1 });
|
|
797
|
+
streamInfo.outputStream.timeBase = new Rational(tb.num, tb.den);
|
|
798
|
+
}
|
|
799
|
+
// 2. Set stream avg_frame_rate, r_frame_rate and sample_aspect_ratio
|
|
800
|
+
const fr = codecContext.framerate;
|
|
801
|
+
streamInfo.outputStream.avgFrameRate = new Rational(fr.num, fr.den);
|
|
802
|
+
streamInfo.outputStream.sampleAspectRatio = codecContext.sampleAspectRatio;
|
|
803
|
+
// 3. Copy codec parameters from encoder context
|
|
804
|
+
const ret = streamInfo.outputStream.codecpar.fromContext(codecContext);
|
|
805
|
+
FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
|
|
806
|
+
// 3b. Overlay the trailing bitstream filter's output parameters, so
|
|
807
|
+
// container-level fields (e.g. extradata/level rewritten by h264_metadata)
|
|
808
|
+
// reflect the filter's output rather than the raw encoder output.
|
|
809
|
+
const bsfParams = streamInfo.bsf?.outputCodecParameters;
|
|
810
|
+
if (bsfParams) {
|
|
811
|
+
const bsfRet = bsfParams.copy(streamInfo.outputStream.codecpar);
|
|
812
|
+
FFmpegError.throwIfError(bsfRet, 'Failed to copy codec parameters from bitstream filter');
|
|
813
|
+
}
|
|
814
|
+
// 4. Copy metadata from input stream
|
|
815
|
+
const input = streamInfo.input;
|
|
816
|
+
if (input) {
|
|
817
|
+
if (input.metadata) {
|
|
818
|
+
streamInfo.outputStream.metadata = input.metadata;
|
|
819
|
+
}
|
|
820
|
+
// 5. Copy disposition from input stream
|
|
821
|
+
streamInfo.outputStream.disposition = input.disposition;
|
|
822
|
+
// 5b. Copy coded_side_data from input stream (display matrix / rotation,
|
|
823
|
+
// HDR mastering display & content light level). The encoder path only
|
|
824
|
+
// copied codec parameters, so without this these are lost on transcode
|
|
825
|
+
// (e.g. a portrait phone video would play sideways). Mirrors the stream
|
|
826
|
+
// copy path.
|
|
827
|
+
for (const sd of input.codedSideData) {
|
|
828
|
+
streamInfo.outputStream.codecpar.addCodedSideData(sd.type, sd.data);
|
|
829
|
+
}
|
|
830
|
+
// 6. Copy duration hint from input stream
|
|
831
|
+
if (input.duration > 0n) {
|
|
832
|
+
const outputTb = streamInfo.outputStream.timeBase;
|
|
833
|
+
streamInfo.outputStream.duration = avRescaleQ(input.duration, input.timeBase, outputTb);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Update the source timebase for timestamp rescaling
|
|
837
|
+
streamInfo.sourceTimeBase = codecContext.timeBase;
|
|
838
|
+
// Mark as initialized
|
|
839
|
+
streamInfo.initialized = true;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
const streamInfo = this._streams.get(streamIndex);
|
|
843
|
+
// Handle NULL packet - signals EOF for this stream (FFmpeg pattern: av_interleaved_write_frame(s, NULL))
|
|
844
|
+
// FFmpeg's behavior:
|
|
845
|
+
// - If muxer not started (uninitialized streams), buffer NULL in PreMuxQueue as EOF marker
|
|
846
|
+
// - If muxer started, send NULL to SyncQueue to signal EOF and flush
|
|
847
|
+
if (!packet) {
|
|
848
|
+
// Mark stream as EOF received
|
|
849
|
+
streamInfo.eofReceived = true;
|
|
850
|
+
// Check if any streams are still uninitialized (PreMuxQueue phase)
|
|
851
|
+
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
852
|
+
// PHASE 1: Before muxer starts - buffer NULL packet in PreMuxQueue
|
|
853
|
+
// This matches FFmpeg's mux_queue_packet() which writes NULL to PreMuxQueue FIFO
|
|
854
|
+
if (uninitialized || this.headerWritePromise) {
|
|
855
|
+
// Buffer NULL as EOF marker (no size contribution)
|
|
856
|
+
streamInfo.preMuxQueue.push(null);
|
|
857
|
+
streamInfo.preMuxArrivals.push(0);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// PHASE 2: After muxer started - send EOF to SyncQueue and flush
|
|
861
|
+
if (!this.headerWritten) {
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
// If using SyncQueue, send EOF for this stream
|
|
865
|
+
if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
|
|
866
|
+
// Send NULL to signal EOF to sync queue
|
|
867
|
+
// Native side handles null correctly (sets sqframe.p = nullptr)
|
|
868
|
+
const ret = this.syncQueue.send(streamInfo.sqIdxMux, null);
|
|
869
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
870
|
+
if (this.options.exitOnError) {
|
|
871
|
+
FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue');
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// Receive and write any remaining packets from sync queue
|
|
875
|
+
while (!this.isClosed) {
|
|
876
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
877
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
878
|
+
break; // No more packets ready
|
|
879
|
+
}
|
|
880
|
+
if (recvRet === AVERROR_EOF) {
|
|
881
|
+
break; // All streams finished
|
|
882
|
+
}
|
|
883
|
+
if (recvRet >= 0) {
|
|
884
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
885
|
+
const pkt = this.sqPacket.clone();
|
|
886
|
+
if (!pkt) {
|
|
887
|
+
throw new Error('Failed to clone packet from sync queue');
|
|
888
|
+
}
|
|
889
|
+
pkt.streamIndex = recvRet;
|
|
890
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return; // EOF signaled, nothing more to do
|
|
895
|
+
}
|
|
896
|
+
// Clone packet immediately - we will modify it and caller retains ownership
|
|
897
|
+
const clonedPacket = packet.clone();
|
|
898
|
+
if (!clonedPacket) {
|
|
899
|
+
throw new Error('Failed to clone packet for writing');
|
|
900
|
+
}
|
|
901
|
+
// The forward check compares DTS steps with the time between packets as
|
|
902
|
+
// they reach the muxer. Taken here and carried through the pre-mux, sync
|
|
903
|
+
// and write queues, so time a packet spends queued does not count.
|
|
904
|
+
const arrival = this.forwardThresholdMs > 0 ? this.clock() : 0;
|
|
905
|
+
// Apply streamcopy filtering BEFORE buffering
|
|
906
|
+
// This ensures rejected packets never enter the queue/buffer
|
|
907
|
+
if (streamInfo.isStreamCopy) {
|
|
908
|
+
const shouldWrite = this.ofStreamcopy(clonedPacket, streamInfo, streamIndex);
|
|
909
|
+
if (!shouldWrite) {
|
|
910
|
+
clonedPacket.free(); // Free the clone since we won't use it
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
else if (this.options.startTime !== undefined) {
|
|
915
|
+
// For encoded (non-streamcopy) streams, strip the device's startTime base.
|
|
916
|
+
this.applyStartTimeOffset(clonedPacket, streamInfo);
|
|
917
|
+
}
|
|
918
|
+
// Announced on acceptance, so the observer also counts what waits in the
|
|
919
|
+
// pre-mux queue for the header.
|
|
920
|
+
const observed = this.announcePacket(clonedPacket, streamIndex);
|
|
921
|
+
// Check if any streams are still uninitialized or header is being written
|
|
922
|
+
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
923
|
+
// PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
|
|
924
|
+
// PreMuxQueue is used during initialization phase ONLY (regardless of SyncQueue presence)
|
|
925
|
+
// After header write, PreMuxQueue is flushed in DTS-sorted order
|
|
926
|
+
if (uninitialized || this.headerWritePromise) {
|
|
927
|
+
// Check PreMuxQueue limits
|
|
928
|
+
const maxPackets = this.options.maxMuxingQueueSize ?? MAX_MUXING_QUEUE_SIZE;
|
|
929
|
+
const dataThreshold = this.options.muxingQueueDataThreshold ?? MUXING_QUEUE_DATA_THRESHOLD;
|
|
930
|
+
const currentPackets = streamInfo.preMuxQueue.length;
|
|
931
|
+
const currentBytes = streamInfo.preMuxQueueDataSize;
|
|
932
|
+
const packetSize = clonedPacket.size;
|
|
933
|
+
const thresholdReached = currentBytes + packetSize > dataThreshold;
|
|
934
|
+
const effectiveMaxPackets = thresholdReached ? maxPackets : Number.MAX_SAFE_INTEGER;
|
|
935
|
+
// Check if we would exceed packet limit (only if threshold reached)
|
|
936
|
+
if (currentPackets >= effectiveMaxPackets) {
|
|
937
|
+
clonedPacket.free(); // Free the clone since we can't buffer it
|
|
938
|
+
if (observed) {
|
|
939
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
940
|
+
}
|
|
941
|
+
throw new Error(
|
|
942
|
+
// eslint-disable-next-line @stylistic/max-len
|
|
943
|
+
`Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
|
|
944
|
+
}
|
|
945
|
+
// Buffer in PreMuxQueue (per-stream FIFO)
|
|
946
|
+
streamInfo.preMuxQueue.push(clonedPacket);
|
|
947
|
+
streamInfo.preMuxArrivals.push(arrival);
|
|
948
|
+
streamInfo.preMuxQueueDataSize += packetSize;
|
|
949
|
+
return; // Don't proceed to header write yet
|
|
950
|
+
}
|
|
951
|
+
// Automatically write header if not written yet
|
|
952
|
+
if (!this.headerWritten) {
|
|
953
|
+
this.headerWritePromise ??= (async () => {
|
|
954
|
+
this.startWriteWorker();
|
|
955
|
+
this.prepareHeaderWrite();
|
|
956
|
+
const ret = await this.formatContext.writeHeader();
|
|
957
|
+
FFmpegError.throwIfError(ret, 'Failed to write header');
|
|
958
|
+
this.headerWritten = true;
|
|
959
|
+
this.readHeaderSetup();
|
|
960
|
+
// PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
|
|
961
|
+
// Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
|
|
962
|
+
// Concurrent writers keep queueing while headerWritePromise is set, also
|
|
963
|
+
// after a flush found the queues empty. Drain until they are empty and
|
|
964
|
+
// clear the promise in that same tick, or such packets are never written.
|
|
965
|
+
do {
|
|
966
|
+
await this.flushPreMuxQueues();
|
|
967
|
+
} while (this.hasPreMuxPackets());
|
|
968
|
+
this.headerWritePromise = undefined;
|
|
969
|
+
})();
|
|
970
|
+
await this.headerWritePromise;
|
|
971
|
+
if (this.headerWritten) {
|
|
972
|
+
this.headerWritePromise = undefined;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// PHASE 3: Write packet - normal muxing after header
|
|
976
|
+
if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
|
|
977
|
+
// Use SyncQueue for packet interleaving
|
|
978
|
+
// NOTE: Do NOT set clonedPacket.timeBase here!
|
|
979
|
+
// Packet must keep its source timebase (encoder timebase) so muxFixupTs can rescale correctly
|
|
980
|
+
// Send packet to sync queue
|
|
981
|
+
const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
|
|
982
|
+
// Handle errors from sq_send
|
|
983
|
+
if (ret < 0) {
|
|
984
|
+
if (observed) {
|
|
985
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
986
|
+
}
|
|
987
|
+
if (ret === AVERROR_EOF) {
|
|
988
|
+
// Stream finished - this is normal, just return
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (this.options.exitOnError) {
|
|
992
|
+
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue');
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
// The sync queue keeps each stream's packets in order and drops none.
|
|
997
|
+
streamInfo.sqArrivals.push(arrival);
|
|
998
|
+
// Receive synchronized packets from queue and write to muxer
|
|
999
|
+
while (!this.isClosed) {
|
|
1000
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
1001
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
1002
|
+
break; // No more packets ready
|
|
1003
|
+
}
|
|
1004
|
+
if (recvRet === AVERROR_EOF) {
|
|
1005
|
+
break; // All streams finished
|
|
1006
|
+
}
|
|
1007
|
+
if (recvRet >= 0) {
|
|
1008
|
+
// recvRet is the stream index
|
|
1009
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
1010
|
+
// Clone packet before writing (muxer takes ownership and will unref it)
|
|
1011
|
+
// We need to keep sqPacket alive for the next receive() call
|
|
1012
|
+
const pkt = this.sqPacket.clone();
|
|
1013
|
+
if (!pkt) {
|
|
1014
|
+
throw new Error('Failed to clone packet from sync queue');
|
|
1015
|
+
}
|
|
1016
|
+
pkt.streamIndex = recvRet;
|
|
1017
|
+
// Write packet (muxer takes ownership)
|
|
1018
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
else {
|
|
1023
|
+
// No sync queue needed - write directly
|
|
1024
|
+
clonedPacket.streamIndex = streamIndex;
|
|
1025
|
+
await this.write(clonedPacket, streamInfo, streamIndex, arrival);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Write a packet to the output synchronously.
|
|
1030
|
+
* Synchronous version of writePacket.
|
|
1031
|
+
*
|
|
1032
|
+
* Writes muxed packet to the specified stream.
|
|
1033
|
+
* Automatically handles:
|
|
1034
|
+
* - Stream initialization on first packet (lazy initialization)
|
|
1035
|
+
* - Codec parameter configuration from encoder or input stream
|
|
1036
|
+
* - Header writing on first packet
|
|
1037
|
+
* - Timestamp rescaling between source and output timebases
|
|
1038
|
+
* - Sync queue for proper interleaving
|
|
1039
|
+
*
|
|
1040
|
+
* For encoder sources, the encoder must have processed at least one frame
|
|
1041
|
+
* before packets can be written (encoder must be initialized).
|
|
1042
|
+
*
|
|
1043
|
+
* Uses FFmpeg CLI's sync queue pattern: buffers packets per stream and writes
|
|
1044
|
+
* them in DTS order using av_compare_ts for timebase-aware comparison.
|
|
1045
|
+
*
|
|
1046
|
+
* To signal EOF for a stream, pass null as the packet.
|
|
1047
|
+
* This tells the muxer that no more packets will be sent for this stream.
|
|
1048
|
+
* The trailer is written only when close() is called.
|
|
1049
|
+
*
|
|
1050
|
+
* Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
|
|
1051
|
+
*
|
|
1052
|
+
* @param packet - Packet to write (or null/undefined to signal EOF)
|
|
1053
|
+
*
|
|
1054
|
+
* @param streamIndex - Target stream index
|
|
1055
|
+
*
|
|
1056
|
+
* @throws {Error} If stream invalid or encoder not initialized
|
|
1057
|
+
*
|
|
1058
|
+
* @throws {Error} On a timestamp discontinuity the output cannot take (see {@link MuxerOptions.dtsBackwardThreshold})
|
|
1059
|
+
*
|
|
1060
|
+
* @throws {FFmpegError} If write fails
|
|
1061
|
+
*
|
|
1062
|
+
* @example
|
|
1063
|
+
* ```typescript
|
|
1064
|
+
* // Write encoded packet - header written automatically on first packet
|
|
1065
|
+
* const packet = encoder.encodeSync(frame);
|
|
1066
|
+
* if (packet) {
|
|
1067
|
+
* output.writePacketSync(packet, videoIdx);
|
|
1068
|
+
* packet.free();
|
|
1069
|
+
* }
|
|
1070
|
+
* ```
|
|
1071
|
+
*
|
|
1072
|
+
* @example
|
|
1073
|
+
* ```typescript
|
|
1074
|
+
* // Stream copy with packet processing
|
|
1075
|
+
* for (const packet of input.packetsSync()) {
|
|
1076
|
+
* if (packet.streamIndex === inputVideoIdx) {
|
|
1077
|
+
* output.writePacketSync(packet, outputVideoIdx);
|
|
1078
|
+
* }
|
|
1079
|
+
* packet.free();
|
|
1080
|
+
* }
|
|
1081
|
+
* ```
|
|
1082
|
+
*
|
|
1083
|
+
* @see {@link writePacket} For async version
|
|
1084
|
+
*/
|
|
1085
|
+
writePacketSync(packet, streamIndex) {
|
|
1086
|
+
if (this.isClosed) {
|
|
1087
|
+
throw new Error('Muxer is closed');
|
|
1088
|
+
}
|
|
1089
|
+
if (this.trailerWritten) {
|
|
1090
|
+
throw new Error('Cannot write packets after output is finalized');
|
|
1091
|
+
}
|
|
1092
|
+
if (!this._streams.get(streamIndex)) {
|
|
1093
|
+
throw new Error(`Invalid stream index: ${streamIndex}`);
|
|
1094
|
+
}
|
|
1095
|
+
// Initialize any encoder streams that are ready
|
|
1096
|
+
for (const streamInfo of this._streams.values()) {
|
|
1097
|
+
if (!streamInfo.initialized && streamInfo.encoder) {
|
|
1098
|
+
const encoder = streamInfo.encoder;
|
|
1099
|
+
const codecContext = encoder.getCodecContext();
|
|
1100
|
+
// Skip if encoder not ready yet
|
|
1101
|
+
if (!encoder.isEncoderInitialized || !codecContext) {
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
// If a trailing bitstream filter is present, wait until it is initialized
|
|
1105
|
+
// so its output parameters (e.g. modified extradata) are available.
|
|
1106
|
+
if (streamInfo.bsf && !streamInfo.bsf.isInitialized) {
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
// This encoder is ready, initialize it now
|
|
1110
|
+
// Read codecType from codecContext, not from stream (which is still uninitialized)
|
|
1111
|
+
const codecType = codecContext.codecType;
|
|
1112
|
+
// 1. Set stream timebase
|
|
1113
|
+
// Use encoder's timebase unless user specified custom timebase
|
|
1114
|
+
if (streamInfo.timeBase) {
|
|
1115
|
+
// User specified custom timebase
|
|
1116
|
+
streamInfo.outputStream.timeBase = new Rational(streamInfo.timeBase.num, streamInfo.timeBase.den);
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
// Use encoder's timebase directly
|
|
1120
|
+
// The encoder timebase is already set from the first frame in Encoder.initialize()
|
|
1121
|
+
streamInfo.outputStream.timeBase = new Rational(codecContext.timeBase.num, codecContext.timeBase.den);
|
|
1122
|
+
}
|
|
1123
|
+
// 2. Set stream avg_frame_rate, r_frame_rate and sample_aspect_ratio
|
|
1124
|
+
if (codecType === AVMEDIA_TYPE_VIDEO) {
|
|
1125
|
+
const fr = codecContext.framerate;
|
|
1126
|
+
streamInfo.outputStream.avgFrameRate = new Rational(fr.num, fr.den);
|
|
1127
|
+
streamInfo.outputStream.rFrameRate = new Rational(fr.num, fr.den);
|
|
1128
|
+
streamInfo.outputStream.sampleAspectRatio = codecContext.sampleAspectRatio;
|
|
1129
|
+
}
|
|
1130
|
+
// 3. Copy codec parameters from encoder context
|
|
1131
|
+
const ret = streamInfo.outputStream.codecpar.fromContext(codecContext);
|
|
1132
|
+
FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
|
|
1133
|
+
// 3b. Overlay the trailing bitstream filter's output parameters, so
|
|
1134
|
+
// container-level fields (e.g. extradata/level rewritten by h264_metadata)
|
|
1135
|
+
// reflect the filter's output rather than the raw encoder output.
|
|
1136
|
+
const bsfParams = streamInfo.bsf?.outputCodecParameters;
|
|
1137
|
+
if (bsfParams) {
|
|
1138
|
+
const bsfRet = bsfParams.copy(streamInfo.outputStream.codecpar);
|
|
1139
|
+
FFmpegError.throwIfError(bsfRet, 'Failed to copy codec parameters from bitstream filter');
|
|
1140
|
+
}
|
|
1141
|
+
// 4. Copy metadata from input stream
|
|
1142
|
+
const input = streamInfo.input;
|
|
1143
|
+
if (input) {
|
|
1144
|
+
if (input.metadata) {
|
|
1145
|
+
streamInfo.outputStream.metadata = input.metadata;
|
|
1146
|
+
}
|
|
1147
|
+
// 5. Copy disposition from input stream
|
|
1148
|
+
streamInfo.outputStream.disposition = input.disposition;
|
|
1149
|
+
// 5b. Copy coded_side_data from input stream (display matrix / rotation,
|
|
1150
|
+
// HDR mastering display & content light level). The encoder path only
|
|
1151
|
+
// copied codec parameters, so without this these are lost on transcode
|
|
1152
|
+
// (e.g. a portrait phone video would play sideways). Mirrors the stream
|
|
1153
|
+
// copy path.
|
|
1154
|
+
for (const sd of input.codedSideData) {
|
|
1155
|
+
streamInfo.outputStream.codecpar.addCodedSideData(sd.type, sd.data);
|
|
1156
|
+
}
|
|
1157
|
+
// 6. Copy duration hint from input stream
|
|
1158
|
+
if (input.duration > 0n) {
|
|
1159
|
+
const outputTb = streamInfo.outputStream.timeBase;
|
|
1160
|
+
streamInfo.outputStream.duration = avRescaleQ(input.duration, input.timeBase, outputTb);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
// Update the source timebase for timestamp rescaling
|
|
1164
|
+
streamInfo.sourceTimeBase = codecContext.timeBase;
|
|
1165
|
+
// Mark as initialized
|
|
1166
|
+
streamInfo.initialized = true;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const streamInfo = this._streams.get(streamIndex);
|
|
1170
|
+
// Handle NULL packet - signals EOF for this stream (FFmpeg pattern: av_interleaved_write_frame(s, NULL))
|
|
1171
|
+
// FFmpeg's behavior:
|
|
1172
|
+
// - If muxer not started (uninitialized streams), buffer NULL in PreMuxQueue as EOF marker
|
|
1173
|
+
// - If muxer started, send NULL to SyncQueue to signal EOF and flush
|
|
1174
|
+
if (!packet) {
|
|
1175
|
+
// Mark stream as EOF received
|
|
1176
|
+
streamInfo.eofReceived = true;
|
|
1177
|
+
// Check if any streams are still uninitialized (PreMuxQueue phase)
|
|
1178
|
+
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
1179
|
+
// PHASE 1: Before muxer starts - buffer NULL packet in PreMuxQueue
|
|
1180
|
+
// This matches FFmpeg's mux_queue_packet() which writes NULL to PreMuxQueue FIFO
|
|
1181
|
+
if (uninitialized || this.headerWritePromise) {
|
|
1182
|
+
// Buffer NULL as EOF marker (no size contribution)
|
|
1183
|
+
streamInfo.preMuxQueue.push(null);
|
|
1184
|
+
streamInfo.preMuxArrivals.push(0);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
// PHASE 2: After muxer started - send EOF to SyncQueue and flush
|
|
1188
|
+
if (!this.headerWritten) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
// If using SyncQueue, send EOF for this stream
|
|
1192
|
+
if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
|
|
1193
|
+
// Send NULL to signal EOF to sync queue
|
|
1194
|
+
// Native side handles null correctly (sets sqframe.p = nullptr)
|
|
1195
|
+
const ret = this.syncQueue.send(streamInfo.sqIdxMux, null);
|
|
1196
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
1197
|
+
if (this.options.exitOnError) {
|
|
1198
|
+
FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue');
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
// Receive and write any remaining packets from sync queue
|
|
1202
|
+
while (!this.isClosed) {
|
|
1203
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
1204
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
1205
|
+
break; // No more packets ready
|
|
1206
|
+
}
|
|
1207
|
+
if (recvRet === AVERROR_EOF) {
|
|
1208
|
+
break; // All streams finished
|
|
1209
|
+
}
|
|
1210
|
+
if (recvRet >= 0) {
|
|
1211
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
1212
|
+
const pkt = this.sqPacket.clone();
|
|
1213
|
+
if (!pkt) {
|
|
1214
|
+
throw new Error('Failed to clone packet from sync queue');
|
|
1215
|
+
}
|
|
1216
|
+
pkt.streamIndex = recvRet;
|
|
1217
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return; // EOF signaled, nothing more to do
|
|
1222
|
+
}
|
|
1223
|
+
// Clone packet immediately - we will modify it and caller retains ownership
|
|
1224
|
+
const clonedPacket = packet.clone();
|
|
1225
|
+
if (!clonedPacket) {
|
|
1226
|
+
throw new Error('Failed to clone packet for writing');
|
|
1227
|
+
}
|
|
1228
|
+
const arrival = this.forwardThresholdMs > 0 ? this.clock() : 0;
|
|
1229
|
+
// Apply streamcopy filtering BEFORE buffering
|
|
1230
|
+
// This ensures rejected packets never enter the queue/buffer
|
|
1231
|
+
if (streamInfo.isStreamCopy) {
|
|
1232
|
+
const shouldWrite = this.ofStreamcopy(clonedPacket, streamInfo, streamIndex);
|
|
1233
|
+
if (!shouldWrite) {
|
|
1234
|
+
clonedPacket.free(); // Free the clone since we won't use it
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
else if (this.options.startTime !== undefined) {
|
|
1239
|
+
// For encoded (non-streamcopy) streams, strip the device's startTime base.
|
|
1240
|
+
this.applyStartTimeOffset(clonedPacket, streamInfo);
|
|
1241
|
+
}
|
|
1242
|
+
// Announced on acceptance, so the observer also counts what waits in the
|
|
1243
|
+
// pre-mux queue for the header.
|
|
1244
|
+
const observed = this.announcePacket(clonedPacket, streamIndex);
|
|
1245
|
+
// Check if any streams are still uninitialized
|
|
1246
|
+
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
1247
|
+
// PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
|
|
1248
|
+
// PreMuxQueue is used during initialization phase ONLY (regardless of SyncQueue presence)
|
|
1249
|
+
// After header write, PreMuxQueue is flushed in DTS-sorted order
|
|
1250
|
+
if (uninitialized) {
|
|
1251
|
+
// Check PreMuxQueue limits
|
|
1252
|
+
const maxPackets = this.options.maxMuxingQueueSize ?? MAX_MUXING_QUEUE_SIZE;
|
|
1253
|
+
const dataThreshold = this.options.muxingQueueDataThreshold ?? MUXING_QUEUE_DATA_THRESHOLD;
|
|
1254
|
+
const currentPackets = streamInfo.preMuxQueue.length;
|
|
1255
|
+
const currentBytes = streamInfo.preMuxQueueDataSize;
|
|
1256
|
+
const packetSize = clonedPacket.size;
|
|
1257
|
+
const thresholdReached = currentBytes + packetSize > dataThreshold;
|
|
1258
|
+
const effectiveMaxPackets = thresholdReached ? maxPackets : Number.MAX_SAFE_INTEGER;
|
|
1259
|
+
// Check if we would exceed packet limit (only if threshold reached)
|
|
1260
|
+
if (currentPackets >= effectiveMaxPackets) {
|
|
1261
|
+
clonedPacket.free(); // Free the clone since we can't buffer it
|
|
1262
|
+
if (observed) {
|
|
1263
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
1264
|
+
}
|
|
1265
|
+
throw new Error(
|
|
1266
|
+
// eslint-disable-next-line @stylistic/max-len
|
|
1267
|
+
`Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
|
|
1268
|
+
}
|
|
1269
|
+
// Buffer in PreMuxQueue (per-stream FIFO)
|
|
1270
|
+
streamInfo.preMuxQueue.push(clonedPacket);
|
|
1271
|
+
streamInfo.preMuxArrivals.push(arrival);
|
|
1272
|
+
streamInfo.preMuxQueueDataSize += packetSize;
|
|
1273
|
+
return; // Don't proceed to header write yet
|
|
1274
|
+
}
|
|
1275
|
+
// Automatically write header if not written yet
|
|
1276
|
+
if (!this.headerWritten) {
|
|
1277
|
+
this.prepareHeaderWrite();
|
|
1278
|
+
const ret = this.formatContext.writeHeaderSync();
|
|
1279
|
+
FFmpegError.throwIfError(ret, 'Failed to write header');
|
|
1280
|
+
this.headerWritten = true;
|
|
1281
|
+
this.readHeaderSetup();
|
|
1282
|
+
// PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
|
|
1283
|
+
// Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
|
|
1284
|
+
this.flushPreMuxQueuesSync();
|
|
1285
|
+
}
|
|
1286
|
+
// PHASE 3: Write packet - normal muxing after header
|
|
1287
|
+
if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
|
|
1288
|
+
// Use SyncQueue for packet interleaving
|
|
1289
|
+
// NOTE: Do NOT set clonedPacket.timeBase here!
|
|
1290
|
+
// Packet must keep its source timebase (encoder timebase) so muxFixupTs can rescale correctly
|
|
1291
|
+
// Send packet to sync queue
|
|
1292
|
+
const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
|
|
1293
|
+
// Handle errors from sq_send
|
|
1294
|
+
if (ret < 0) {
|
|
1295
|
+
if (observed) {
|
|
1296
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
1297
|
+
}
|
|
1298
|
+
if (ret === AVERROR_EOF) {
|
|
1299
|
+
// Stream finished - this is normal, just return
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (this.options.exitOnError) {
|
|
1303
|
+
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue');
|
|
1304
|
+
}
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
streamInfo.sqArrivals.push(arrival);
|
|
1308
|
+
// Receive synchronized packets from queue and write to muxer
|
|
1309
|
+
while (!this.isClosed) {
|
|
1310
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
1311
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
1312
|
+
break; // No more packets ready
|
|
1313
|
+
}
|
|
1314
|
+
if (recvRet === AVERROR_EOF) {
|
|
1315
|
+
break; // All streams finished
|
|
1316
|
+
}
|
|
1317
|
+
if (recvRet >= 0) {
|
|
1318
|
+
// recvRet is the stream index
|
|
1319
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
1320
|
+
// Clone packet before writing (muxer takes ownership and will unref it)
|
|
1321
|
+
// We need to keep sqPacket alive for the next receive() call
|
|
1322
|
+
const pkt = this.sqPacket.clone();
|
|
1323
|
+
if (!pkt) {
|
|
1324
|
+
throw new Error('Failed to clone packet from sync queue');
|
|
1325
|
+
}
|
|
1326
|
+
pkt.streamIndex = recvRet;
|
|
1327
|
+
// Write packet (muxer takes ownership)
|
|
1328
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
// No sync queue needed - write directly
|
|
1334
|
+
clonedPacket.streamIndex = streamIndex;
|
|
1335
|
+
this.writeSync(clonedPacket, streamInfo, streamIndex, arrival);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Close muxer and free resources.
|
|
1340
|
+
*
|
|
1341
|
+
* Automatically writes trailer if header was written.
|
|
1342
|
+
* Closes the output file and releases all resources.
|
|
1343
|
+
* Safe to call multiple times.
|
|
1344
|
+
* Automatically called by Symbol.asyncDispose.
|
|
1345
|
+
*
|
|
1346
|
+
* @example
|
|
1347
|
+
* ```typescript
|
|
1348
|
+
* const output = await Muxer.open('output.mp4');
|
|
1349
|
+
* try {
|
|
1350
|
+
* // Use output - trailer written automatically on close
|
|
1351
|
+
* } finally {
|
|
1352
|
+
* await output.close();
|
|
1353
|
+
* }
|
|
1354
|
+
* ```
|
|
1355
|
+
*
|
|
1356
|
+
* @throws {Error} If a background write failed and was not yet observed by the caller (resources are still released; a second close() is a no-op)
|
|
1357
|
+
*
|
|
1358
|
+
* @see {@link Symbol.asyncDispose} For automatic cleanup
|
|
1359
|
+
*/
|
|
1360
|
+
async close() {
|
|
1361
|
+
if (this.isClosed) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
this.isClosed = true;
|
|
1365
|
+
// Detached first, so the final flush can never fail on the observer.
|
|
1366
|
+
this.packetObserver = undefined;
|
|
1367
|
+
// A header write launched by a concurrent writePacket() must settle before
|
|
1368
|
+
// teardown: nulling pb / freeing the format context under a running
|
|
1369
|
+
// avformat_write_header() crashes on the worker thread. This happens when a
|
|
1370
|
+
// pipeline is stopped mid-startup - the reader task rejects the shared
|
|
1371
|
+
// completion while the writer task is still inside its first writePacket().
|
|
1372
|
+
if (this.headerWritePromise) {
|
|
1373
|
+
try {
|
|
1374
|
+
await this.headerWritePromise;
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
// Ignore errors - teardown proceeds either way
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
// Close write queue and wait for worker to finish.
|
|
1381
|
+
// The worker catches its own errors, but guard the await anyway so a
|
|
1382
|
+
// failed worker can never abort cleanup - resources below must always
|
|
1383
|
+
// be freed even after a write failure.
|
|
1384
|
+
if (this.writeQueue) {
|
|
1385
|
+
this.writeQueue.close();
|
|
1386
|
+
try {
|
|
1387
|
+
await this.writeWorkerPromise;
|
|
1388
|
+
}
|
|
1389
|
+
catch (error) {
|
|
1390
|
+
this.writeWorkerError ??= error instanceof Error ? error : new Error(String(error));
|
|
1391
|
+
}
|
|
1392
|
+
this.writeQueue.clear(); // Free any job packet the worker never consumed
|
|
1393
|
+
}
|
|
1394
|
+
// Free PreMuxQueue packets
|
|
1395
|
+
for (const streamInfo of this._streams.values()) {
|
|
1396
|
+
// Free any packets in PreMuxQueue
|
|
1397
|
+
for (const pkt of streamInfo.preMuxQueue) {
|
|
1398
|
+
pkt?.free();
|
|
1399
|
+
}
|
|
1400
|
+
streamInfo.preMuxQueue = [];
|
|
1401
|
+
streamInfo.preMuxArrivals = [];
|
|
1402
|
+
}
|
|
1403
|
+
// Free sync queue resources
|
|
1404
|
+
if (this.sqPacket) {
|
|
1405
|
+
this.sqPacket.free();
|
|
1406
|
+
this.sqPacket = undefined;
|
|
1407
|
+
}
|
|
1408
|
+
if (this.syncQueue) {
|
|
1409
|
+
this.syncQueue.free();
|
|
1410
|
+
this.syncQueue = undefined;
|
|
1411
|
+
}
|
|
1412
|
+
// Try to write trailer if header was written but trailer wasn't
|
|
1413
|
+
try {
|
|
1414
|
+
if (this.headerWritten && !this.trailerWritten && !this.trailerDiscarded) {
|
|
1415
|
+
await this.formatContext.writeTrailer();
|
|
1416
|
+
this.trailerWritten = true;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
// Ignore errors
|
|
1421
|
+
}
|
|
1422
|
+
// Clear pb reference first to prevent use-after-free
|
|
1423
|
+
if (this.ioContext) {
|
|
1424
|
+
this.formatContext.pb = null;
|
|
1425
|
+
}
|
|
1426
|
+
// For file-based IO, close the file handle via closep - freeContext() alone
|
|
1427
|
+
// would release the AVIOContext but leak the underlying protocol handle,
|
|
1428
|
+
// leaving the file locked (EBUSY on Windows) until the process exits.
|
|
1429
|
+
// For custom IO, the context will be freed below
|
|
1430
|
+
if (this.ioContext && !this.customIO) {
|
|
1431
|
+
try {
|
|
1432
|
+
await this.ioContext.closep();
|
|
1433
|
+
}
|
|
1434
|
+
catch {
|
|
1435
|
+
// Ignore errors
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
// Free format context
|
|
1439
|
+
if (this.formatContext) {
|
|
1440
|
+
try {
|
|
1441
|
+
this.formatContext.freeContext();
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
// Ignore errors
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
// Now free custom IO context if present
|
|
1448
|
+
if (this.ioContext && this.customIO) {
|
|
1449
|
+
try {
|
|
1450
|
+
this.ioContext.freeContext();
|
|
1451
|
+
}
|
|
1452
|
+
catch {
|
|
1453
|
+
// Ignore errors
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// Surface a background write failure the caller may not have observed yet.
|
|
1457
|
+
// Thrown only after all resources are freed - a second close() is a no-op.
|
|
1458
|
+
if (this.writeWorkerError) {
|
|
1459
|
+
throw this.writeWorkerError;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Close muxer and free resources synchronously.
|
|
1464
|
+
* Synchronous version of close.
|
|
1465
|
+
*
|
|
1466
|
+
* Automatically writes trailer if header was written.
|
|
1467
|
+
* Closes the output file and releases all resources.
|
|
1468
|
+
* Safe to call multiple times.
|
|
1469
|
+
* Automatically called by Symbol.dispose.
|
|
1470
|
+
*
|
|
1471
|
+
* @example
|
|
1472
|
+
* ```typescript
|
|
1473
|
+
* const output = Muxer.openSync('output.mp4');
|
|
1474
|
+
* try {
|
|
1475
|
+
* // Use output - trailer written automatically on close
|
|
1476
|
+
* } finally {
|
|
1477
|
+
* output.closeSync();
|
|
1478
|
+
* }
|
|
1479
|
+
* ```
|
|
1480
|
+
*
|
|
1481
|
+
* @see {@link close} For async version
|
|
1482
|
+
*/
|
|
1483
|
+
closeSync() {
|
|
1484
|
+
if (this.isClosed) {
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
this.isClosed = true;
|
|
1488
|
+
this.packetObserver = undefined;
|
|
1489
|
+
// Free PreMuxQueue packets
|
|
1490
|
+
for (const streamInfo of this._streams.values()) {
|
|
1491
|
+
// Free any packets in PreMuxQueue
|
|
1492
|
+
for (const pkt of streamInfo.preMuxQueue) {
|
|
1493
|
+
pkt?.free();
|
|
1494
|
+
}
|
|
1495
|
+
streamInfo.preMuxQueue = [];
|
|
1496
|
+
streamInfo.preMuxArrivals = [];
|
|
1497
|
+
}
|
|
1498
|
+
// Free sync queue resources
|
|
1499
|
+
if (this.sqPacket) {
|
|
1500
|
+
this.sqPacket.free();
|
|
1501
|
+
this.sqPacket = undefined;
|
|
1502
|
+
}
|
|
1503
|
+
if (this.syncQueue) {
|
|
1504
|
+
this.syncQueue.free();
|
|
1505
|
+
this.syncQueue = undefined;
|
|
1506
|
+
}
|
|
1507
|
+
// Try to write trailer if header was written but trailer wasn't
|
|
1508
|
+
try {
|
|
1509
|
+
if (this.headerWritten && !this.trailerWritten && !this.trailerDiscarded) {
|
|
1510
|
+
this.formatContext.writeTrailerSync();
|
|
1511
|
+
this.trailerWritten = true;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
catch {
|
|
1515
|
+
// Ignore errors
|
|
1516
|
+
}
|
|
1517
|
+
// Clear pb reference first to prevent use-after-free
|
|
1518
|
+
if (this.ioContext) {
|
|
1519
|
+
this.formatContext.pb = null;
|
|
1520
|
+
}
|
|
1521
|
+
// For file-based IO, close the file handle via closep
|
|
1522
|
+
// For custom IO, the context will be freed below
|
|
1523
|
+
if (this.ioContext && !this.customIO) {
|
|
1524
|
+
try {
|
|
1525
|
+
this.ioContext.closepSync();
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
// Ignore errors
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
// Free format context
|
|
1532
|
+
if (this.formatContext) {
|
|
1533
|
+
try {
|
|
1534
|
+
this.formatContext.freeContext();
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
// Ignore errors
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// Now free custom IO context if present
|
|
1541
|
+
if (this.ioContext && this.customIO) {
|
|
1542
|
+
try {
|
|
1543
|
+
this.ioContext.freeContext();
|
|
1544
|
+
}
|
|
1545
|
+
catch {
|
|
1546
|
+
// Ignore errors
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Get underlying format context.
|
|
1552
|
+
*
|
|
1553
|
+
* Returns the internal format context for advanced operations.
|
|
1554
|
+
*
|
|
1555
|
+
* @returns Format context
|
|
1556
|
+
*
|
|
1557
|
+
* @internal
|
|
1558
|
+
*/
|
|
1559
|
+
getFormatContext() {
|
|
1560
|
+
return this.formatContext;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Follow the packets this muxer accepts for writing.
|
|
1564
|
+
*
|
|
1565
|
+
* The observer hears about every packet with payload once it is accepted
|
|
1566
|
+
* (before it waits in the pre-mux queue or reaches libavformat) and about
|
|
1567
|
+
* every such packet that is dropped later, so together with the output it
|
|
1568
|
+
* knows what the muxer holds. close() detaches it.
|
|
1569
|
+
*
|
|
1570
|
+
* @param observer - Observer, or undefined to detach
|
|
1571
|
+
*
|
|
1572
|
+
* @internal
|
|
1573
|
+
*/
|
|
1574
|
+
setPacketObserver(observer) {
|
|
1575
|
+
this.packetObserver = observer;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Replace the clock that stamps packet arrival times for the dtsForwardThreshold check.
|
|
1579
|
+
*
|
|
1580
|
+
* Lets tests drive real time deterministically. Set it before the first packet.
|
|
1581
|
+
*
|
|
1582
|
+
* @param clock - Monotonic time in ms
|
|
1583
|
+
*
|
|
1584
|
+
* @internal
|
|
1585
|
+
*/
|
|
1586
|
+
setClock(clock) {
|
|
1587
|
+
this.clock = clock;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Make close() skip the trailer.
|
|
1591
|
+
*
|
|
1592
|
+
* For owners that drop whatever the muxer still emits, such as FMP4Stream
|
|
1593
|
+
* after a failed session: the trailer would only push libavformat's backlog
|
|
1594
|
+
* into the void, and movenc asserts on some track states a failed session
|
|
1595
|
+
* can leave behind. The backlog is freed with the format context instead.
|
|
1596
|
+
*
|
|
1597
|
+
* @internal
|
|
1598
|
+
*/
|
|
1599
|
+
discardOnClose() {
|
|
1600
|
+
this.trailerDiscarded = true;
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Announce a packet the muxer has accepted to the packet observer.
|
|
1604
|
+
*
|
|
1605
|
+
* @param pkt - The muxer's clone of the packet
|
|
1606
|
+
*
|
|
1607
|
+
* @param streamIndex - Output stream index
|
|
1608
|
+
*
|
|
1609
|
+
* @returns True when the observer now tracks the packet
|
|
1610
|
+
*
|
|
1611
|
+
* @throws {Error} If the observer fails the write (the clone is freed)
|
|
1612
|
+
*
|
|
1613
|
+
* @internal
|
|
1614
|
+
*/
|
|
1615
|
+
announcePacket(pkt, streamIndex) {
|
|
1616
|
+
// movenc drops packets without payload instead of storing a sample.
|
|
1617
|
+
if (this.packetObserver === undefined || pkt.size <= 0) {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
try {
|
|
1621
|
+
this.packetObserver.onPacket(streamIndex);
|
|
1622
|
+
}
|
|
1623
|
+
catch (error) {
|
|
1624
|
+
pkt.free();
|
|
1625
|
+
throw error;
|
|
1626
|
+
}
|
|
1627
|
+
return true;
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* Apply shared pre-header setup for writePacket and writePacketSync.
|
|
1631
|
+
*
|
|
1632
|
+
* Configures sync queues, dispositions and container metadata, then applies
|
|
1633
|
+
* user-provided context options and the configure callback. Must run
|
|
1634
|
+
* immediately before writing the header so both variants stay in sync.
|
|
1635
|
+
*
|
|
1636
|
+
* @internal
|
|
1637
|
+
*/
|
|
1638
|
+
prepareHeaderWrite() {
|
|
1639
|
+
this.setupSyncQueues();
|
|
1640
|
+
this.updateDefaultDisposition();
|
|
1641
|
+
this.copyContainerMetadata();
|
|
1642
|
+
applyContextOptions(this.formatContext, this.options.context);
|
|
1643
|
+
this.options.configure?.(this.formatContext);
|
|
1644
|
+
const oformat = this.formatContext.oformat;
|
|
1645
|
+
this.movSampleLimit = MOV_FAMILY_FORMATS.has(oformat?.name ?? '');
|
|
1646
|
+
this.tsNonStrict = oformat?.hasFlags(AVFMT_TS_NONSTRICT) ?? false;
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Read the muxing setup libavformat settles while writing the header.
|
|
1650
|
+
*
|
|
1651
|
+
* movenc decides on edit lists only then: without one (fragmented output
|
|
1652
|
+
* without delay_moov) it shifts the output to start at 0 and puts each
|
|
1653
|
+
* track's first sample there, which {@link checkMovLateStart} guards.
|
|
1654
|
+
*
|
|
1655
|
+
* @internal
|
|
1656
|
+
*/
|
|
1657
|
+
readHeaderSetup() {
|
|
1658
|
+
if (!this.movSampleLimit) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const ctx = this.formatContext;
|
|
1662
|
+
this.movZeroStart = ctx.getOption('use_editlist', AV_OPT_TYPE_BOOL) === false && ctx.getOption('avoid_negative_ts', AV_OPT_TYPE_INT) === AVFMT_AVOID_NEG_TS_MAKE_ZERO;
|
|
1663
|
+
this.interleaveDeltaUs = ctx.maxInterleaveDelta;
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Setup sync queues based on stream configuration.
|
|
1667
|
+
*
|
|
1668
|
+
* Called before writing header.
|
|
1669
|
+
* Muxing sync queue is created only if nb_interleaved > nb_av_enc
|
|
1670
|
+
* (i.e., when there are streamcopy streams).
|
|
1671
|
+
*
|
|
1672
|
+
* All streams are added as non-limiting (FFmpeg default without -shortest),
|
|
1673
|
+
* which means no timestamp-based synchronization - frames are output immediately.
|
|
1674
|
+
*
|
|
1675
|
+
* @internal
|
|
1676
|
+
*/
|
|
1677
|
+
setupSyncQueues() {
|
|
1678
|
+
const nbInterleaved = this._streams.size; // All streams are interleaved (no attachments)
|
|
1679
|
+
const nbAvEnc = Array.from(this._streams.values()).filter((s) => !s.isStreamCopy).length;
|
|
1680
|
+
// FFmpeg's condition: if there are streamcopy streams (nb_interleaved > nb_av_enc),
|
|
1681
|
+
// then ALL streams use the sync queue (but as non-limiting, so no actual sync happens)
|
|
1682
|
+
const needsSyncQueue = this.options.useSyncQueue && nbInterleaved > nbAvEnc;
|
|
1683
|
+
if (needsSyncQueue && !this.syncQueue) {
|
|
1684
|
+
// Create sync queue
|
|
1685
|
+
const bufDurationSec = this.options.syncQueueBufferDuration ?? SYNC_BUFFER_DURATION;
|
|
1686
|
+
const bufSizeUs = bufDurationSec * 1000000; // Convert to microseconds
|
|
1687
|
+
this.syncQueue = SyncQueue.create(SyncQueueType.PACKETS, bufSizeUs);
|
|
1688
|
+
this.sqPacket = new Packet();
|
|
1689
|
+
this.sqPacket.alloc();
|
|
1690
|
+
// Add all streams to sync queue
|
|
1691
|
+
// FFmpeg standard (without -shortest): limiting = 0 (non-limiting)
|
|
1692
|
+
// This means frames are output immediately without synchronization
|
|
1693
|
+
for (const streamInfo of this._streams.values()) {
|
|
1694
|
+
streamInfo.sqIdxMux = this.syncQueue.addStream(0); // 0 = non-limiting
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
else if (!needsSyncQueue && this.syncQueue) {
|
|
1698
|
+
// Free sync queue if we don't need it anymore
|
|
1699
|
+
this.sqPacket?.free();
|
|
1700
|
+
this.sqPacket = undefined;
|
|
1701
|
+
this.syncQueue.free();
|
|
1702
|
+
this.syncQueue = undefined;
|
|
1703
|
+
// Reset all sqIdxMux to -1
|
|
1704
|
+
for (const streamInfo of this._streams.values()) {
|
|
1705
|
+
streamInfo.sqIdxMux = -1;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Whether any stream still holds packets (or an EOF marker) in its pre-mux queue.
|
|
1711
|
+
*
|
|
1712
|
+
* @returns True if a pre-mux queue is not empty
|
|
1713
|
+
*
|
|
1714
|
+
* @internal
|
|
1715
|
+
*/
|
|
1716
|
+
hasPreMuxPackets() {
|
|
1717
|
+
for (const streamInfo of this._streams.values()) {
|
|
1718
|
+
if (streamInfo.preMuxQueue.length > 0) {
|
|
1719
|
+
return true;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Flush all PreMuxQueues in DTS-sorted order.
|
|
1726
|
+
*
|
|
1727
|
+
* Implements FFmpeg's PreMuxQueue flush algorithm from mux_task_start().
|
|
1728
|
+
* Repeatedly finds the stream with the earliest DTS packet and sends it:
|
|
1729
|
+
* - WITH SyncQueue: Sends to SyncQueue for interleaving
|
|
1730
|
+
* - WITHOUT SyncQueue: Writes directly to muxer
|
|
1731
|
+
* NULL packets (EOF markers) and packets with AV_NOPTS_VALUE have priority (sent first).
|
|
1732
|
+
*
|
|
1733
|
+
* @internal
|
|
1734
|
+
*/
|
|
1735
|
+
async flushPreMuxQueues() {
|
|
1736
|
+
while (true) {
|
|
1737
|
+
let minStreamInfo = null;
|
|
1738
|
+
let minStreamIndex = -1;
|
|
1739
|
+
let minDts = AV_NOPTS_VALUE;
|
|
1740
|
+
let minTimeBase = { num: 1, den: 1 };
|
|
1741
|
+
// 1. Find stream with earliest DTS across all PreMuxQueues
|
|
1742
|
+
// FFmpeg logic: NULL packets and AV_NOPTS_VALUE packets have priority
|
|
1743
|
+
for (const [streamIndex, streamInfo] of this._streams) {
|
|
1744
|
+
if (streamInfo.preMuxQueue.length === 0) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
const pkt = streamInfo.preMuxQueue[0]; // Peek at first packet (can be null)
|
|
1748
|
+
// NULL packets (EOF markers) have highest priority (FFmpeg: if (!pkt) -> priority)
|
|
1749
|
+
// Packets with AV_NOPTS_VALUE also have priority
|
|
1750
|
+
if (!pkt || pkt.dts === AV_NOPTS_VALUE) {
|
|
1751
|
+
minStreamInfo = streamInfo;
|
|
1752
|
+
minStreamIndex = streamIndex;
|
|
1753
|
+
break;
|
|
1754
|
+
}
|
|
1755
|
+
// Compare DTS with current minimum
|
|
1756
|
+
if (minDts === AV_NOPTS_VALUE || avCompareTs(pkt.dts, pkt.timeBase, minDts, minTimeBase) < 0) {
|
|
1757
|
+
minStreamInfo = streamInfo;
|
|
1758
|
+
minStreamIndex = streamIndex;
|
|
1759
|
+
minDts = pkt.dts;
|
|
1760
|
+
minTimeBase = pkt.timeBase;
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
// 2. No more packets - all queues empty
|
|
1764
|
+
if (!minStreamInfo) {
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
// 3. Take packet from stream with earliest DTS (or NULL for EOF)
|
|
1768
|
+
const pkt = minStreamInfo.preMuxQueue.shift();
|
|
1769
|
+
const arrival = minStreamInfo.preMuxArrivals.shift() ?? 0;
|
|
1770
|
+
// 4. Handle NULL packet (EOF marker)
|
|
1771
|
+
// FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
|
|
1772
|
+
if (!pkt) {
|
|
1773
|
+
// Signal EOF to SyncQueue for this stream
|
|
1774
|
+
if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
|
|
1775
|
+
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, null);
|
|
1776
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
1777
|
+
if (this.options.exitOnError) {
|
|
1778
|
+
FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue during PreMuxQueue flush');
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
// If not using SyncQueue, nothing to do - stream finished without data
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
// 5. Normal packet - update data size and send
|
|
1786
|
+
minStreamInfo.preMuxQueueDataSize -= pkt.size;
|
|
1787
|
+
// 6. Send to SyncQueue or write directly
|
|
1788
|
+
pkt.streamIndex = minStreamIndex;
|
|
1789
|
+
if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
|
|
1790
|
+
// Send to SyncQueue for interleaving
|
|
1791
|
+
// NOTE: Do NOT set pkt.timeBase here!
|
|
1792
|
+
// Packet must keep its source timebase so muxFixupTs can rescale correctly
|
|
1793
|
+
// pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
|
|
1794
|
+
const size = pkt.size;
|
|
1795
|
+
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
|
|
1796
|
+
if (ret < 0 && size > 0) {
|
|
1797
|
+
this.packetObserver?.onPacketRejected(minStreamIndex);
|
|
1798
|
+
}
|
|
1799
|
+
if (ret >= 0) {
|
|
1800
|
+
minStreamInfo.sqArrivals.push(arrival);
|
|
1801
|
+
}
|
|
1802
|
+
else if (ret !== AVERROR_EOF) {
|
|
1803
|
+
if (this.options.exitOnError) {
|
|
1804
|
+
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
else {
|
|
1809
|
+
// Write directly to muxer
|
|
1810
|
+
await this.write(pkt, minStreamInfo, minStreamIndex, arrival);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
// If using SyncQueue, receive and write all interleaved packets
|
|
1814
|
+
if (this.syncQueue) {
|
|
1815
|
+
while (!this.isClosed) {
|
|
1816
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
1817
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
1818
|
+
break; // No more packets ready
|
|
1819
|
+
}
|
|
1820
|
+
if (recvRet === AVERROR_EOF) {
|
|
1821
|
+
break; // All streams finished
|
|
1822
|
+
}
|
|
1823
|
+
if (recvRet >= 0) {
|
|
1824
|
+
// recvRet is the stream index
|
|
1825
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
1826
|
+
// Clone packet before writing (muxer takes ownership and will unref it)
|
|
1827
|
+
const pkt = this.sqPacket.clone();
|
|
1828
|
+
if (!pkt) {
|
|
1829
|
+
throw new Error('Failed to clone packet from sync queue during PreMuxQueue flush');
|
|
1830
|
+
}
|
|
1831
|
+
pkt.streamIndex = recvRet;
|
|
1832
|
+
// Write packet (muxer takes ownership)
|
|
1833
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
/**
|
|
1839
|
+
* Flush all PreMuxQueues in DTS-sorted order (synchronous version).
|
|
1840
|
+
*
|
|
1841
|
+
* Implements FFmpeg's PreMuxQueue flush algorithm from mux_task_start().
|
|
1842
|
+
* Repeatedly finds the stream with the earliest DTS packet and sends it:
|
|
1843
|
+
* - WITH SyncQueue: Sends to SyncQueue for interleaving
|
|
1844
|
+
* - WITHOUT SyncQueue: Writes directly to muxer
|
|
1845
|
+
* NULL packets (EOF markers) and packets with AV_NOPTS_VALUE have priority (sent first).
|
|
1846
|
+
*
|
|
1847
|
+
* @internal
|
|
1848
|
+
*/
|
|
1849
|
+
flushPreMuxQueuesSync() {
|
|
1850
|
+
while (true) {
|
|
1851
|
+
let minStreamInfo = null;
|
|
1852
|
+
let minStreamIndex = -1;
|
|
1853
|
+
let minDts = AV_NOPTS_VALUE;
|
|
1854
|
+
let minTimeBase = { num: 1, den: 1 };
|
|
1855
|
+
// 1. Find stream with earliest DTS across all PreMuxQueues
|
|
1856
|
+
// FFmpeg logic: NULL packets and AV_NOPTS_VALUE packets have priority
|
|
1857
|
+
for (const [streamIndex, streamInfo] of this._streams) {
|
|
1858
|
+
if (streamInfo.preMuxQueue.length === 0)
|
|
1859
|
+
continue;
|
|
1860
|
+
const pkt = streamInfo.preMuxQueue[0]; // Peek at first packet (can be null)
|
|
1861
|
+
// NULL packets (EOF markers) have highest priority (FFmpeg: if (!pkt) -> priority)
|
|
1862
|
+
// Packets with AV_NOPTS_VALUE also have priority
|
|
1863
|
+
if (!pkt || pkt.dts === AV_NOPTS_VALUE) {
|
|
1864
|
+
minStreamInfo = streamInfo;
|
|
1865
|
+
minStreamIndex = streamIndex;
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
// Compare DTS with current minimum
|
|
1869
|
+
if (minDts === AV_NOPTS_VALUE || avCompareTs(pkt.dts, pkt.timeBase, minDts, minTimeBase) < 0) {
|
|
1870
|
+
minStreamInfo = streamInfo;
|
|
1871
|
+
minStreamIndex = streamIndex;
|
|
1872
|
+
minDts = pkt.dts;
|
|
1873
|
+
minTimeBase = pkt.timeBase;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
// 2. No more packets - all queues empty
|
|
1877
|
+
if (!minStreamInfo)
|
|
1878
|
+
break;
|
|
1879
|
+
// 3. Take packet from stream with earliest DTS (or NULL for EOF)
|
|
1880
|
+
const pkt = minStreamInfo.preMuxQueue.shift();
|
|
1881
|
+
const arrival = minStreamInfo.preMuxArrivals.shift() ?? 0;
|
|
1882
|
+
// 4. Handle NULL packet (EOF marker)
|
|
1883
|
+
// FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
|
|
1884
|
+
if (!pkt) {
|
|
1885
|
+
// Signal EOF to SyncQueue for this stream
|
|
1886
|
+
if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
|
|
1887
|
+
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, null);
|
|
1888
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
1889
|
+
if (this.options.exitOnError) {
|
|
1890
|
+
FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue during PreMuxQueue flush');
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
// If not using SyncQueue, nothing to do - stream finished without data
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
// 5. Normal packet - update data size and send
|
|
1898
|
+
minStreamInfo.preMuxQueueDataSize -= pkt.size;
|
|
1899
|
+
// 6. Send to SyncQueue or write directly
|
|
1900
|
+
pkt.streamIndex = minStreamIndex;
|
|
1901
|
+
if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
|
|
1902
|
+
// Send to SyncQueue for interleaving
|
|
1903
|
+
// NOTE: Do NOT set pkt.timeBase here!
|
|
1904
|
+
// Packet must keep its source timebase so muxFixupTs can rescale correctly
|
|
1905
|
+
// pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
|
|
1906
|
+
const size = pkt.size;
|
|
1907
|
+
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
|
|
1908
|
+
if (ret < 0 && size > 0) {
|
|
1909
|
+
this.packetObserver?.onPacketRejected(minStreamIndex);
|
|
1910
|
+
}
|
|
1911
|
+
if (ret >= 0) {
|
|
1912
|
+
minStreamInfo.sqArrivals.push(arrival);
|
|
1913
|
+
}
|
|
1914
|
+
else if (ret !== AVERROR_EOF) {
|
|
1915
|
+
if (this.options.exitOnError) {
|
|
1916
|
+
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
// Write directly to muxer
|
|
1922
|
+
this.writeSync(pkt, minStreamInfo, minStreamIndex, arrival);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
// If using SyncQueue, receive and write all interleaved packets
|
|
1926
|
+
if (this.syncQueue) {
|
|
1927
|
+
while (!this.isClosed) {
|
|
1928
|
+
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
1929
|
+
if (recvRet === AVERROR_EAGAIN) {
|
|
1930
|
+
break; // No more packets ready
|
|
1931
|
+
}
|
|
1932
|
+
if (recvRet === AVERROR_EOF) {
|
|
1933
|
+
break; // All streams finished
|
|
1934
|
+
}
|
|
1935
|
+
if (recvRet >= 0) {
|
|
1936
|
+
// recvRet is the stream index
|
|
1937
|
+
const recvStreamInfo = this._streams.get(recvRet);
|
|
1938
|
+
// Clone packet before writing (muxer takes ownership and will unref it)
|
|
1939
|
+
const pkt = this.sqPacket.clone();
|
|
1940
|
+
if (!pkt) {
|
|
1941
|
+
throw new Error('Failed to clone packet from sync queue during PreMuxQueue flush');
|
|
1942
|
+
}
|
|
1943
|
+
pkt.streamIndex = recvRet;
|
|
1944
|
+
// Write packet (muxer takes ownership)
|
|
1945
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Write a packet to the output.
|
|
1952
|
+
*
|
|
1953
|
+
* @param pkt - Packet to write
|
|
1954
|
+
*
|
|
1955
|
+
* @param streamInfo - Stream description
|
|
1956
|
+
*
|
|
1957
|
+
* @param streamIndex - Stream index
|
|
1958
|
+
*
|
|
1959
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
1960
|
+
*
|
|
1961
|
+
* @internal
|
|
1962
|
+
*/
|
|
1963
|
+
async write(pkt, streamInfo, streamIndex, arrival) {
|
|
1964
|
+
if (this.writeQueue) {
|
|
1965
|
+
// Use async queue for serialized writes.
|
|
1966
|
+
// If the worker died with a write error, the queue is poisoned and send()
|
|
1967
|
+
// rethrows that error - free the clone since it never reaches the worker.
|
|
1968
|
+
try {
|
|
1969
|
+
await this.writeQueue.send({ pkt, streamInfo, streamIndex, arrival });
|
|
1970
|
+
}
|
|
1971
|
+
catch (error) {
|
|
1972
|
+
pkt.free();
|
|
1973
|
+
throw error;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
else {
|
|
1977
|
+
// Direct write without serialization
|
|
1978
|
+
await this.writeInternal(pkt, streamInfo, streamIndex, arrival);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Internal write implementation.
|
|
1983
|
+
* Called either directly or through the write worker.
|
|
1984
|
+
*
|
|
1985
|
+
* @param pkt - Packet to write
|
|
1986
|
+
*
|
|
1987
|
+
* @param streamInfo - Stream description
|
|
1988
|
+
*
|
|
1989
|
+
* @param streamIndex - Stream index
|
|
1990
|
+
*
|
|
1991
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
1992
|
+
*
|
|
1993
|
+
* @internal
|
|
1994
|
+
*/
|
|
1995
|
+
async writeInternal(pkt, streamInfo, streamIndex, arrival) {
|
|
1996
|
+
// Read before the write moves the payload out (see announcePacket()).
|
|
1997
|
+
const observed = this.packetObserver !== undefined && pkt.size > 0;
|
|
1998
|
+
let accepted = false;
|
|
1999
|
+
try {
|
|
2000
|
+
// Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
|
|
2001
|
+
this.muxFixupTs(pkt, streamInfo, streamIndex, arrival);
|
|
2002
|
+
// Write the packet (muxer takes ownership and will unref it)
|
|
2003
|
+
// NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
|
|
2004
|
+
const ret = await this.formatContext.interleavedWriteFrame(pkt);
|
|
2005
|
+
accepted = ret >= 0;
|
|
2006
|
+
// Handle write errors
|
|
2007
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
2008
|
+
if (this.options.exitOnError) {
|
|
2009
|
+
FFmpegError.throwIfError(ret, 'Failed to write packet');
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
finally {
|
|
2014
|
+
if (observed && !accepted) {
|
|
2015
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
2016
|
+
}
|
|
2017
|
+
// Every packet reaching here is a clone this class made, and the write
|
|
2018
|
+
// has consumed its contents. Release the struct now instead of leaving a
|
|
2019
|
+
// GC-sized backlog of them behind - this is the last owner.
|
|
2020
|
+
pkt.free();
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Start background worker for async write queue.
|
|
2025
|
+
* Processes write jobs sequentially to prevent race conditions.
|
|
2026
|
+
*
|
|
2027
|
+
* @internal
|
|
2028
|
+
*/
|
|
2029
|
+
startWriteWorker() {
|
|
2030
|
+
if (!this.options.useAsyncWrite || this._streams.size <= 1) {
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
this.writeQueue ??= new AsyncQueue(1, (job) => job.pkt.free()); // size=1 for strict serialization
|
|
2034
|
+
this.writeWorkerPromise ??= (async () => {
|
|
2035
|
+
try {
|
|
2036
|
+
while (true) {
|
|
2037
|
+
const job = await this.writeQueue.receive();
|
|
2038
|
+
if (!job)
|
|
2039
|
+
break; // Queue closed
|
|
2040
|
+
await this.writeInternal(job.pkt, job.streamInfo, job.streamIndex, job.arrival);
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
catch (error) {
|
|
2044
|
+
// A write failure kills the worker. Without propagation the next send()
|
|
2045
|
+
// would block forever on a queue nobody drains (pipeline deadlock), so
|
|
2046
|
+
// store the error and poison the queue: pending and future send() calls
|
|
2047
|
+
// throw it instead of hanging. close() surfaces it after cleanup.
|
|
2048
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2049
|
+
this.writeWorkerError = err;
|
|
2050
|
+
this.writeQueue?.closeWithError(err);
|
|
2051
|
+
this.writeQueue?.clear(); // Free any job packet still buffered
|
|
2052
|
+
}
|
|
2053
|
+
})();
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Write a packet to the output synchronously.
|
|
2057
|
+
* Synchronous version of write.
|
|
2058
|
+
*
|
|
2059
|
+
* @param pkt - Packet to write
|
|
2060
|
+
*
|
|
2061
|
+
* @param streamInfo - Stream description
|
|
2062
|
+
*
|
|
2063
|
+
* @param streamIndex - Stream index
|
|
2064
|
+
*
|
|
2065
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2066
|
+
*
|
|
2067
|
+
* @internal
|
|
2068
|
+
*/
|
|
2069
|
+
writeSync(pkt, streamInfo, streamIndex, arrival) {
|
|
2070
|
+
const observed = this.packetObserver !== undefined && pkt.size > 0;
|
|
2071
|
+
let accepted = false;
|
|
2072
|
+
try {
|
|
2073
|
+
// Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
|
|
2074
|
+
this.muxFixupTs(pkt, streamInfo, streamIndex, arrival);
|
|
2075
|
+
// Write the packet (muxer takes ownership and will unref it)
|
|
2076
|
+
// NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
|
|
2077
|
+
const ret = this.formatContext.interleavedWriteFrameSync(pkt);
|
|
2078
|
+
accepted = ret >= 0;
|
|
2079
|
+
FFmpegError.throwIfError(ret, 'Failed to write packet');
|
|
2080
|
+
}
|
|
2081
|
+
finally {
|
|
2082
|
+
if (observed && !accepted) {
|
|
2083
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
2084
|
+
}
|
|
2085
|
+
// See writeInternal(): this is the last owner of the clone.
|
|
2086
|
+
pkt.free();
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Streamcopy packet filtering and timestamp offset.
|
|
2091
|
+
*
|
|
2092
|
+
* Applies streamcopy-specific logic before muxing:
|
|
2093
|
+
* 1. Recording time limit check
|
|
2094
|
+
* 2. Skip non-keyframe packets at start (unless copyInitialNonkeyframes)
|
|
2095
|
+
* 3. Skip packets before ts_copy_start (unless copyPriorStart)
|
|
2096
|
+
* 4. Skip packets before startTime
|
|
2097
|
+
* 5. Apply start_time timestamp offset
|
|
2098
|
+
*
|
|
2099
|
+
* @param pkt - Packet to process
|
|
2100
|
+
*
|
|
2101
|
+
* @param streamInfo - Stream description
|
|
2102
|
+
*
|
|
2103
|
+
* @param streamIndex - Stream index
|
|
2104
|
+
*
|
|
2105
|
+
* @returns true if packet should be written, false if packet should be skipped
|
|
2106
|
+
*
|
|
2107
|
+
* @throws {Error} If recording time limit reached
|
|
2108
|
+
*
|
|
2109
|
+
* @internal
|
|
2110
|
+
*/
|
|
2111
|
+
/**
|
|
2112
|
+
* Apply the configured `startTime` offset to an encoded packet, per stream.
|
|
2113
|
+
*
|
|
2114
|
+
* `startTime` exists to strip a device's boot-relative timestamp base (e.g. the
|
|
2115
|
+
* mach uptime avfoundation reports). But not every stream of an output carries
|
|
2116
|
+
* that base (e.g. zero-based audio next to device video), so subtracting a large
|
|
2117
|
+
* device startTime from such a stream would push its timestamps hugely negative and
|
|
2118
|
+
* produce a broken/overflowed edit list that strict players (QuickTime) reject. The
|
|
2119
|
+
* effective offset is therefore decided once on the stream's first packet and
|
|
2120
|
+
* clamped to what the stream actually carries —
|
|
2121
|
+
* `min(startTime, max(0, firstPts))`: boot-relative streams normalize to zero,
|
|
2122
|
+
* already-zero-based streams are left untouched.
|
|
2123
|
+
*
|
|
2124
|
+
* @param packet - Cloned packet to adjust in place
|
|
2125
|
+
*
|
|
2126
|
+
* @param streamInfo - Per-stream muxing state
|
|
2127
|
+
*
|
|
2128
|
+
* @internal
|
|
2129
|
+
*/
|
|
2130
|
+
applyStartTimeOffset(packet, streamInfo) {
|
|
2131
|
+
if (this.options.startTime === undefined) {
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (streamInfo.startTimeOffset === undefined) {
|
|
2135
|
+
const startTimeUs = BigInt(Math.floor(this.options.startTime * 1000000));
|
|
2136
|
+
const requested = avRescaleQ(startTimeUs, AV_TIME_BASE_Q, packet.timeBase);
|
|
2137
|
+
const firstTs = packet.pts !== AV_NOPTS_VALUE ? packet.pts : packet.dts;
|
|
2138
|
+
const carried = firstTs !== AV_NOPTS_VALUE && firstTs > 0n ? firstTs : 0n;
|
|
2139
|
+
streamInfo.startTimeOffset = requested < carried ? requested : carried;
|
|
2140
|
+
}
|
|
2141
|
+
const offset = streamInfo.startTimeOffset;
|
|
2142
|
+
if (offset === 0n) {
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (packet.pts !== AV_NOPTS_VALUE) {
|
|
2146
|
+
packet.pts -= offset;
|
|
2147
|
+
}
|
|
2148
|
+
if (packet.dts !== AV_NOPTS_VALUE) {
|
|
2149
|
+
packet.dts -= offset;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
ofStreamcopy(pkt, streamInfo, streamIndex) {
|
|
2153
|
+
const outputStream = this.formatContext.streams[streamIndex];
|
|
2154
|
+
if (!outputStream) {
|
|
2155
|
+
return false;
|
|
2156
|
+
}
|
|
2157
|
+
// Get DTS in AV_TIME_BASE for comparison
|
|
2158
|
+
// Use packet DTS directly
|
|
2159
|
+
const dts = pkt.dts !== AV_NOPTS_VALUE ? avRescaleQ(pkt.dts, pkt.timeBase, AV_TIME_BASE_Q) : AV_NOPTS_VALUE;
|
|
2160
|
+
const startTimeUs = this.options.startTime !== undefined ? BigInt(Math.floor(this.options.startTime * 1000000)) : AV_NOPTS_VALUE;
|
|
2161
|
+
// 1. Skip non-keyframes at start
|
|
2162
|
+
const copyInitialNonkeyframes = this.options.copyInitialNonkeyframes ?? false;
|
|
2163
|
+
if (!streamInfo.streamcopyStarted && !pkt.isKeyframe && !copyInitialNonkeyframes) {
|
|
2164
|
+
return false; // skip packet
|
|
2165
|
+
}
|
|
2166
|
+
// 2. Copy from specific start point
|
|
2167
|
+
if (!streamInfo.streamcopyStarted) {
|
|
2168
|
+
const copyPriorStart = this.options.copyPriorStart ?? -1;
|
|
2169
|
+
// Calculate ts_copy_start
|
|
2170
|
+
// Since we don't have input file timestamps, ts_copy_start is simply startTime or 0
|
|
2171
|
+
const tsCopyStart = startTimeUs !== AV_NOPTS_VALUE ? startTimeUs : 0n;
|
|
2172
|
+
// Only check ts_copy_start if copyPriorStart is not set (0 or -1)
|
|
2173
|
+
if (copyPriorStart !== 1 && tsCopyStart > 0n) {
|
|
2174
|
+
const pktTsUs = pkt.pts !== AV_NOPTS_VALUE ? avRescaleQ(pkt.pts, pkt.timeBase, AV_TIME_BASE_Q) : dts;
|
|
2175
|
+
if (pktTsUs !== AV_NOPTS_VALUE && pktTsUs < tsCopyStart) {
|
|
2176
|
+
return false; // skip packet
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
// 3. Skip packets before startTime
|
|
2180
|
+
if (startTimeUs !== AV_NOPTS_VALUE && dts !== AV_NOPTS_VALUE && dts < startTimeUs) {
|
|
2181
|
+
return false; // skip packet
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// 4. Apply start_time timestamp offset
|
|
2185
|
+
// FFmpeg uses: start_time = (of->start_time == AV_NOPTS_VALUE) ? 0 : of->start_time
|
|
2186
|
+
const startForOffset = startTimeUs !== AV_NOPTS_VALUE ? startTimeUs : 0n;
|
|
2187
|
+
const tsOffset = avRescaleQ(startForOffset, AV_TIME_BASE_Q, pkt.timeBase);
|
|
2188
|
+
if (pkt.pts !== AV_NOPTS_VALUE) {
|
|
2189
|
+
pkt.pts -= tsOffset;
|
|
2190
|
+
}
|
|
2191
|
+
if (pkt.dts === AV_NOPTS_VALUE) {
|
|
2192
|
+
// If DTS missing, use our estimated DTS
|
|
2193
|
+
if (dts !== AV_NOPTS_VALUE) {
|
|
2194
|
+
pkt.dts = avRescaleQ(dts, AV_TIME_BASE_Q, pkt.timeBase);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
else if (outputStream.codecpar.codecType === AVMEDIA_TYPE_AUDIO) {
|
|
2198
|
+
// Audio: PTS = DTS - ts_offset
|
|
2199
|
+
pkt.pts = pkt.dts - tsOffset;
|
|
2200
|
+
}
|
|
2201
|
+
if (pkt.dts !== AV_NOPTS_VALUE) {
|
|
2202
|
+
pkt.dts -= tsOffset;
|
|
2203
|
+
}
|
|
2204
|
+
// Mark streamcopy as started
|
|
2205
|
+
streamInfo.streamcopyStarted = true;
|
|
2206
|
+
return true; // Packet should be written
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Fix packet timestamps before muxing.
|
|
2210
|
+
*
|
|
2211
|
+
* Performs timestamp corrections:
|
|
2212
|
+
* 1. Rescales timestamps to output timebase (av_rescale_delta for audio streamcopy)
|
|
2213
|
+
* 2. Sets pkt.timeBase to output stream timebase
|
|
2214
|
+
* 3. Rejects backward jumps beyond dtsBackwardThreshold
|
|
2215
|
+
* 4. Fixes invalid DTS > PTS relationships
|
|
2216
|
+
* 5. Enforces monotonic DTS (never decreasing), shrinking a clamped packet's duration to the clamped step
|
|
2217
|
+
* 6. Rejects forward jumps that run ahead of real time beyond dtsForwardThreshold
|
|
2218
|
+
* 7. Rejects DTS steps/durations a movenc output cannot store, and a late stream's first packet before its start
|
|
2219
|
+
*
|
|
2220
|
+
* The stream's last muxed DTS only advances when the packet is accepted and
|
|
2221
|
+
* carries a DTS.
|
|
2222
|
+
*
|
|
2223
|
+
* @param pkt - Packet to fix
|
|
2224
|
+
*
|
|
2225
|
+
* @param streamInfo - Stream description
|
|
2226
|
+
*
|
|
2227
|
+
* @param streamIndex - Stream index
|
|
2228
|
+
*
|
|
2229
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2230
|
+
*
|
|
2231
|
+
* @throws {Error} On a rejected timestamp discontinuity
|
|
2232
|
+
*
|
|
2233
|
+
* @internal
|
|
2234
|
+
*/
|
|
2235
|
+
muxFixupTs(pkt, streamInfo, streamIndex, arrival) {
|
|
2236
|
+
const outputStream = streamInfo.outputStream;
|
|
2237
|
+
// Both are final once the header is written, which happens before the
|
|
2238
|
+
// first packet gets here.
|
|
2239
|
+
const dstTb = (streamInfo.muxTimeBase ??= outputStream.timeBase);
|
|
2240
|
+
const codecType = (streamInfo.muxCodecType ??= outputStream.codecpar.codecType);
|
|
2241
|
+
const lastMuxDts = streamInfo.lastMuxDts;
|
|
2242
|
+
// Every timestamp getter is a native call returning a new BigInt, so each
|
|
2243
|
+
// value is read once, corrected in locals, and only changes are written back.
|
|
2244
|
+
let dts = pkt.dts;
|
|
2245
|
+
let pts = pkt.pts;
|
|
2246
|
+
// Check if timestamps are valid before rescaling
|
|
2247
|
+
// FFmpeg's av_rescale_q/av_rescale_delta don't accept AV_NOPTS_VALUE
|
|
2248
|
+
if (dts === AV_NOPTS_VALUE && pts === AV_NOPTS_VALUE) {
|
|
2249
|
+
// Set packet timebase anyway for muxer
|
|
2250
|
+
pkt.timeBase = dstTb;
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
// 1. Rescale timestamps to the stream timebase
|
|
2254
|
+
const srcTb = streamInfo.sourceTimeBase;
|
|
2255
|
+
if (codecType === AVMEDIA_TYPE_AUDIO && streamInfo.isStreamCopy) {
|
|
2256
|
+
const codecpar = outputStream.codecpar;
|
|
2257
|
+
let duration = avGetAudioFrameDuration2(codecpar, pkt.size);
|
|
2258
|
+
if (!duration) {
|
|
2259
|
+
duration = codecpar.frameSize;
|
|
2260
|
+
}
|
|
2261
|
+
const fsTb = { num: 1, den: codecpar.sampleRate };
|
|
2262
|
+
dts = avRescaleDelta(srcTb, dts, fsTb, duration, streamInfo.tsRescaleDeltaLast, dstTb);
|
|
2263
|
+
pts = dts;
|
|
2264
|
+
pkt.dts = dts;
|
|
2265
|
+
pkt.pts = pts;
|
|
2266
|
+
pkt.duration = avRescaleQ(pkt.duration, srcTb, dstTb);
|
|
2267
|
+
}
|
|
2268
|
+
else {
|
|
2269
|
+
// For video or encoded audio, use regular rescaling
|
|
2270
|
+
pkt.rescaleTs(srcTb, dstTb);
|
|
2271
|
+
dts = pkt.dts;
|
|
2272
|
+
pts = pkt.pts;
|
|
2273
|
+
}
|
|
2274
|
+
// 2. Set packet timeBase
|
|
2275
|
+
// av_interleaved_write_frame uses this for sorting!
|
|
2276
|
+
pkt.timeBase = dstTb;
|
|
2277
|
+
const isAudioVideo = codecType === AVMEDIA_TYPE_AUDIO || codecType === AVMEDIA_TYPE_VIDEO;
|
|
2278
|
+
// 3. Reject a large backward jump while it is still visible - the corrections
|
|
2279
|
+
// below would turn it into a run of one-tick steps
|
|
2280
|
+
if (isAudioVideo) {
|
|
2281
|
+
this.checkBackwardDts(dts, lastMuxDts, dstTb, streamIndex);
|
|
2282
|
+
}
|
|
2283
|
+
// 4. Fix DTS > PTS (invalid relationship)
|
|
2284
|
+
// FFmpeg formula: median of (pts, dts, last_mux_dts+1)
|
|
2285
|
+
if (dts !== AV_NOPTS_VALUE && pts !== AV_NOPTS_VALUE && dts > pts) {
|
|
2286
|
+
const last = lastMuxDts !== AV_NOPTS_VALUE ? lastMuxDts + 1n : 0n;
|
|
2287
|
+
const min = pts < dts ? (pts < last ? pts : last) : dts < last ? dts : last;
|
|
2288
|
+
const max = pts > dts ? (pts > last ? pts : last) : dts > last ? dts : last;
|
|
2289
|
+
const median = pts + dts + last - min - max;
|
|
2290
|
+
pts = median;
|
|
2291
|
+
dts = median;
|
|
2292
|
+
pkt.pts = median;
|
|
2293
|
+
pkt.dts = median;
|
|
2294
|
+
}
|
|
2295
|
+
// 5. Enforce monotonic DTS
|
|
2296
|
+
let clampedDuration;
|
|
2297
|
+
if (isAudioVideo && dts !== AV_NOPTS_VALUE && lastMuxDts !== AV_NOPTS_VALUE) {
|
|
2298
|
+
// FFmpeg: max = last_mux_dts + !(oformat->flags & AVFMT_TS_NONSTRICT)
|
|
2299
|
+
// AVFMT_TS_NONSTRICT allows non-strict monotonic timestamps (equal DTS is OK)
|
|
2300
|
+
const max = lastMuxDts + (this.tsNonStrict ? 0n : 1n);
|
|
2301
|
+
if (dts < max) {
|
|
2302
|
+
// Adjust PTS if it would create invalid relationship
|
|
2303
|
+
if (pts !== AV_NOPTS_VALUE && pts >= dts && pts < max) {
|
|
2304
|
+
pts = max;
|
|
2305
|
+
pkt.pts = max;
|
|
2306
|
+
}
|
|
2307
|
+
dts = max;
|
|
2308
|
+
pkt.dts = max;
|
|
2309
|
+
// The clamped packet must not keep its source duration: after a fragment
|
|
2310
|
+
// flush movenc continues the track at the previous DTS plus its duration,
|
|
2311
|
+
// which then lies ahead of the clamped timeline. movenc corrects that by
|
|
2312
|
+
// moving later DTS itself and can end with a negative sample duration
|
|
2313
|
+
// (av_assert0 in get_cluster_duration). A duration of 0 does not help -
|
|
2314
|
+
// mux.c guesses a new one - so use the clamped step.
|
|
2315
|
+
if (max > lastMuxDts) {
|
|
2316
|
+
clampedDuration = max - lastMuxDts;
|
|
2317
|
+
pkt.duration = clampedDuration;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
// mux.c derives a missing DTS from the PTS and never above it (its PTS-0
|
|
2322
|
+
// fallback aside, which only steps one frame), so the PTS stands in for the
|
|
2323
|
+
// DTS of such a packet in the checks below.
|
|
2324
|
+
const ts = dts !== AV_NOPTS_VALUE ? dts : pts;
|
|
2325
|
+
// 6. Reject a forward jump on the DTS libavformat would see, before the
|
|
2326
|
+
// packet enters it: once in, movenc takes the jump as the duration of the
|
|
2327
|
+
// stream's previous sample.
|
|
2328
|
+
let forwardOffset = NaN;
|
|
2329
|
+
if (isAudioVideo && this.forwardThresholdMs > 0) {
|
|
2330
|
+
forwardOffset = this.checkForwardDts(ts, arrival, streamInfo, streamIndex);
|
|
2331
|
+
}
|
|
2332
|
+
// 7. Reject what movenc cannot store before it reaches its abort
|
|
2333
|
+
if (this.movSampleLimit) {
|
|
2334
|
+
const duration = clampedDuration ?? pkt.duration;
|
|
2335
|
+
this.checkMovSampleDuration(ts, duration, lastMuxDts, dstTb, streamIndex);
|
|
2336
|
+
if (this.movZeroStart && streamInfo.firstMuxTs === undefined) {
|
|
2337
|
+
this.checkMovLateStart(ts, duration, dstTb, streamIndex);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
// 8. Update last mux DTS for next packet. mux.c fills a missing DTS itself
|
|
2341
|
+
// and rejects it unless it lies past the previous one, so the last known
|
|
2342
|
+
// DTS stays a valid reference: the clamp and the guards remain armed for
|
|
2343
|
+
// the next packet instead of letting it through unchecked.
|
|
2344
|
+
if (dts !== AV_NOPTS_VALUE) {
|
|
2345
|
+
streamInfo.lastMuxDts = dts;
|
|
2346
|
+
}
|
|
2347
|
+
streamInfo.firstMuxTs ??= ts;
|
|
2348
|
+
if (streamInfo.forward && !Number.isNaN(forwardOffset)) {
|
|
2349
|
+
this.recordForwardDts(streamInfo.forward, ts, forwardOffset, arrival);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Reject a packet whose DTS lies further behind the stream's last muxed DTS
|
|
2354
|
+
* than the configured dtsBackwardThreshold.
|
|
2355
|
+
*
|
|
2356
|
+
* Expects the DTS already rescaled to the output stream time base. No-op when
|
|
2357
|
+
* the threshold is unset or 0.
|
|
2358
|
+
*
|
|
2359
|
+
* @param dts - Packet DTS in the output time base
|
|
2360
|
+
*
|
|
2361
|
+
* @param lastMuxDts - The stream's last muxed DTS
|
|
2362
|
+
*
|
|
2363
|
+
* @param timeBase - Output stream time base
|
|
2364
|
+
*
|
|
2365
|
+
* @param streamIndex - Stream index
|
|
2366
|
+
*
|
|
2367
|
+
* @throws {Error} If the backward jump exceeds the threshold
|
|
2368
|
+
*
|
|
2369
|
+
* @internal
|
|
2370
|
+
*/
|
|
2371
|
+
checkBackwardDts(dts, lastMuxDts, timeBase, streamIndex) {
|
|
2372
|
+
const threshold = this.options.dtsBackwardThreshold;
|
|
2373
|
+
if (!threshold || dts === AV_NOPTS_VALUE || lastMuxDts === AV_NOPTS_VALUE || dts >= lastMuxDts) {
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
const regression = lastMuxDts - dts;
|
|
2377
|
+
const thresholdUs = BigInt(Math.round(threshold * 1_000_000));
|
|
2378
|
+
if (avCompareTs(regression, timeBase, thresholdUs, AV_TIME_BASE_Q) <= 0) {
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
const seconds = ((Number(regression) * timeBase.num) / timeBase.den).toFixed(3);
|
|
2382
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: DTS jumped back ${seconds}s, more than dtsBackwardThreshold (${threshold}s)`);
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Reject a packet whose DTS runs further ahead of real time than the
|
|
2386
|
+
* configured dtsForwardThreshold.
|
|
2387
|
+
*
|
|
2388
|
+
* Tracks per stream how real time and media time move apart (the offset) and
|
|
2389
|
+
* compares the packet's offset with the lowest one of the stream's recent
|
|
2390
|
+
* packets, see FORWARD_WINDOW_BUCKET. A gap in a live source leaves the
|
|
2391
|
+
* offset level, since the wait comes with it; a jump lowers it by the size
|
|
2392
|
+
* of the jump. The stream's first packet only sets the starting point.
|
|
2393
|
+
*
|
|
2394
|
+
* @param ts - Final packet DTS (PTS without DTS) in the output time base
|
|
2395
|
+
*
|
|
2396
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2397
|
+
*
|
|
2398
|
+
* @param streamInfo - Stream description
|
|
2399
|
+
*
|
|
2400
|
+
* @param streamIndex - Stream index
|
|
2401
|
+
*
|
|
2402
|
+
* @returns The packet's offset, for {@link recordForwardDts} once the packet is accepted
|
|
2403
|
+
*
|
|
2404
|
+
* @throws {Error} If the DTS runs ahead of real time by more than the threshold
|
|
2405
|
+
*
|
|
2406
|
+
* @internal
|
|
2407
|
+
*/
|
|
2408
|
+
checkForwardDts(ts, arrival, streamInfo, streamIndex) {
|
|
2409
|
+
const tb = streamInfo.muxTimeBase;
|
|
2410
|
+
const state = (streamInfo.forward ??= {
|
|
2411
|
+
msPerTick: (1000 * tb.num) / tb.den,
|
|
2412
|
+
lastTs: AV_NOPTS_VALUE,
|
|
2413
|
+
lastArrival: NaN,
|
|
2414
|
+
offset: 0,
|
|
2415
|
+
windowMin: Infinity,
|
|
2416
|
+
windowPrevMin: Infinity,
|
|
2417
|
+
windowCount: 0,
|
|
2418
|
+
});
|
|
2419
|
+
if (Number.isNaN(state.lastArrival)) {
|
|
2420
|
+
return 0;
|
|
2421
|
+
}
|
|
2422
|
+
const stepMs = Number(ts - state.lastTs) * state.msPerTick;
|
|
2423
|
+
const elapsedMs = arrival - state.lastArrival;
|
|
2424
|
+
const offset = state.offset + elapsedMs - stepMs;
|
|
2425
|
+
const reference = state.windowMin < state.windowPrevMin ? state.windowMin : state.windowPrevMin;
|
|
2426
|
+
if (reference - offset <= this.forwardThresholdMs) {
|
|
2427
|
+
return offset;
|
|
2428
|
+
}
|
|
2429
|
+
const jump = `DTS jumped forward ${(stepMs / 1000).toFixed(3)}s while ${(elapsedMs / 1000).toFixed(3)}s passed`;
|
|
2430
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${jump}, more than dtsForwardThreshold (${this.options.dtsForwardThreshold}s)`);
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Make an accepted packet the reference of the stream's next forward check.
|
|
2434
|
+
*
|
|
2435
|
+
* @param state - The stream's forward check state
|
|
2436
|
+
*
|
|
2437
|
+
* @param ts - The packet's DTS (PTS without DTS) in the output time base
|
|
2438
|
+
*
|
|
2439
|
+
* @param offset - The packet's offset from {@link checkForwardDts}
|
|
2440
|
+
*
|
|
2441
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2442
|
+
*
|
|
2443
|
+
* @internal
|
|
2444
|
+
*/
|
|
2445
|
+
recordForwardDts(state, ts, offset, arrival) {
|
|
2446
|
+
state.lastTs = ts;
|
|
2447
|
+
state.offset = offset;
|
|
2448
|
+
state.lastArrival = arrival;
|
|
2449
|
+
if (offset < state.windowMin) {
|
|
2450
|
+
state.windowMin = offset;
|
|
2451
|
+
}
|
|
2452
|
+
// Two buckets keep the window between one and two buckets long in O(1).
|
|
2453
|
+
if (++state.windowCount >= FORWARD_WINDOW_BUCKET) {
|
|
2454
|
+
state.windowPrevMin = state.windowMin;
|
|
2455
|
+
state.windowMin = Infinity;
|
|
2456
|
+
state.windowCount = 0;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
/**
|
|
2460
|
+
* Reject a packet whose DTS step or duration a movenc output cannot store.
|
|
2461
|
+
*
|
|
2462
|
+
* Runs after the monotonic clamp, on the DTS movenc will see. Steps are
|
|
2463
|
+
* measured against the stream's last muxed DTS.
|
|
2464
|
+
*
|
|
2465
|
+
* @param dts - Final packet DTS, or its PTS for a packet without DTS
|
|
2466
|
+
*
|
|
2467
|
+
* @param duration - Final packet duration
|
|
2468
|
+
*
|
|
2469
|
+
* @param lastMuxDts - The stream's last muxed DTS
|
|
2470
|
+
*
|
|
2471
|
+
* @param timeBase - Output stream time base
|
|
2472
|
+
*
|
|
2473
|
+
* @param streamIndex - Stream index
|
|
2474
|
+
*
|
|
2475
|
+
* @throws {Error} If the step or duration reaches INT_MAX ticks
|
|
2476
|
+
*
|
|
2477
|
+
* @internal
|
|
2478
|
+
*/
|
|
2479
|
+
checkMovSampleDuration(dts, duration, lastMuxDts, timeBase, streamIndex) {
|
|
2480
|
+
const step = dts !== AV_NOPTS_VALUE && lastMuxDts !== AV_NOPTS_VALUE ? dts - lastMuxDts : 0n;
|
|
2481
|
+
const ticks = step >= MOV_SAMPLE_DURATION_LIMIT ? step : duration >= MOV_SAMPLE_DURATION_LIMIT ? duration : undefined;
|
|
2482
|
+
if (ticks === undefined) {
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const what = ticks === step ? 'DTS jumped forward' : 'packet duration is';
|
|
2486
|
+
const seconds = ((Number(ticks) * timeBase.num) / timeBase.den).toFixed(3);
|
|
2487
|
+
const format = this.formatContext.oformat?.name ?? 'mov';
|
|
2488
|
+
const detail = `${what} ${ticks} ticks (${seconds}s at time base ${timeBase.num}/${timeBase.den})`;
|
|
2489
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${detail}, but ${format} sample durations must stay below ${MOV_SAMPLE_DURATION_LIMIT} ticks`);
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Reject a stream's first packet that ends before the output's start.
|
|
2493
|
+
*
|
|
2494
|
+
* Without an edit list movenc puts each track's first sample at the output's
|
|
2495
|
+
* start, so a first packet that ends before it becomes a sample of negative
|
|
2496
|
+
* duration, and movenc aborts the process (av_assert0 in
|
|
2497
|
+
* get_cluster_duration) at the next fragment or the trailer unless a second
|
|
2498
|
+
* sample reaches it first. libavformat sets that start from the lowest DTS
|
|
2499
|
+
* it holds when it writes its first packet. It holds every packet until each
|
|
2500
|
+
* stream has sent one or its queue spans max_interleave_delta, so only a
|
|
2501
|
+
* stream that starts that much later than the others can land before it.
|
|
2502
|
+
*
|
|
2503
|
+
* @param ts - Packet DTS (PTS without DTS) in the output time base
|
|
2504
|
+
*
|
|
2505
|
+
* @param duration - Packet duration
|
|
2506
|
+
*
|
|
2507
|
+
* @param timeBase - Output stream time base
|
|
2508
|
+
*
|
|
2509
|
+
* @param streamIndex - Stream index
|
|
2510
|
+
*
|
|
2511
|
+
* @throws {Error} If the packet ends before a start libavformat may already have set
|
|
2512
|
+
*
|
|
2513
|
+
* @internal
|
|
2514
|
+
*/
|
|
2515
|
+
checkMovLateStart(ts, duration, timeBase, streamIndex) {
|
|
2516
|
+
if (this.interleaveDeltaUs <= 0n) {
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
// The other streams' earliest and latest timestamp so far, in µs
|
|
2520
|
+
let start;
|
|
2521
|
+
let startUs = 0n;
|
|
2522
|
+
let latestUs = 0n;
|
|
2523
|
+
for (const [index, other] of this._streams) {
|
|
2524
|
+
const first = other.firstMuxTs;
|
|
2525
|
+
const otherTb = other.muxTimeBase;
|
|
2526
|
+
if (first === undefined || !otherTb) {
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
const firstUs = avRescaleQ(first, otherTb, AV_TIME_BASE_Q);
|
|
2530
|
+
const lastUs = other.lastMuxDts !== AV_NOPTS_VALUE ? avRescaleQ(other.lastMuxDts, otherTb, AV_TIME_BASE_Q) : firstUs;
|
|
2531
|
+
if (!start) {
|
|
2532
|
+
latestUs = lastUs;
|
|
2533
|
+
}
|
|
2534
|
+
else if (lastUs > latestUs) {
|
|
2535
|
+
latestUs = lastUs;
|
|
2536
|
+
}
|
|
2537
|
+
if (!start || firstUs < startUs) {
|
|
2538
|
+
start = { ts: first, timeBase: otherTb, index };
|
|
2539
|
+
startUs = firstUs;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
// Until its queue spans max_interleave_delta, libavformat has written nothing
|
|
2543
|
+
// and will start the output at this packet if it is the lowest.
|
|
2544
|
+
if (!start || latestUs - startUs <= this.interleaveDeltaUs) {
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
const end = ts + (duration > 0n ? duration : 0n);
|
|
2548
|
+
if (avCompareTs(end, timeBase, start.ts, start.timeBase) >= 0) {
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
const seconds = (Number(startUs - avRescaleQ(end, timeBase, AV_TIME_BASE_Q)) / 1_000_000).toFixed(3);
|
|
2552
|
+
const format = this.formatContext.oformat?.name ?? 'mov';
|
|
2553
|
+
const detail = `its first packet ends ${seconds}s before the start of output stream ${start.index}`;
|
|
2554
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${detail}, which ${format} without an edit list cannot store`);
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Copy container metadata from input to output.
|
|
2558
|
+
*
|
|
2559
|
+
* Automatically copies global metadata from input Demuxer to output format context.
|
|
2560
|
+
* Only copies once (on first call). Removes duration/creation_time metadata.
|
|
2561
|
+
*
|
|
2562
|
+
* @internal
|
|
2563
|
+
*/
|
|
2564
|
+
copyContainerMetadata() {
|
|
2565
|
+
if (this.containerMetadataCopied || !this.options.input) {
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
const demuxer = 'input' in this.options.input ? this.options.input.input : this.options.input;
|
|
2569
|
+
const inputFormatContext = demuxer.getFormatContext();
|
|
2570
|
+
const inputMetadata = inputFormatContext.metadata;
|
|
2571
|
+
if (inputMetadata) {
|
|
2572
|
+
// Keys that FFmpeg removes after copying
|
|
2573
|
+
const keysToSkip = new Set(['duration', 'creation_time', 'company_name', 'product_name', 'product_version']);
|
|
2574
|
+
// Get all input metadata entries
|
|
2575
|
+
const entries = inputMetadata.getAll();
|
|
2576
|
+
// Filter out keys that should be skipped
|
|
2577
|
+
const filteredEntries = {};
|
|
2578
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
2579
|
+
if (!keysToSkip.has(key)) {
|
|
2580
|
+
filteredEntries[key] = value;
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
// Create new dictionary with filtered entries
|
|
2584
|
+
const metadata = Dictionary.fromObject(filteredEntries);
|
|
2585
|
+
// Set metadata to format context. The setter copies the content via
|
|
2586
|
+
// av_dict_copy, so the local dictionary must be freed afterwards.
|
|
2587
|
+
try {
|
|
2588
|
+
this.formatContext.metadata = metadata;
|
|
2589
|
+
}
|
|
2590
|
+
finally {
|
|
2591
|
+
metadata.free();
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
this.containerMetadataCopied = true;
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Auto-set DEFAULT disposition for first stream of each type.
|
|
2598
|
+
*
|
|
2599
|
+
* FFmpeg automatically sets DEFAULT flag for the first stream of each type
|
|
2600
|
+
* if no stream of that type has DEFAULT set yet.
|
|
2601
|
+
*
|
|
2602
|
+
* @internal
|
|
2603
|
+
*/
|
|
2604
|
+
updateDefaultDisposition() {
|
|
2605
|
+
// Group streams by media type
|
|
2606
|
+
const streamsByType = new Map();
|
|
2607
|
+
for (const streamInfo of this._streams.values()) {
|
|
2608
|
+
const codecType = streamInfo.outputStream.codecpar.codecType;
|
|
2609
|
+
if (!streamsByType.has(codecType)) {
|
|
2610
|
+
streamsByType.set(codecType, []);
|
|
2611
|
+
}
|
|
2612
|
+
streamsByType.get(codecType).push(streamInfo.outputStream);
|
|
2613
|
+
}
|
|
2614
|
+
// For each media type, check if any stream has DEFAULT disposition
|
|
2615
|
+
// If not, set DEFAULT on first stream
|
|
2616
|
+
for (const [_, streams] of streamsByType.entries()) {
|
|
2617
|
+
// Skip if only one stream of this type
|
|
2618
|
+
if (streams.length < 2) {
|
|
2619
|
+
continue;
|
|
2620
|
+
}
|
|
2621
|
+
// Check if any stream already has DEFAULT disposition
|
|
2622
|
+
const hasDefault = streams.some((s) => s.hasDisposition(AV_DISPOSITION_DEFAULT));
|
|
2623
|
+
if (!hasDefault) {
|
|
2624
|
+
// Find first stream that is not an attached picture
|
|
2625
|
+
const firstNonAttachedPic = streams.find((s) => !s.hasDisposition(AV_DISPOSITION_ATTACHED_PIC));
|
|
2626
|
+
if (firstNonAttachedPic) {
|
|
2627
|
+
// Set DEFAULT on first non-attached-picture stream
|
|
2628
|
+
firstNonAttachedPic.setDisposition(AV_DISPOSITION_DEFAULT);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
/**
|
|
2634
|
+
* Dispose of muxer.
|
|
2635
|
+
*
|
|
2636
|
+
* Implements AsyncDisposable interface for automatic cleanup.
|
|
2637
|
+
* Equivalent to calling close().
|
|
2638
|
+
*
|
|
2639
|
+
* @example
|
|
2640
|
+
* ```typescript
|
|
2641
|
+
* {
|
|
2642
|
+
* await using output = await Muxer.open('output.mp4');
|
|
2643
|
+
* // Use output...
|
|
2644
|
+
* } // Automatically closed
|
|
2645
|
+
* ```
|
|
2646
|
+
*
|
|
2647
|
+
* @see {@link close} For manual cleanup
|
|
2648
|
+
*/
|
|
2649
|
+
async [Symbol.asyncDispose]() {
|
|
2650
|
+
await this.close();
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Dispose of muxer synchronously.
|
|
2654
|
+
*
|
|
2655
|
+
* Implements Disposable interface for automatic cleanup.
|
|
2656
|
+
* Equivalent to calling closeSync().
|
|
2657
|
+
*
|
|
2658
|
+
* @example
|
|
2659
|
+
* ```typescript
|
|
2660
|
+
* {
|
|
2661
|
+
* using output = Muxer.openSync('output.mp4');
|
|
2662
|
+
* // Use output...
|
|
2663
|
+
* } // Automatically closed
|
|
2664
|
+
* ```
|
|
2665
|
+
*
|
|
2666
|
+
* @see {@link closeSync} For manual cleanup
|
|
2667
|
+
*/
|
|
2668
|
+
[Symbol.dispose]() {
|
|
2669
|
+
this.closeSync();
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
//# sourceMappingURL=muxer.js.map
|