node-av 6.2.0-beta.3 → 6.2.0-beta.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/dist/api/audio-frame-buffer.d.ts +438 -7
- package/dist/api/audio-frame-buffer.js +792 -52
- package/dist/api/audio-frame-buffer.js.map +1 -1
- package/dist/api/bitstream-filter.d.ts +82 -4
- package/dist/api/bitstream-filter.js +197 -100
- package/dist/api/bitstream-filter.js.map +1 -1
- package/dist/api/decoder.d.ts +5 -0
- package/dist/api/decoder.js +13 -6
- package/dist/api/decoder.js.map +1 -1
- package/dist/api/demuxer.d.ts +29 -4
- package/dist/api/demuxer.js +209 -60
- package/dist/api/demuxer.js.map +1 -1
- package/dist/api/encoder-pool.d.ts +43 -1
- package/dist/api/encoder-pool.js +121 -9
- package/dist/api/encoder-pool.js.map +1 -1
- package/dist/api/encoder.d.ts +58 -2
- package/dist/api/encoder.js +173 -57
- package/dist/api/encoder.js.map +1 -1
- package/dist/api/filter.js +15 -6
- package/dist/api/filter.js.map +1 -1
- package/dist/api/fmp4-stream.d.ts +327 -8
- package/dist/api/fmp4-stream.js +663 -123
- package/dist/api/fmp4-stream.js.map +1 -1
- package/dist/api/hardware.d.ts +2 -0
- package/dist/api/hardware.js +25 -4
- package/dist/api/hardware.js.map +1 -1
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js.map +1 -1
- package/dist/api/io-stream.d.ts +3 -2
- package/dist/api/io-stream.js +77 -5
- package/dist/api/io-stream.js.map +1 -1
- package/dist/api/muxer.d.ts +289 -7
- package/dist/api/muxer.js +696 -109
- package/dist/api/muxer.js.map +1 -1
- package/dist/api/pipeline.d.ts +24 -0
- package/dist/api/pipeline.js +76 -5
- package/dist/api/pipeline.js.map +1 -1
- package/dist/api/rtp-stream.d.ts +77 -8
- package/dist/api/rtp-stream.js +316 -88
- package/dist/api/rtp-stream.js.map +1 -1
- package/dist/api/scaler.d.ts +124 -21
- package/dist/api/scaler.js +347 -114
- package/dist/api/scaler.js.map +1 -1
- package/dist/api/utilities/async-queue.d.ts +3 -0
- package/dist/api/utilities/async-queue.js +8 -0
- package/dist/api/utilities/async-queue.js.map +1 -1
- package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
- package/dist/api/utilities/packet-age-watchdog.js +594 -0
- package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
- package/dist/api/webrtc-stream.d.ts +2 -0
- package/dist/api/webrtc-stream.js +46 -12
- package/dist/api/webrtc-stream.js.map +1 -1
- package/dist/api/whisper.d.ts +2 -0
- package/dist/api/whisper.js +63 -23
- package/dist/api/whisper.js.map +1 -1
- package/dist/constants/constants.d.ts +1 -0
- package/dist/constants/constants.js +1 -0
- package/dist/constants/constants.js.map +1 -1
- package/dist/constants/filter-options.d.ts +2 -0
- package/dist/ffmpeg/install.js +104 -26
- package/dist/ffmpeg/install.js.map +1 -1
- package/dist/ffmpeg/utils.js +7 -4
- package/dist/ffmpeg/utils.js.map +1 -1
- package/dist/lib/binding.d.ts +10 -1
- package/dist/lib/binding.js +25 -16
- package/dist/lib/binding.js.map +1 -1
- package/dist/lib/bitstream-filter-context.d.ts +2 -0
- package/dist/lib/bitstream-filter-context.js +2 -0
- package/dist/lib/bitstream-filter-context.js.map +1 -1
- package/dist/lib/codec-context.d.ts +0 -2
- package/dist/lib/codec-context.js +4 -22
- package/dist/lib/codec-context.js.map +1 -1
- package/dist/lib/filter-context.d.ts +3 -1
- package/dist/lib/filter-context.js +5 -11
- package/dist/lib/filter-context.js.map +1 -1
- package/dist/lib/filter-graph-segment.d.ts +3 -2
- package/dist/lib/filter-graph-segment.js +3 -2
- package/dist/lib/filter-graph-segment.js.map +1 -1
- package/dist/lib/filter-graph.d.ts +4 -4
- package/dist/lib/filter-graph.js +4 -4
- package/dist/lib/filter-inout.d.ts +4 -0
- package/dist/lib/filter-inout.js +4 -0
- package/dist/lib/filter-inout.js.map +1 -1
- package/dist/lib/format-context.d.ts +72 -7
- package/dist/lib/format-context.js +80 -22
- package/dist/lib/format-context.js.map +1 -1
- package/dist/lib/frame.d.ts +17 -13
- package/dist/lib/frame.js +24 -23
- package/dist/lib/frame.js.map +1 -1
- package/dist/lib/hardware-frames-context.d.ts +0 -1
- package/dist/lib/hardware-frames-context.js +2 -8
- package/dist/lib/hardware-frames-context.js.map +1 -1
- package/dist/lib/io-context.d.ts +4 -2
- package/dist/lib/io-context.js +4 -2
- package/dist/lib/io-context.js.map +1 -1
- package/dist/lib/native-types.d.ts +7 -5
- package/dist/lib/option.d.ts +26 -0
- package/dist/lib/option.js +42 -18
- package/dist/lib/option.js.map +1 -1
- package/dist/lib/packet.d.ts +5 -0
- package/dist/lib/packet.js +5 -0
- package/dist/lib/packet.js.map +1 -1
- package/dist/lib/stream.d.ts +8 -4
- package/dist/lib/stream.js +22 -24
- package/dist/lib/stream.js.map +1 -1
- package/install/check.js +40 -79
- package/package.json +45 -34
- package/build_mac_local.sh +0 -69
package/dist/api/muxer.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdirSync } from 'fs';
|
|
|
2
2
|
import { mkdir } from 'fs/promises';
|
|
3
3
|
import { Writable } from 'node:stream';
|
|
4
4
|
import { dirname, resolve } from 'path';
|
|
5
|
-
import { AV_CODEC_FLAG_GLOBAL_HEADER, AV_DISPOSITION_ATTACHED_PIC, AV_DISPOSITION_DEFAULT, AV_NOPTS_VALUE, AV_TIME_BASE_Q, AVERROR_EAGAIN, AVERROR_EOF, AVFMT_FLAG_CUSTOM_IO, AVFMT_GLOBALHEADER, AVFMT_NOFILE, AVFMT_TS_NONSTRICT, AVIO_FLAG_WRITE, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO, } from '../constants/constants.js';
|
|
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
6
|
import { Dictionary } from '../lib/dictionary.js';
|
|
7
7
|
import { FFmpegError } from '../lib/error.js';
|
|
8
8
|
import { FormatContext } from '../lib/format-context.js';
|
|
@@ -16,6 +16,35 @@ import { Encoder } from './encoder.js';
|
|
|
16
16
|
import { IOStream } from './io-stream.js';
|
|
17
17
|
import { AsyncQueue } from './utilities/async-queue.js';
|
|
18
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;
|
|
19
48
|
/**
|
|
20
49
|
* High-level muxer for writing and muxing media files.
|
|
21
50
|
*
|
|
@@ -69,6 +98,7 @@ export class Muxer {
|
|
|
69
98
|
options;
|
|
70
99
|
_streams = new Map();
|
|
71
100
|
ioContext;
|
|
101
|
+
customIO = false; // ioContext is callback-backed (Writable/IOOutputCallbacks) - freed, never closed
|
|
72
102
|
headerWritten = false;
|
|
73
103
|
headerWritePromise;
|
|
74
104
|
trailerWritten = false;
|
|
@@ -78,13 +108,32 @@ export class Muxer {
|
|
|
78
108
|
containerMetadataCopied = false; // Track if container metadata has been copied
|
|
79
109
|
writeQueue; // Optional async queue for serialized writes
|
|
80
110
|
writeWorkerPromise; // Background worker promise
|
|
111
|
+
writeWorkerError; // First error from the write worker - poisons subsequent writes and close()
|
|
81
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)
|
|
82
121
|
/**
|
|
83
122
|
* @param options - Media output options
|
|
84
123
|
*
|
|
124
|
+
* @throws {RangeError} If dtsBackwardThreshold or dtsForwardThreshold is not a finite number >= 0
|
|
125
|
+
*
|
|
85
126
|
* @internal
|
|
86
127
|
*/
|
|
87
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
|
+
}
|
|
88
137
|
this.options = {
|
|
89
138
|
copyInitialNonkeyframes: false,
|
|
90
139
|
exitOnError: true,
|
|
@@ -92,6 +141,7 @@ export class Muxer {
|
|
|
92
141
|
useAsyncWrite: true,
|
|
93
142
|
...options,
|
|
94
143
|
};
|
|
144
|
+
this.forwardThresholdMs = (options?.dtsForwardThreshold ?? 0) * 1000;
|
|
95
145
|
this.formatContext = new FormatContext();
|
|
96
146
|
}
|
|
97
147
|
static async open(target, options) {
|
|
@@ -143,6 +193,7 @@ export class Muxer {
|
|
|
143
193
|
}
|
|
144
194
|
}
|
|
145
195
|
output.ioContext = IOStream.createOutput(target, options);
|
|
196
|
+
output.customIO = true;
|
|
146
197
|
output.formatContext.pb = output.ioContext;
|
|
147
198
|
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
148
199
|
}
|
|
@@ -161,6 +212,7 @@ export class Muxer {
|
|
|
161
212
|
}
|
|
162
213
|
}
|
|
163
214
|
output.ioContext = IOStream.createOutput(target, options);
|
|
215
|
+
output.customIO = true;
|
|
164
216
|
output.formatContext.pb = output.ioContext;
|
|
165
217
|
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
166
218
|
}
|
|
@@ -174,10 +226,10 @@ export class Muxer {
|
|
|
174
226
|
// Cleanup on error
|
|
175
227
|
if (output.ioContext) {
|
|
176
228
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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) {
|
|
181
233
|
// For custom IO with callbacks, free the context
|
|
182
234
|
output.ioContext.freeContext();
|
|
183
235
|
}
|
|
@@ -250,6 +302,7 @@ export class Muxer {
|
|
|
250
302
|
}
|
|
251
303
|
}
|
|
252
304
|
output.ioContext = IOStream.createOutput(target, options);
|
|
305
|
+
output.customIO = true;
|
|
253
306
|
output.formatContext.pb = output.ioContext;
|
|
254
307
|
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
255
308
|
}
|
|
@@ -268,6 +321,7 @@ export class Muxer {
|
|
|
268
321
|
}
|
|
269
322
|
}
|
|
270
323
|
output.ioContext = IOStream.createOutput(target, options);
|
|
324
|
+
output.customIO = true;
|
|
271
325
|
output.formatContext.pb = output.ioContext;
|
|
272
326
|
output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
273
327
|
}
|
|
@@ -281,10 +335,10 @@ export class Muxer {
|
|
|
281
335
|
// Cleanup on error
|
|
282
336
|
if (output.ioContext) {
|
|
283
337
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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) {
|
|
288
342
|
// For custom IO with callbacks, free the context
|
|
289
343
|
output.ioContext.freeContext();
|
|
290
344
|
}
|
|
@@ -480,6 +534,8 @@ export class Muxer {
|
|
|
480
534
|
isStreamCopy: true,
|
|
481
535
|
sqIdxMux: -1, // Will be set if sync queue is needed
|
|
482
536
|
preMuxQueue: [],
|
|
537
|
+
preMuxArrivals: [],
|
|
538
|
+
sqArrivals: [],
|
|
483
539
|
preMuxQueueDataSize: 0,
|
|
484
540
|
eofReceived: false,
|
|
485
541
|
lastMuxDts: AV_NOPTS_VALUE,
|
|
@@ -501,6 +557,8 @@ export class Muxer {
|
|
|
501
557
|
isStreamCopy: false,
|
|
502
558
|
sqIdxMux: -1, // Will be set if sync queue is needed
|
|
503
559
|
preMuxQueue: [],
|
|
560
|
+
preMuxArrivals: [],
|
|
561
|
+
sqArrivals: [],
|
|
504
562
|
preMuxQueueDataSize: 0,
|
|
505
563
|
eofReceived: false,
|
|
506
564
|
lastMuxDts: AV_NOPTS_VALUE,
|
|
@@ -658,6 +716,9 @@ export class Muxer {
|
|
|
658
716
|
*
|
|
659
717
|
* @throws {Error} If stream invalid or encoder not initialized
|
|
660
718
|
*
|
|
719
|
+
* @throws {Error} On a timestamp discontinuity the output cannot take (see {@link MuxerOptions.dtsBackwardThreshold}).
|
|
720
|
+
* With the background write queue (more than one stream) it surfaces from a later writePacket() or close().
|
|
721
|
+
*
|
|
661
722
|
* @throws {FFmpegError} If write fails
|
|
662
723
|
*
|
|
663
724
|
* @example
|
|
@@ -685,6 +746,10 @@ export class Muxer {
|
|
|
685
746
|
*/
|
|
686
747
|
async writePacket(packet, streamIndex) {
|
|
687
748
|
this.signal?.throwIfAborted();
|
|
749
|
+
// A previous background write failed - surface it instead of buffering more packets
|
|
750
|
+
if (this.writeWorkerError) {
|
|
751
|
+
throw this.writeWorkerError;
|
|
752
|
+
}
|
|
688
753
|
if (this.isClosed) {
|
|
689
754
|
throw new Error('Muxer is closed');
|
|
690
755
|
}
|
|
@@ -777,6 +842,7 @@ export class Muxer {
|
|
|
777
842
|
if (uninitialized || this.headerWritePromise) {
|
|
778
843
|
// Buffer NULL as EOF marker (no size contribution)
|
|
779
844
|
streamInfo.preMuxQueue.push(null);
|
|
845
|
+
streamInfo.preMuxArrivals.push(0);
|
|
780
846
|
return;
|
|
781
847
|
}
|
|
782
848
|
// PHASE 2: After muxer started - send EOF to SyncQueue and flush
|
|
@@ -809,7 +875,7 @@ export class Muxer {
|
|
|
809
875
|
throw new Error('Failed to clone packet from sync queue');
|
|
810
876
|
}
|
|
811
877
|
pkt.streamIndex = recvRet;
|
|
812
|
-
await this.write(pkt, recvStreamInfo, recvRet);
|
|
878
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
813
879
|
}
|
|
814
880
|
}
|
|
815
881
|
}
|
|
@@ -820,6 +886,10 @@ export class Muxer {
|
|
|
820
886
|
if (!clonedPacket) {
|
|
821
887
|
throw new Error('Failed to clone packet for writing');
|
|
822
888
|
}
|
|
889
|
+
// The forward check compares DTS steps with the time between packets as
|
|
890
|
+
// they reach the muxer. Taken here and carried through the pre-mux, sync
|
|
891
|
+
// and write queues, so time a packet spends queued does not count.
|
|
892
|
+
const arrival = this.forwardThresholdMs > 0 ? this.clock() : 0;
|
|
823
893
|
// Apply streamcopy filtering BEFORE buffering
|
|
824
894
|
// This ensures rejected packets never enter the queue/buffer
|
|
825
895
|
if (streamInfo.isStreamCopy) {
|
|
@@ -833,6 +903,9 @@ export class Muxer {
|
|
|
833
903
|
// For encoded (non-streamcopy) streams, strip the device's startTime base.
|
|
834
904
|
this.applyStartTimeOffset(clonedPacket, streamInfo);
|
|
835
905
|
}
|
|
906
|
+
// Announced on acceptance, so the observer also counts what waits in the
|
|
907
|
+
// pre-mux queue for the header.
|
|
908
|
+
const observed = this.announcePacket(clonedPacket, streamIndex);
|
|
836
909
|
// Check if any streams are still uninitialized or header is being written
|
|
837
910
|
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
838
911
|
// PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
|
|
@@ -850,12 +923,16 @@ export class Muxer {
|
|
|
850
923
|
// Check if we would exceed packet limit (only if threshold reached)
|
|
851
924
|
if (currentPackets >= effectiveMaxPackets) {
|
|
852
925
|
clonedPacket.free(); // Free the clone since we can't buffer it
|
|
926
|
+
if (observed) {
|
|
927
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
928
|
+
}
|
|
853
929
|
throw new Error(
|
|
854
930
|
// eslint-disable-next-line @stylistic/max-len
|
|
855
931
|
`Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
|
|
856
932
|
}
|
|
857
933
|
// Buffer in PreMuxQueue (per-stream FIFO)
|
|
858
934
|
streamInfo.preMuxQueue.push(clonedPacket);
|
|
935
|
+
streamInfo.preMuxArrivals.push(arrival);
|
|
859
936
|
streamInfo.preMuxQueueDataSize += packetSize;
|
|
860
937
|
return; // Don't proceed to header write yet
|
|
861
938
|
}
|
|
@@ -863,16 +940,20 @@ export class Muxer {
|
|
|
863
940
|
if (!this.headerWritten) {
|
|
864
941
|
this.headerWritePromise ??= (async () => {
|
|
865
942
|
this.startWriteWorker();
|
|
866
|
-
this.
|
|
867
|
-
this.updateDefaultDisposition();
|
|
868
|
-
this.copyContainerMetadata();
|
|
869
|
-
this.options.configure?.(this.formatContext);
|
|
943
|
+
this.prepareHeaderWrite();
|
|
870
944
|
const ret = await this.formatContext.writeHeader();
|
|
871
945
|
FFmpegError.throwIfError(ret, 'Failed to write header');
|
|
872
946
|
this.headerWritten = true;
|
|
947
|
+
this.readHeaderSetup();
|
|
873
948
|
// PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
|
|
874
949
|
// Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
|
|
875
|
-
|
|
950
|
+
// Concurrent writers keep queueing while headerWritePromise is set, also
|
|
951
|
+
// after a flush found the queues empty. Drain until they are empty and
|
|
952
|
+
// clear the promise in that same tick, or such packets are never written.
|
|
953
|
+
do {
|
|
954
|
+
await this.flushPreMuxQueues();
|
|
955
|
+
} while (this.hasPreMuxPackets());
|
|
956
|
+
this.headerWritePromise = undefined;
|
|
876
957
|
})();
|
|
877
958
|
await this.headerWritePromise;
|
|
878
959
|
if (this.headerWritten) {
|
|
@@ -888,6 +969,9 @@ export class Muxer {
|
|
|
888
969
|
const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
|
|
889
970
|
// Handle errors from sq_send
|
|
890
971
|
if (ret < 0) {
|
|
972
|
+
if (observed) {
|
|
973
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
974
|
+
}
|
|
891
975
|
if (ret === AVERROR_EOF) {
|
|
892
976
|
// Stream finished - this is normal, just return
|
|
893
977
|
return;
|
|
@@ -897,6 +981,8 @@ export class Muxer {
|
|
|
897
981
|
}
|
|
898
982
|
return;
|
|
899
983
|
}
|
|
984
|
+
// The sync queue keeps each stream's packets in order and drops none.
|
|
985
|
+
streamInfo.sqArrivals.push(arrival);
|
|
900
986
|
// Receive synchronized packets from queue and write to muxer
|
|
901
987
|
while (!this.isClosed) {
|
|
902
988
|
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
@@ -917,14 +1003,14 @@ export class Muxer {
|
|
|
917
1003
|
}
|
|
918
1004
|
pkt.streamIndex = recvRet;
|
|
919
1005
|
// Write packet (muxer takes ownership)
|
|
920
|
-
await this.write(pkt, recvStreamInfo, recvRet);
|
|
1006
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
921
1007
|
}
|
|
922
1008
|
}
|
|
923
1009
|
}
|
|
924
1010
|
else {
|
|
925
1011
|
// No sync queue needed - write directly
|
|
926
1012
|
clonedPacket.streamIndex = streamIndex;
|
|
927
|
-
await this.write(clonedPacket, streamInfo, streamIndex);
|
|
1013
|
+
await this.write(clonedPacket, streamInfo, streamIndex, arrival);
|
|
928
1014
|
}
|
|
929
1015
|
}
|
|
930
1016
|
/**
|
|
@@ -957,6 +1043,8 @@ export class Muxer {
|
|
|
957
1043
|
*
|
|
958
1044
|
* @throws {Error} If stream invalid or encoder not initialized
|
|
959
1045
|
*
|
|
1046
|
+
* @throws {Error} On a timestamp discontinuity the output cannot take (see {@link MuxerOptions.dtsBackwardThreshold})
|
|
1047
|
+
*
|
|
960
1048
|
* @throws {FFmpegError} If write fails
|
|
961
1049
|
*
|
|
962
1050
|
* @example
|
|
@@ -1084,6 +1172,7 @@ export class Muxer {
|
|
|
1084
1172
|
if (uninitialized || this.headerWritePromise) {
|
|
1085
1173
|
// Buffer NULL as EOF marker (no size contribution)
|
|
1086
1174
|
streamInfo.preMuxQueue.push(null);
|
|
1175
|
+
streamInfo.preMuxArrivals.push(0);
|
|
1087
1176
|
return;
|
|
1088
1177
|
}
|
|
1089
1178
|
// PHASE 2: After muxer started - send EOF to SyncQueue and flush
|
|
@@ -1116,7 +1205,7 @@ export class Muxer {
|
|
|
1116
1205
|
throw new Error('Failed to clone packet from sync queue');
|
|
1117
1206
|
}
|
|
1118
1207
|
pkt.streamIndex = recvRet;
|
|
1119
|
-
this.writeSync(pkt, recvStreamInfo, recvRet);
|
|
1208
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1120
1209
|
}
|
|
1121
1210
|
}
|
|
1122
1211
|
}
|
|
@@ -1127,6 +1216,7 @@ export class Muxer {
|
|
|
1127
1216
|
if (!clonedPacket) {
|
|
1128
1217
|
throw new Error('Failed to clone packet for writing');
|
|
1129
1218
|
}
|
|
1219
|
+
const arrival = this.forwardThresholdMs > 0 ? this.clock() : 0;
|
|
1130
1220
|
// Apply streamcopy filtering BEFORE buffering
|
|
1131
1221
|
// This ensures rejected packets never enter the queue/buffer
|
|
1132
1222
|
if (streamInfo.isStreamCopy) {
|
|
@@ -1140,6 +1230,9 @@ export class Muxer {
|
|
|
1140
1230
|
// For encoded (non-streamcopy) streams, strip the device's startTime base.
|
|
1141
1231
|
this.applyStartTimeOffset(clonedPacket, streamInfo);
|
|
1142
1232
|
}
|
|
1233
|
+
// Announced on acceptance, so the observer also counts what waits in the
|
|
1234
|
+
// pre-mux queue for the header.
|
|
1235
|
+
const observed = this.announcePacket(clonedPacket, streamIndex);
|
|
1143
1236
|
// Check if any streams are still uninitialized
|
|
1144
1237
|
const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
|
|
1145
1238
|
// PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
|
|
@@ -1157,25 +1250,26 @@ export class Muxer {
|
|
|
1157
1250
|
// Check if we would exceed packet limit (only if threshold reached)
|
|
1158
1251
|
if (currentPackets >= effectiveMaxPackets) {
|
|
1159
1252
|
clonedPacket.free(); // Free the clone since we can't buffer it
|
|
1253
|
+
if (observed) {
|
|
1254
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
1255
|
+
}
|
|
1160
1256
|
throw new Error(
|
|
1161
1257
|
// eslint-disable-next-line @stylistic/max-len
|
|
1162
1258
|
`Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
|
|
1163
1259
|
}
|
|
1164
1260
|
// Buffer in PreMuxQueue (per-stream FIFO)
|
|
1165
1261
|
streamInfo.preMuxQueue.push(clonedPacket);
|
|
1262
|
+
streamInfo.preMuxArrivals.push(arrival);
|
|
1166
1263
|
streamInfo.preMuxQueueDataSize += packetSize;
|
|
1167
1264
|
return; // Don't proceed to header write yet
|
|
1168
1265
|
}
|
|
1169
1266
|
// Automatically write header if not written yet
|
|
1170
1267
|
if (!this.headerWritten) {
|
|
1171
|
-
this.
|
|
1172
|
-
this.updateDefaultDisposition();
|
|
1173
|
-
this.copyContainerMetadata();
|
|
1174
|
-
applyContextOptions(this.formatContext, this.options.context);
|
|
1175
|
-
this.options.configure?.(this.formatContext);
|
|
1268
|
+
this.prepareHeaderWrite();
|
|
1176
1269
|
const ret = this.formatContext.writeHeaderSync();
|
|
1177
1270
|
FFmpegError.throwIfError(ret, 'Failed to write header');
|
|
1178
1271
|
this.headerWritten = true;
|
|
1272
|
+
this.readHeaderSetup();
|
|
1179
1273
|
// PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
|
|
1180
1274
|
// Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
|
|
1181
1275
|
this.flushPreMuxQueuesSync();
|
|
@@ -1189,6 +1283,9 @@ export class Muxer {
|
|
|
1189
1283
|
const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
|
|
1190
1284
|
// Handle errors from sq_send
|
|
1191
1285
|
if (ret < 0) {
|
|
1286
|
+
if (observed) {
|
|
1287
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
1288
|
+
}
|
|
1192
1289
|
if (ret === AVERROR_EOF) {
|
|
1193
1290
|
// Stream finished - this is normal, just return
|
|
1194
1291
|
return;
|
|
@@ -1198,6 +1295,7 @@ export class Muxer {
|
|
|
1198
1295
|
}
|
|
1199
1296
|
return;
|
|
1200
1297
|
}
|
|
1298
|
+
streamInfo.sqArrivals.push(arrival);
|
|
1201
1299
|
// Receive synchronized packets from queue and write to muxer
|
|
1202
1300
|
while (!this.isClosed) {
|
|
1203
1301
|
const recvRet = this.syncQueue.receive(-1, this.sqPacket);
|
|
@@ -1218,14 +1316,14 @@ export class Muxer {
|
|
|
1218
1316
|
}
|
|
1219
1317
|
pkt.streamIndex = recvRet;
|
|
1220
1318
|
// Write packet (muxer takes ownership)
|
|
1221
|
-
this.writeSync(pkt, recvStreamInfo, recvRet);
|
|
1319
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1222
1320
|
}
|
|
1223
1321
|
}
|
|
1224
1322
|
}
|
|
1225
1323
|
else {
|
|
1226
1324
|
// No sync queue needed - write directly
|
|
1227
1325
|
clonedPacket.streamIndex = streamIndex;
|
|
1228
|
-
this.writeSync(clonedPacket, streamInfo, streamIndex);
|
|
1326
|
+
this.writeSync(clonedPacket, streamInfo, streamIndex, arrival);
|
|
1229
1327
|
}
|
|
1230
1328
|
}
|
|
1231
1329
|
/**
|
|
@@ -1246,6 +1344,8 @@ export class Muxer {
|
|
|
1246
1344
|
* }
|
|
1247
1345
|
* ```
|
|
1248
1346
|
*
|
|
1347
|
+
* @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)
|
|
1348
|
+
*
|
|
1249
1349
|
* @see {@link Symbol.asyncDispose} For automatic cleanup
|
|
1250
1350
|
*/
|
|
1251
1351
|
async close() {
|
|
@@ -1253,10 +1353,34 @@ export class Muxer {
|
|
|
1253
1353
|
return;
|
|
1254
1354
|
}
|
|
1255
1355
|
this.isClosed = true;
|
|
1256
|
-
//
|
|
1356
|
+
// Detached first, so the final flush can never fail on the observer.
|
|
1357
|
+
this.packetObserver = undefined;
|
|
1358
|
+
// A header write launched by a concurrent writePacket() must settle before
|
|
1359
|
+
// teardown: nulling pb / freeing the format context under a running
|
|
1360
|
+
// avformat_write_header() crashes on the worker thread. This happens when a
|
|
1361
|
+
// pipeline is stopped mid-startup - the reader task rejects the shared
|
|
1362
|
+
// completion while the writer task is still inside its first writePacket().
|
|
1363
|
+
if (this.headerWritePromise) {
|
|
1364
|
+
try {
|
|
1365
|
+
await this.headerWritePromise;
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
// Ignore errors - teardown proceeds either way
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Close write queue and wait for worker to finish.
|
|
1372
|
+
// The worker catches its own errors, but guard the await anyway so a
|
|
1373
|
+
// failed worker can never abort cleanup - resources below must always
|
|
1374
|
+
// be freed even after a write failure.
|
|
1257
1375
|
if (this.writeQueue) {
|
|
1258
1376
|
this.writeQueue.close();
|
|
1259
|
-
|
|
1377
|
+
try {
|
|
1378
|
+
await this.writeWorkerPromise;
|
|
1379
|
+
}
|
|
1380
|
+
catch (error) {
|
|
1381
|
+
this.writeWorkerError ??= error instanceof Error ? error : new Error(String(error));
|
|
1382
|
+
}
|
|
1383
|
+
this.writeQueue.clear(); // Free any job packet the worker never consumed
|
|
1260
1384
|
}
|
|
1261
1385
|
// Free PreMuxQueue packets
|
|
1262
1386
|
for (const streamInfo of this._streams.values()) {
|
|
@@ -1265,6 +1389,7 @@ export class Muxer {
|
|
|
1265
1389
|
pkt?.free();
|
|
1266
1390
|
}
|
|
1267
1391
|
streamInfo.preMuxQueue = [];
|
|
1392
|
+
streamInfo.preMuxArrivals = [];
|
|
1268
1393
|
}
|
|
1269
1394
|
// Free sync queue resources
|
|
1270
1395
|
if (this.sqPacket) {
|
|
@@ -1277,7 +1402,7 @@ export class Muxer {
|
|
|
1277
1402
|
}
|
|
1278
1403
|
// Try to write trailer if header was written but trailer wasn't
|
|
1279
1404
|
try {
|
|
1280
|
-
if (this.headerWritten && !this.trailerWritten) {
|
|
1405
|
+
if (this.headerWritten && !this.trailerWritten && !this.trailerDiscarded) {
|
|
1281
1406
|
await this.formatContext.writeTrailer();
|
|
1282
1407
|
this.trailerWritten = true;
|
|
1283
1408
|
}
|
|
@@ -1289,11 +1414,11 @@ export class Muxer {
|
|
|
1289
1414
|
if (this.ioContext) {
|
|
1290
1415
|
this.formatContext.pb = null;
|
|
1291
1416
|
}
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1294
|
-
//
|
|
1417
|
+
// For file-based IO, close the file handle via closep - freeContext() alone
|
|
1418
|
+
// would release the AVIOContext but leak the underlying protocol handle,
|
|
1419
|
+
// leaving the file locked (EBUSY on Windows) until the process exits.
|
|
1295
1420
|
// For custom IO, the context will be freed below
|
|
1296
|
-
if (this.ioContext && !
|
|
1421
|
+
if (this.ioContext && !this.customIO) {
|
|
1297
1422
|
try {
|
|
1298
1423
|
await this.ioContext.closep();
|
|
1299
1424
|
}
|
|
@@ -1311,7 +1436,7 @@ export class Muxer {
|
|
|
1311
1436
|
}
|
|
1312
1437
|
}
|
|
1313
1438
|
// Now free custom IO context if present
|
|
1314
|
-
if (this.ioContext &&
|
|
1439
|
+
if (this.ioContext && this.customIO) {
|
|
1315
1440
|
try {
|
|
1316
1441
|
this.ioContext.freeContext();
|
|
1317
1442
|
}
|
|
@@ -1319,6 +1444,11 @@ export class Muxer {
|
|
|
1319
1444
|
// Ignore errors
|
|
1320
1445
|
}
|
|
1321
1446
|
}
|
|
1447
|
+
// Surface a background write failure the caller may not have observed yet.
|
|
1448
|
+
// Thrown only after all resources are freed - a second close() is a no-op.
|
|
1449
|
+
if (this.writeWorkerError) {
|
|
1450
|
+
throw this.writeWorkerError;
|
|
1451
|
+
}
|
|
1322
1452
|
}
|
|
1323
1453
|
/**
|
|
1324
1454
|
* Close muxer and free resources synchronously.
|
|
@@ -1346,6 +1476,7 @@ export class Muxer {
|
|
|
1346
1476
|
return;
|
|
1347
1477
|
}
|
|
1348
1478
|
this.isClosed = true;
|
|
1479
|
+
this.packetObserver = undefined;
|
|
1349
1480
|
// Free PreMuxQueue packets
|
|
1350
1481
|
for (const streamInfo of this._streams.values()) {
|
|
1351
1482
|
// Free any packets in PreMuxQueue
|
|
@@ -1353,6 +1484,7 @@ export class Muxer {
|
|
|
1353
1484
|
pkt?.free();
|
|
1354
1485
|
}
|
|
1355
1486
|
streamInfo.preMuxQueue = [];
|
|
1487
|
+
streamInfo.preMuxArrivals = [];
|
|
1356
1488
|
}
|
|
1357
1489
|
// Free sync queue resources
|
|
1358
1490
|
if (this.sqPacket) {
|
|
@@ -1365,7 +1497,7 @@ export class Muxer {
|
|
|
1365
1497
|
}
|
|
1366
1498
|
// Try to write trailer if header was written but trailer wasn't
|
|
1367
1499
|
try {
|
|
1368
|
-
if (this.headerWritten && !this.trailerWritten) {
|
|
1500
|
+
if (this.headerWritten && !this.trailerWritten && !this.trailerDiscarded) {
|
|
1369
1501
|
this.formatContext.writeTrailerSync();
|
|
1370
1502
|
this.trailerWritten = true;
|
|
1371
1503
|
}
|
|
@@ -1377,11 +1509,9 @@ export class Muxer {
|
|
|
1377
1509
|
if (this.ioContext) {
|
|
1378
1510
|
this.formatContext.pb = null;
|
|
1379
1511
|
}
|
|
1380
|
-
// Determine if this is custom IO before freeing format context
|
|
1381
|
-
const isCustomIO = this.formatContext.hasFlags(AVFMT_FLAG_CUSTOM_IO);
|
|
1382
1512
|
// For file-based IO, close the file handle via closep
|
|
1383
1513
|
// For custom IO, the context will be freed below
|
|
1384
|
-
if (this.ioContext && !
|
|
1514
|
+
if (this.ioContext && !this.customIO) {
|
|
1385
1515
|
try {
|
|
1386
1516
|
this.ioContext.closepSync();
|
|
1387
1517
|
}
|
|
@@ -1399,7 +1529,7 @@ export class Muxer {
|
|
|
1399
1529
|
}
|
|
1400
1530
|
}
|
|
1401
1531
|
// Now free custom IO context if present
|
|
1402
|
-
if (this.ioContext &&
|
|
1532
|
+
if (this.ioContext && this.customIO) {
|
|
1403
1533
|
try {
|
|
1404
1534
|
this.ioContext.freeContext();
|
|
1405
1535
|
}
|
|
@@ -1420,6 +1550,109 @@ export class Muxer {
|
|
|
1420
1550
|
getFormatContext() {
|
|
1421
1551
|
return this.formatContext;
|
|
1422
1552
|
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Follow the packets this muxer accepts for writing.
|
|
1555
|
+
*
|
|
1556
|
+
* The observer hears about every packet with payload once it is accepted
|
|
1557
|
+
* (before it waits in the pre-mux queue or reaches libavformat) and about
|
|
1558
|
+
* every such packet that is dropped later, so together with the output it
|
|
1559
|
+
* knows what the muxer holds. close() detaches it.
|
|
1560
|
+
*
|
|
1561
|
+
* @param observer - Observer, or undefined to detach
|
|
1562
|
+
*
|
|
1563
|
+
* @internal
|
|
1564
|
+
*/
|
|
1565
|
+
setPacketObserver(observer) {
|
|
1566
|
+
this.packetObserver = observer;
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Replace the clock that stamps packet arrival times for the dtsForwardThreshold check.
|
|
1570
|
+
*
|
|
1571
|
+
* Lets tests drive real time deterministically. Set it before the first packet.
|
|
1572
|
+
*
|
|
1573
|
+
* @param clock - Monotonic time in ms
|
|
1574
|
+
*
|
|
1575
|
+
* @internal
|
|
1576
|
+
*/
|
|
1577
|
+
setClock(clock) {
|
|
1578
|
+
this.clock = clock;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Make close() skip the trailer.
|
|
1582
|
+
*
|
|
1583
|
+
* For owners that drop whatever the muxer still emits, such as FMP4Stream
|
|
1584
|
+
* after a failed session: the trailer would only push libavformat's backlog
|
|
1585
|
+
* into the void, and movenc asserts on some track states a failed session
|
|
1586
|
+
* can leave behind. The backlog is freed with the format context instead.
|
|
1587
|
+
*
|
|
1588
|
+
* @internal
|
|
1589
|
+
*/
|
|
1590
|
+
discardOnClose() {
|
|
1591
|
+
this.trailerDiscarded = true;
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Announce a packet the muxer has accepted to the packet observer.
|
|
1595
|
+
*
|
|
1596
|
+
* @param pkt - The muxer's clone of the packet
|
|
1597
|
+
*
|
|
1598
|
+
* @param streamIndex - Output stream index
|
|
1599
|
+
*
|
|
1600
|
+
* @returns True when the observer now tracks the packet
|
|
1601
|
+
*
|
|
1602
|
+
* @throws {Error} If the observer fails the write (the clone is freed)
|
|
1603
|
+
*
|
|
1604
|
+
* @internal
|
|
1605
|
+
*/
|
|
1606
|
+
announcePacket(pkt, streamIndex) {
|
|
1607
|
+
// movenc drops packets without payload instead of storing a sample.
|
|
1608
|
+
if (this.packetObserver === undefined || pkt.size <= 0) {
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
try {
|
|
1612
|
+
this.packetObserver.onPacket(streamIndex);
|
|
1613
|
+
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
pkt.free();
|
|
1616
|
+
throw error;
|
|
1617
|
+
}
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Apply shared pre-header setup for writePacket and writePacketSync.
|
|
1622
|
+
*
|
|
1623
|
+
* Configures sync queues, dispositions and container metadata, then applies
|
|
1624
|
+
* user-provided context options and the configure callback. Must run
|
|
1625
|
+
* immediately before writing the header so both variants stay in sync.
|
|
1626
|
+
*
|
|
1627
|
+
* @internal
|
|
1628
|
+
*/
|
|
1629
|
+
prepareHeaderWrite() {
|
|
1630
|
+
this.setupSyncQueues();
|
|
1631
|
+
this.updateDefaultDisposition();
|
|
1632
|
+
this.copyContainerMetadata();
|
|
1633
|
+
applyContextOptions(this.formatContext, this.options.context);
|
|
1634
|
+
this.options.configure?.(this.formatContext);
|
|
1635
|
+
const oformat = this.formatContext.oformat;
|
|
1636
|
+
this.movSampleLimit = MOV_FAMILY_FORMATS.has(oformat?.name ?? '');
|
|
1637
|
+
this.tsNonStrict = oformat?.hasFlags(AVFMT_TS_NONSTRICT) ?? false;
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Read the muxing setup libavformat settles while writing the header.
|
|
1641
|
+
*
|
|
1642
|
+
* movenc decides on edit lists only then: without one (fragmented output
|
|
1643
|
+
* without delay_moov) it shifts the output to start at 0 and puts each
|
|
1644
|
+
* track's first sample there, which {@link checkMovLateStart} guards.
|
|
1645
|
+
*
|
|
1646
|
+
* @internal
|
|
1647
|
+
*/
|
|
1648
|
+
readHeaderSetup() {
|
|
1649
|
+
if (!this.movSampleLimit) {
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const ctx = this.formatContext;
|
|
1653
|
+
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;
|
|
1654
|
+
this.interleaveDeltaUs = ctx.maxInterleaveDelta;
|
|
1655
|
+
}
|
|
1423
1656
|
/**
|
|
1424
1657
|
* Setup sync queues based on stream configuration.
|
|
1425
1658
|
*
|
|
@@ -1464,6 +1697,21 @@ export class Muxer {
|
|
|
1464
1697
|
}
|
|
1465
1698
|
}
|
|
1466
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Whether any stream still holds packets (or an EOF marker) in its pre-mux queue.
|
|
1702
|
+
*
|
|
1703
|
+
* @returns True if a pre-mux queue is not empty
|
|
1704
|
+
*
|
|
1705
|
+
* @internal
|
|
1706
|
+
*/
|
|
1707
|
+
hasPreMuxPackets() {
|
|
1708
|
+
for (const streamInfo of this._streams.values()) {
|
|
1709
|
+
if (streamInfo.preMuxQueue.length > 0) {
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1467
1715
|
/**
|
|
1468
1716
|
* Flush all PreMuxQueues in DTS-sorted order.
|
|
1469
1717
|
*
|
|
@@ -1509,6 +1757,7 @@ export class Muxer {
|
|
|
1509
1757
|
}
|
|
1510
1758
|
// 3. Take packet from stream with earliest DTS (or NULL for EOF)
|
|
1511
1759
|
const pkt = minStreamInfo.preMuxQueue.shift();
|
|
1760
|
+
const arrival = minStreamInfo.preMuxArrivals.shift() ?? 0;
|
|
1512
1761
|
// 4. Handle NULL packet (EOF marker)
|
|
1513
1762
|
// FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
|
|
1514
1763
|
if (!pkt) {
|
|
@@ -1533,8 +1782,15 @@ export class Muxer {
|
|
|
1533
1782
|
// NOTE: Do NOT set pkt.timeBase here!
|
|
1534
1783
|
// Packet must keep its source timebase so muxFixupTs can rescale correctly
|
|
1535
1784
|
// pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
|
|
1785
|
+
const size = pkt.size;
|
|
1536
1786
|
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
|
|
1537
|
-
if (ret < 0 &&
|
|
1787
|
+
if (ret < 0 && size > 0) {
|
|
1788
|
+
this.packetObserver?.onPacketRejected(minStreamIndex);
|
|
1789
|
+
}
|
|
1790
|
+
if (ret >= 0) {
|
|
1791
|
+
minStreamInfo.sqArrivals.push(arrival);
|
|
1792
|
+
}
|
|
1793
|
+
else if (ret !== AVERROR_EOF) {
|
|
1538
1794
|
if (this.options.exitOnError) {
|
|
1539
1795
|
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
|
|
1540
1796
|
}
|
|
@@ -1542,7 +1798,7 @@ export class Muxer {
|
|
|
1542
1798
|
}
|
|
1543
1799
|
else {
|
|
1544
1800
|
// Write directly to muxer
|
|
1545
|
-
await this.write(pkt, minStreamInfo, minStreamIndex);
|
|
1801
|
+
await this.write(pkt, minStreamInfo, minStreamIndex, arrival);
|
|
1546
1802
|
}
|
|
1547
1803
|
}
|
|
1548
1804
|
// If using SyncQueue, receive and write all interleaved packets
|
|
@@ -1565,7 +1821,7 @@ export class Muxer {
|
|
|
1565
1821
|
}
|
|
1566
1822
|
pkt.streamIndex = recvRet;
|
|
1567
1823
|
// Write packet (muxer takes ownership)
|
|
1568
|
-
await this.write(pkt, recvStreamInfo, recvRet);
|
|
1824
|
+
await this.write(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1569
1825
|
}
|
|
1570
1826
|
}
|
|
1571
1827
|
}
|
|
@@ -1613,6 +1869,7 @@ export class Muxer {
|
|
|
1613
1869
|
break;
|
|
1614
1870
|
// 3. Take packet from stream with earliest DTS (or NULL for EOF)
|
|
1615
1871
|
const pkt = minStreamInfo.preMuxQueue.shift();
|
|
1872
|
+
const arrival = minStreamInfo.preMuxArrivals.shift() ?? 0;
|
|
1616
1873
|
// 4. Handle NULL packet (EOF marker)
|
|
1617
1874
|
// FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
|
|
1618
1875
|
if (!pkt) {
|
|
@@ -1637,8 +1894,15 @@ export class Muxer {
|
|
|
1637
1894
|
// NOTE: Do NOT set pkt.timeBase here!
|
|
1638
1895
|
// Packet must keep its source timebase so muxFixupTs can rescale correctly
|
|
1639
1896
|
// pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
|
|
1897
|
+
const size = pkt.size;
|
|
1640
1898
|
const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
|
|
1641
|
-
if (ret < 0 &&
|
|
1899
|
+
if (ret < 0 && size > 0) {
|
|
1900
|
+
this.packetObserver?.onPacketRejected(minStreamIndex);
|
|
1901
|
+
}
|
|
1902
|
+
if (ret >= 0) {
|
|
1903
|
+
minStreamInfo.sqArrivals.push(arrival);
|
|
1904
|
+
}
|
|
1905
|
+
else if (ret !== AVERROR_EOF) {
|
|
1642
1906
|
if (this.options.exitOnError) {
|
|
1643
1907
|
FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
|
|
1644
1908
|
}
|
|
@@ -1646,7 +1910,7 @@ export class Muxer {
|
|
|
1646
1910
|
}
|
|
1647
1911
|
else {
|
|
1648
1912
|
// Write directly to muxer
|
|
1649
|
-
this.writeSync(pkt, minStreamInfo, minStreamIndex);
|
|
1913
|
+
this.writeSync(pkt, minStreamInfo, minStreamIndex, arrival);
|
|
1650
1914
|
}
|
|
1651
1915
|
}
|
|
1652
1916
|
// If using SyncQueue, receive and write all interleaved packets
|
|
@@ -1669,7 +1933,7 @@ export class Muxer {
|
|
|
1669
1933
|
}
|
|
1670
1934
|
pkt.streamIndex = recvRet;
|
|
1671
1935
|
// Write packet (muxer takes ownership)
|
|
1672
|
-
this.writeSync(pkt, recvStreamInfo, recvRet);
|
|
1936
|
+
this.writeSync(pkt, recvStreamInfo, recvRet, recvStreamInfo.sqArrivals.shift() ?? 0);
|
|
1673
1937
|
}
|
|
1674
1938
|
}
|
|
1675
1939
|
}
|
|
@@ -1683,16 +1947,26 @@ export class Muxer {
|
|
|
1683
1947
|
*
|
|
1684
1948
|
* @param streamIndex - Stream index
|
|
1685
1949
|
*
|
|
1950
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
1951
|
+
*
|
|
1686
1952
|
* @internal
|
|
1687
1953
|
*/
|
|
1688
|
-
async write(pkt, streamInfo, streamIndex) {
|
|
1954
|
+
async write(pkt, streamInfo, streamIndex, arrival) {
|
|
1689
1955
|
if (this.writeQueue) {
|
|
1690
|
-
// Use async queue for serialized writes
|
|
1691
|
-
|
|
1956
|
+
// Use async queue for serialized writes.
|
|
1957
|
+
// If the worker died with a write error, the queue is poisoned and send()
|
|
1958
|
+
// rethrows that error - free the clone since it never reaches the worker.
|
|
1959
|
+
try {
|
|
1960
|
+
await this.writeQueue.send({ pkt, streamInfo, streamIndex, arrival });
|
|
1961
|
+
}
|
|
1962
|
+
catch (error) {
|
|
1963
|
+
pkt.free();
|
|
1964
|
+
throw error;
|
|
1965
|
+
}
|
|
1692
1966
|
}
|
|
1693
1967
|
else {
|
|
1694
1968
|
// Direct write without serialization
|
|
1695
|
-
await this.writeInternal(pkt, streamInfo, streamIndex);
|
|
1969
|
+
await this.writeInternal(pkt, streamInfo, streamIndex, arrival);
|
|
1696
1970
|
}
|
|
1697
1971
|
}
|
|
1698
1972
|
/**
|
|
@@ -1705,20 +1979,37 @@ export class Muxer {
|
|
|
1705
1979
|
*
|
|
1706
1980
|
* @param streamIndex - Stream index
|
|
1707
1981
|
*
|
|
1982
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
1983
|
+
*
|
|
1708
1984
|
* @internal
|
|
1709
1985
|
*/
|
|
1710
|
-
async writeInternal(pkt, streamInfo, streamIndex) {
|
|
1711
|
-
//
|
|
1712
|
-
this.
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
if (
|
|
1719
|
-
|
|
1986
|
+
async writeInternal(pkt, streamInfo, streamIndex, arrival) {
|
|
1987
|
+
// Read before the write moves the payload out (see announcePacket()).
|
|
1988
|
+
const observed = this.packetObserver !== undefined && pkt.size > 0;
|
|
1989
|
+
let accepted = false;
|
|
1990
|
+
try {
|
|
1991
|
+
// Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
|
|
1992
|
+
this.muxFixupTs(pkt, streamInfo, streamIndex, arrival);
|
|
1993
|
+
// Write the packet (muxer takes ownership and will unref it)
|
|
1994
|
+
// NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
|
|
1995
|
+
const ret = await this.formatContext.interleavedWriteFrame(pkt);
|
|
1996
|
+
accepted = ret >= 0;
|
|
1997
|
+
// Handle write errors
|
|
1998
|
+
if (ret < 0 && ret !== AVERROR_EOF) {
|
|
1999
|
+
if (this.options.exitOnError) {
|
|
2000
|
+
FFmpegError.throwIfError(ret, 'Failed to write packet');
|
|
2001
|
+
}
|
|
1720
2002
|
}
|
|
1721
2003
|
}
|
|
2004
|
+
finally {
|
|
2005
|
+
if (observed && !accepted) {
|
|
2006
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
2007
|
+
}
|
|
2008
|
+
// Every packet reaching here is a clone this class made, and the write
|
|
2009
|
+
// has consumed its contents. Release the struct now instead of leaving a
|
|
2010
|
+
// GC-sized backlog of them behind - this is the last owner.
|
|
2011
|
+
pkt.free();
|
|
2012
|
+
}
|
|
1722
2013
|
}
|
|
1723
2014
|
/**
|
|
1724
2015
|
* Start background worker for async write queue.
|
|
@@ -1730,13 +2021,25 @@ export class Muxer {
|
|
|
1730
2021
|
if (!this.options.useAsyncWrite || this._streams.size <= 1) {
|
|
1731
2022
|
return;
|
|
1732
2023
|
}
|
|
1733
|
-
this.writeQueue ??= new AsyncQueue(1); // size=1 for strict serialization
|
|
2024
|
+
this.writeQueue ??= new AsyncQueue(1, (job) => job.pkt.free()); // size=1 for strict serialization
|
|
1734
2025
|
this.writeWorkerPromise ??= (async () => {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
2026
|
+
try {
|
|
2027
|
+
while (true) {
|
|
2028
|
+
const job = await this.writeQueue.receive();
|
|
2029
|
+
if (!job)
|
|
2030
|
+
break; // Queue closed
|
|
2031
|
+
await this.writeInternal(job.pkt, job.streamInfo, job.streamIndex, job.arrival);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
catch (error) {
|
|
2035
|
+
// A write failure kills the worker. Without propagation the next send()
|
|
2036
|
+
// would block forever on a queue nobody drains (pipeline deadlock), so
|
|
2037
|
+
// store the error and poison the queue: pending and future send() calls
|
|
2038
|
+
// throw it instead of hanging. close() surfaces it after cleanup.
|
|
2039
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2040
|
+
this.writeWorkerError = err;
|
|
2041
|
+
this.writeQueue?.closeWithError(err);
|
|
2042
|
+
this.writeQueue?.clear(); // Free any job packet still buffered
|
|
1740
2043
|
}
|
|
1741
2044
|
})();
|
|
1742
2045
|
}
|
|
@@ -1750,15 +2053,29 @@ export class Muxer {
|
|
|
1750
2053
|
*
|
|
1751
2054
|
* @param streamIndex - Stream index
|
|
1752
2055
|
*
|
|
2056
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2057
|
+
*
|
|
1753
2058
|
* @internal
|
|
1754
2059
|
*/
|
|
1755
|
-
writeSync(pkt, streamInfo, streamIndex) {
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
2060
|
+
writeSync(pkt, streamInfo, streamIndex, arrival) {
|
|
2061
|
+
const observed = this.packetObserver !== undefined && pkt.size > 0;
|
|
2062
|
+
let accepted = false;
|
|
2063
|
+
try {
|
|
2064
|
+
// Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
|
|
2065
|
+
this.muxFixupTs(pkt, streamInfo, streamIndex, arrival);
|
|
2066
|
+
// Write the packet (muxer takes ownership and will unref it)
|
|
2067
|
+
// NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
|
|
2068
|
+
const ret = this.formatContext.interleavedWriteFrameSync(pkt);
|
|
2069
|
+
accepted = ret >= 0;
|
|
2070
|
+
FFmpegError.throwIfError(ret, 'Failed to write packet');
|
|
2071
|
+
}
|
|
2072
|
+
finally {
|
|
2073
|
+
if (observed && !accepted) {
|
|
2074
|
+
this.packetObserver?.onPacketRejected(streamIndex);
|
|
2075
|
+
}
|
|
2076
|
+
// See writeInternal(): this is the last owner of the clone.
|
|
2077
|
+
pkt.free();
|
|
2078
|
+
}
|
|
1762
2079
|
}
|
|
1763
2080
|
/**
|
|
1764
2081
|
* Streamcopy packet filtering and timestamp offset.
|
|
@@ -1786,11 +2103,12 @@ export class Muxer {
|
|
|
1786
2103
|
* Apply the configured `startTime` offset to an encoded packet, per stream.
|
|
1787
2104
|
*
|
|
1788
2105
|
* `startTime` exists to strip a device's boot-relative timestamp base (e.g. the
|
|
1789
|
-
* mach uptime avfoundation reports). But
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
* players (QuickTime) reject. The
|
|
1793
|
-
*
|
|
2106
|
+
* mach uptime avfoundation reports). But not every stream of an output carries
|
|
2107
|
+
* that base (e.g. zero-based audio next to device video), so subtracting a large
|
|
2108
|
+
* device startTime from such a stream would push its timestamps hugely negative and
|
|
2109
|
+
* produce a broken/overflowed edit list that strict players (QuickTime) reject. The
|
|
2110
|
+
* effective offset is therefore decided once on the stream's first packet and
|
|
2111
|
+
* clamped to what the stream actually carries —
|
|
1794
2112
|
* `min(startTime, max(0, firstPts))`: boot-relative streams normalize to zero,
|
|
1795
2113
|
* already-zero-based streams are left untouched.
|
|
1796
2114
|
*
|
|
@@ -1884,8 +2202,14 @@ export class Muxer {
|
|
|
1884
2202
|
* Performs timestamp corrections:
|
|
1885
2203
|
* 1. Rescales timestamps to output timebase (av_rescale_delta for audio streamcopy)
|
|
1886
2204
|
* 2. Sets pkt.timeBase to output stream timebase
|
|
1887
|
-
* 3.
|
|
1888
|
-
* 4.
|
|
2205
|
+
* 3. Rejects backward jumps beyond dtsBackwardThreshold
|
|
2206
|
+
* 4. Fixes invalid DTS > PTS relationships
|
|
2207
|
+
* 5. Enforces monotonic DTS (never decreasing), shrinking a clamped packet's duration to the clamped step
|
|
2208
|
+
* 6. Rejects forward jumps that run ahead of real time beyond dtsForwardThreshold
|
|
2209
|
+
* 7. Rejects DTS steps/durations a movenc output cannot store, and a late stream's first packet before its start
|
|
2210
|
+
*
|
|
2211
|
+
* The stream's last muxed DTS only advances when the packet is accepted and
|
|
2212
|
+
* carries a DTS.
|
|
1889
2213
|
*
|
|
1890
2214
|
* @param pkt - Packet to fix
|
|
1891
2215
|
*
|
|
@@ -1893,69 +2217,332 @@ export class Muxer {
|
|
|
1893
2217
|
*
|
|
1894
2218
|
* @param streamIndex - Stream index
|
|
1895
2219
|
*
|
|
2220
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2221
|
+
*
|
|
2222
|
+
* @throws {Error} On a rejected timestamp discontinuity
|
|
2223
|
+
*
|
|
1896
2224
|
* @internal
|
|
1897
2225
|
*/
|
|
1898
|
-
muxFixupTs(pkt, streamInfo, streamIndex) {
|
|
1899
|
-
const outputStream =
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
const
|
|
1903
|
-
const
|
|
1904
|
-
|
|
2226
|
+
muxFixupTs(pkt, streamInfo, streamIndex, arrival) {
|
|
2227
|
+
const outputStream = streamInfo.outputStream;
|
|
2228
|
+
// Both are final once the header is written, which happens before the
|
|
2229
|
+
// first packet gets here.
|
|
2230
|
+
const dstTb = (streamInfo.muxTimeBase ??= outputStream.timeBase);
|
|
2231
|
+
const codecType = (streamInfo.muxCodecType ??= outputStream.codecpar.codecType);
|
|
2232
|
+
const lastMuxDts = streamInfo.lastMuxDts;
|
|
2233
|
+
// Every timestamp getter is a native call returning a new BigInt, so each
|
|
2234
|
+
// value is read once, corrected in locals, and only changes are written back.
|
|
2235
|
+
let dts = pkt.dts;
|
|
2236
|
+
let pts = pkt.pts;
|
|
1905
2237
|
// Check if timestamps are valid before rescaling
|
|
1906
2238
|
// FFmpeg's av_rescale_q/av_rescale_delta don't accept AV_NOPTS_VALUE
|
|
1907
|
-
if (
|
|
2239
|
+
if (dts === AV_NOPTS_VALUE && pts === AV_NOPTS_VALUE) {
|
|
1908
2240
|
// Set packet timebase anyway for muxer
|
|
1909
2241
|
pkt.timeBase = dstTb;
|
|
1910
2242
|
return;
|
|
1911
2243
|
}
|
|
1912
2244
|
// 1. Rescale timestamps to the stream timebase
|
|
2245
|
+
const srcTb = streamInfo.sourceTimeBase;
|
|
1913
2246
|
if (codecType === AVMEDIA_TYPE_AUDIO && streamInfo.isStreamCopy) {
|
|
1914
|
-
|
|
2247
|
+
const codecpar = outputStream.codecpar;
|
|
2248
|
+
let duration = avGetAudioFrameDuration2(codecpar, pkt.size);
|
|
1915
2249
|
if (!duration) {
|
|
1916
|
-
duration =
|
|
2250
|
+
duration = codecpar.frameSize;
|
|
1917
2251
|
}
|
|
1918
|
-
const
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
pkt.dts =
|
|
1922
|
-
pkt.pts =
|
|
2252
|
+
const fsTb = { num: 1, den: codecpar.sampleRate };
|
|
2253
|
+
dts = avRescaleDelta(srcTb, dts, fsTb, duration, streamInfo.tsRescaleDeltaLast, dstTb);
|
|
2254
|
+
pts = dts;
|
|
2255
|
+
pkt.dts = dts;
|
|
2256
|
+
pkt.pts = pts;
|
|
1923
2257
|
pkt.duration = avRescaleQ(pkt.duration, srcTb, dstTb);
|
|
1924
2258
|
}
|
|
1925
2259
|
else {
|
|
1926
2260
|
// For video or encoded audio, use regular rescaling
|
|
1927
|
-
const srcTb = streamInfo.sourceTimeBase;
|
|
1928
2261
|
pkt.rescaleTs(srcTb, dstTb);
|
|
2262
|
+
dts = pkt.dts;
|
|
2263
|
+
pts = pkt.pts;
|
|
1929
2264
|
}
|
|
1930
2265
|
// 2. Set packet timeBase
|
|
1931
2266
|
// av_interleaved_write_frame uses this for sorting!
|
|
1932
2267
|
pkt.timeBase = dstTb;
|
|
1933
|
-
|
|
2268
|
+
const isAudioVideo = codecType === AVMEDIA_TYPE_AUDIO || codecType === AVMEDIA_TYPE_VIDEO;
|
|
2269
|
+
// 3. Reject a large backward jump while it is still visible - the corrections
|
|
2270
|
+
// below would turn it into a run of one-tick steps
|
|
2271
|
+
if (isAudioVideo) {
|
|
2272
|
+
this.checkBackwardDts(dts, lastMuxDts, dstTb, streamIndex);
|
|
2273
|
+
}
|
|
2274
|
+
// 4. Fix DTS > PTS (invalid relationship)
|
|
1934
2275
|
// FFmpeg formula: median of (pts, dts, last_mux_dts+1)
|
|
1935
|
-
if (
|
|
1936
|
-
const last =
|
|
1937
|
-
const min =
|
|
1938
|
-
const max =
|
|
1939
|
-
const median =
|
|
2276
|
+
if (dts !== AV_NOPTS_VALUE && pts !== AV_NOPTS_VALUE && dts > pts) {
|
|
2277
|
+
const last = lastMuxDts !== AV_NOPTS_VALUE ? lastMuxDts + 1n : 0n;
|
|
2278
|
+
const min = pts < dts ? (pts < last ? pts : last) : dts < last ? dts : last;
|
|
2279
|
+
const max = pts > dts ? (pts > last ? pts : last) : dts > last ? dts : last;
|
|
2280
|
+
const median = pts + dts + last - min - max;
|
|
2281
|
+
pts = median;
|
|
2282
|
+
dts = median;
|
|
1940
2283
|
pkt.pts = median;
|
|
1941
2284
|
pkt.dts = median;
|
|
1942
2285
|
}
|
|
1943
|
-
//
|
|
1944
|
-
|
|
2286
|
+
// 5. Enforce monotonic DTS
|
|
2287
|
+
let clampedDuration;
|
|
2288
|
+
if (isAudioVideo && dts !== AV_NOPTS_VALUE && lastMuxDts !== AV_NOPTS_VALUE) {
|
|
1945
2289
|
// FFmpeg: max = last_mux_dts + !(oformat->flags & AVFMT_TS_NONSTRICT)
|
|
1946
2290
|
// AVFMT_TS_NONSTRICT allows non-strict monotonic timestamps (equal DTS is OK)
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
if (pkt.dts < max) {
|
|
2291
|
+
const max = lastMuxDts + (this.tsNonStrict ? 0n : 1n);
|
|
2292
|
+
if (dts < max) {
|
|
1950
2293
|
// Adjust PTS if it would create invalid relationship
|
|
1951
|
-
if (
|
|
1952
|
-
|
|
2294
|
+
if (pts !== AV_NOPTS_VALUE && pts >= dts && pts < max) {
|
|
2295
|
+
pts = max;
|
|
2296
|
+
pkt.pts = max;
|
|
1953
2297
|
}
|
|
2298
|
+
dts = max;
|
|
1954
2299
|
pkt.dts = max;
|
|
2300
|
+
// The clamped packet must not keep its source duration: after a fragment
|
|
2301
|
+
// flush movenc continues the track at the previous DTS plus its duration,
|
|
2302
|
+
// which then lies ahead of the clamped timeline. movenc corrects that by
|
|
2303
|
+
// moving later DTS itself and can end with a negative sample duration
|
|
2304
|
+
// (av_assert0 in get_cluster_duration). A duration of 0 does not help -
|
|
2305
|
+
// mux.c guesses a new one - so use the clamped step.
|
|
2306
|
+
if (max > lastMuxDts) {
|
|
2307
|
+
clampedDuration = max - lastMuxDts;
|
|
2308
|
+
pkt.duration = clampedDuration;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
// mux.c derives a missing DTS from the PTS and never above it (its PTS-0
|
|
2313
|
+
// fallback aside, which only steps one frame), so the PTS stands in for the
|
|
2314
|
+
// DTS of such a packet in the checks below.
|
|
2315
|
+
const ts = dts !== AV_NOPTS_VALUE ? dts : pts;
|
|
2316
|
+
// 6. Reject a forward jump on the DTS libavformat would see, before the
|
|
2317
|
+
// packet enters it: once in, movenc takes the jump as the duration of the
|
|
2318
|
+
// stream's previous sample.
|
|
2319
|
+
let forwardOffset = NaN;
|
|
2320
|
+
if (isAudioVideo && this.forwardThresholdMs > 0) {
|
|
2321
|
+
forwardOffset = this.checkForwardDts(ts, arrival, streamInfo, streamIndex);
|
|
2322
|
+
}
|
|
2323
|
+
// 7. Reject what movenc cannot store before it reaches its abort
|
|
2324
|
+
if (this.movSampleLimit) {
|
|
2325
|
+
const duration = clampedDuration ?? pkt.duration;
|
|
2326
|
+
this.checkMovSampleDuration(ts, duration, lastMuxDts, dstTb, streamIndex);
|
|
2327
|
+
if (this.movZeroStart && streamInfo.firstMuxTs === undefined) {
|
|
2328
|
+
this.checkMovLateStart(ts, duration, dstTb, streamIndex);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
// 8. Update last mux DTS for next packet. mux.c fills a missing DTS itself
|
|
2332
|
+
// and rejects it unless it lies past the previous one, so the last known
|
|
2333
|
+
// DTS stays a valid reference: the clamp and the guards remain armed for
|
|
2334
|
+
// the next packet instead of letting it through unchecked.
|
|
2335
|
+
if (dts !== AV_NOPTS_VALUE) {
|
|
2336
|
+
streamInfo.lastMuxDts = dts;
|
|
2337
|
+
}
|
|
2338
|
+
streamInfo.firstMuxTs ??= ts;
|
|
2339
|
+
if (streamInfo.forward && !Number.isNaN(forwardOffset)) {
|
|
2340
|
+
this.recordForwardDts(streamInfo.forward, ts, forwardOffset, arrival);
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
/**
|
|
2344
|
+
* Reject a packet whose DTS lies further behind the stream's last muxed DTS
|
|
2345
|
+
* than the configured dtsBackwardThreshold.
|
|
2346
|
+
*
|
|
2347
|
+
* Expects the DTS already rescaled to the output stream time base. No-op when
|
|
2348
|
+
* the threshold is unset or 0.
|
|
2349
|
+
*
|
|
2350
|
+
* @param dts - Packet DTS in the output time base
|
|
2351
|
+
*
|
|
2352
|
+
* @param lastMuxDts - The stream's last muxed DTS
|
|
2353
|
+
*
|
|
2354
|
+
* @param timeBase - Output stream time base
|
|
2355
|
+
*
|
|
2356
|
+
* @param streamIndex - Stream index
|
|
2357
|
+
*
|
|
2358
|
+
* @throws {Error} If the backward jump exceeds the threshold
|
|
2359
|
+
*
|
|
2360
|
+
* @internal
|
|
2361
|
+
*/
|
|
2362
|
+
checkBackwardDts(dts, lastMuxDts, timeBase, streamIndex) {
|
|
2363
|
+
const threshold = this.options.dtsBackwardThreshold;
|
|
2364
|
+
if (!threshold || dts === AV_NOPTS_VALUE || lastMuxDts === AV_NOPTS_VALUE || dts >= lastMuxDts) {
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const regression = lastMuxDts - dts;
|
|
2368
|
+
const thresholdUs = BigInt(Math.round(threshold * 1_000_000));
|
|
2369
|
+
if (avCompareTs(regression, timeBase, thresholdUs, AV_TIME_BASE_Q) <= 0) {
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const seconds = ((Number(regression) * timeBase.num) / timeBase.den).toFixed(3);
|
|
2373
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: DTS jumped back ${seconds}s, more than dtsBackwardThreshold (${threshold}s)`);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Reject a packet whose DTS runs further ahead of real time than the
|
|
2377
|
+
* configured dtsForwardThreshold.
|
|
2378
|
+
*
|
|
2379
|
+
* Tracks per stream how real time and media time move apart (the offset) and
|
|
2380
|
+
* compares the packet's offset with the lowest one of the stream's recent
|
|
2381
|
+
* packets, see FORWARD_WINDOW_BUCKET. A gap in a live source leaves the
|
|
2382
|
+
* offset level, since the wait comes with it; a jump lowers it by the size
|
|
2383
|
+
* of the jump. The stream's first packet only sets the starting point.
|
|
2384
|
+
*
|
|
2385
|
+
* @param ts - Final packet DTS (PTS without DTS) in the output time base
|
|
2386
|
+
*
|
|
2387
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2388
|
+
*
|
|
2389
|
+
* @param streamInfo - Stream description
|
|
2390
|
+
*
|
|
2391
|
+
* @param streamIndex - Stream index
|
|
2392
|
+
*
|
|
2393
|
+
* @returns The packet's offset, for {@link recordForwardDts} once the packet is accepted
|
|
2394
|
+
*
|
|
2395
|
+
* @throws {Error} If the DTS runs ahead of real time by more than the threshold
|
|
2396
|
+
*
|
|
2397
|
+
* @internal
|
|
2398
|
+
*/
|
|
2399
|
+
checkForwardDts(ts, arrival, streamInfo, streamIndex) {
|
|
2400
|
+
const tb = streamInfo.muxTimeBase;
|
|
2401
|
+
const state = (streamInfo.forward ??= {
|
|
2402
|
+
msPerTick: (1000 * tb.num) / tb.den,
|
|
2403
|
+
lastTs: AV_NOPTS_VALUE,
|
|
2404
|
+
lastArrival: NaN,
|
|
2405
|
+
offset: 0,
|
|
2406
|
+
windowMin: Infinity,
|
|
2407
|
+
windowPrevMin: Infinity,
|
|
2408
|
+
windowCount: 0,
|
|
2409
|
+
});
|
|
2410
|
+
if (Number.isNaN(state.lastArrival)) {
|
|
2411
|
+
return 0;
|
|
2412
|
+
}
|
|
2413
|
+
const stepMs = Number(ts - state.lastTs) * state.msPerTick;
|
|
2414
|
+
const elapsedMs = arrival - state.lastArrival;
|
|
2415
|
+
const offset = state.offset + elapsedMs - stepMs;
|
|
2416
|
+
const reference = state.windowMin < state.windowPrevMin ? state.windowMin : state.windowPrevMin;
|
|
2417
|
+
if (reference - offset <= this.forwardThresholdMs) {
|
|
2418
|
+
return offset;
|
|
2419
|
+
}
|
|
2420
|
+
const jump = `DTS jumped forward ${(stepMs / 1000).toFixed(3)}s while ${(elapsedMs / 1000).toFixed(3)}s passed`;
|
|
2421
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${jump}, more than dtsForwardThreshold (${this.options.dtsForwardThreshold}s)`);
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Make an accepted packet the reference of the stream's next forward check.
|
|
2425
|
+
*
|
|
2426
|
+
* @param state - The stream's forward check state
|
|
2427
|
+
*
|
|
2428
|
+
* @param ts - The packet's DTS (PTS without DTS) in the output time base
|
|
2429
|
+
*
|
|
2430
|
+
* @param offset - The packet's offset from {@link checkForwardDts}
|
|
2431
|
+
*
|
|
2432
|
+
* @param arrival - When the packet reached the muxer (ms on the arrival clock)
|
|
2433
|
+
*
|
|
2434
|
+
* @internal
|
|
2435
|
+
*/
|
|
2436
|
+
recordForwardDts(state, ts, offset, arrival) {
|
|
2437
|
+
state.lastTs = ts;
|
|
2438
|
+
state.offset = offset;
|
|
2439
|
+
state.lastArrival = arrival;
|
|
2440
|
+
if (offset < state.windowMin) {
|
|
2441
|
+
state.windowMin = offset;
|
|
2442
|
+
}
|
|
2443
|
+
// Two buckets keep the window between one and two buckets long in O(1).
|
|
2444
|
+
if (++state.windowCount >= FORWARD_WINDOW_BUCKET) {
|
|
2445
|
+
state.windowPrevMin = state.windowMin;
|
|
2446
|
+
state.windowMin = Infinity;
|
|
2447
|
+
state.windowCount = 0;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Reject a packet whose DTS step or duration a movenc output cannot store.
|
|
2452
|
+
*
|
|
2453
|
+
* Runs after the monotonic clamp, on the DTS movenc will see. Steps are
|
|
2454
|
+
* measured against the stream's last muxed DTS.
|
|
2455
|
+
*
|
|
2456
|
+
* @param dts - Final packet DTS, or its PTS for a packet without DTS
|
|
2457
|
+
*
|
|
2458
|
+
* @param duration - Final packet duration
|
|
2459
|
+
*
|
|
2460
|
+
* @param lastMuxDts - The stream's last muxed DTS
|
|
2461
|
+
*
|
|
2462
|
+
* @param timeBase - Output stream time base
|
|
2463
|
+
*
|
|
2464
|
+
* @param streamIndex - Stream index
|
|
2465
|
+
*
|
|
2466
|
+
* @throws {Error} If the step or duration reaches INT_MAX ticks
|
|
2467
|
+
*
|
|
2468
|
+
* @internal
|
|
2469
|
+
*/
|
|
2470
|
+
checkMovSampleDuration(dts, duration, lastMuxDts, timeBase, streamIndex) {
|
|
2471
|
+
const step = dts !== AV_NOPTS_VALUE && lastMuxDts !== AV_NOPTS_VALUE ? dts - lastMuxDts : 0n;
|
|
2472
|
+
const ticks = step >= MOV_SAMPLE_DURATION_LIMIT ? step : duration >= MOV_SAMPLE_DURATION_LIMIT ? duration : undefined;
|
|
2473
|
+
if (ticks === undefined) {
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const what = ticks === step ? 'DTS jumped forward' : 'packet duration is';
|
|
2477
|
+
const seconds = ((Number(ticks) * timeBase.num) / timeBase.den).toFixed(3);
|
|
2478
|
+
const format = this.formatContext.oformat?.name ?? 'mov';
|
|
2479
|
+
const detail = `${what} ${ticks} ticks (${seconds}s at time base ${timeBase.num}/${timeBase.den})`;
|
|
2480
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${detail}, but ${format} sample durations must stay below ${MOV_SAMPLE_DURATION_LIMIT} ticks`);
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Reject a stream's first packet that ends before the output's start.
|
|
2484
|
+
*
|
|
2485
|
+
* Without an edit list movenc puts each track's first sample at the output's
|
|
2486
|
+
* start, so a first packet that ends before it becomes a sample of negative
|
|
2487
|
+
* duration, and movenc aborts the process (av_assert0 in
|
|
2488
|
+
* get_cluster_duration) at the next fragment or the trailer unless a second
|
|
2489
|
+
* sample reaches it first. libavformat sets that start from the lowest DTS
|
|
2490
|
+
* it holds when it writes its first packet. It holds every packet until each
|
|
2491
|
+
* stream has sent one or its queue spans max_interleave_delta, so only a
|
|
2492
|
+
* stream that starts that much later than the others can land before it.
|
|
2493
|
+
*
|
|
2494
|
+
* @param ts - Packet DTS (PTS without DTS) in the output time base
|
|
2495
|
+
*
|
|
2496
|
+
* @param duration - Packet duration
|
|
2497
|
+
*
|
|
2498
|
+
* @param timeBase - Output stream time base
|
|
2499
|
+
*
|
|
2500
|
+
* @param streamIndex - Stream index
|
|
2501
|
+
*
|
|
2502
|
+
* @throws {Error} If the packet ends before a start libavformat may already have set
|
|
2503
|
+
*
|
|
2504
|
+
* @internal
|
|
2505
|
+
*/
|
|
2506
|
+
checkMovLateStart(ts, duration, timeBase, streamIndex) {
|
|
2507
|
+
if (this.interleaveDeltaUs <= 0n) {
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
// The other streams' earliest and latest timestamp so far, in µs
|
|
2511
|
+
let start;
|
|
2512
|
+
let startUs = 0n;
|
|
2513
|
+
let latestUs = 0n;
|
|
2514
|
+
for (const [index, other] of this._streams) {
|
|
2515
|
+
const first = other.firstMuxTs;
|
|
2516
|
+
const otherTb = other.muxTimeBase;
|
|
2517
|
+
if (first === undefined || !otherTb) {
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
const firstUs = avRescaleQ(first, otherTb, AV_TIME_BASE_Q);
|
|
2521
|
+
const lastUs = other.lastMuxDts !== AV_NOPTS_VALUE ? avRescaleQ(other.lastMuxDts, otherTb, AV_TIME_BASE_Q) : firstUs;
|
|
2522
|
+
if (!start) {
|
|
2523
|
+
latestUs = lastUs;
|
|
1955
2524
|
}
|
|
2525
|
+
else if (lastUs > latestUs) {
|
|
2526
|
+
latestUs = lastUs;
|
|
2527
|
+
}
|
|
2528
|
+
if (!start || firstUs < startUs) {
|
|
2529
|
+
start = { ts: first, timeBase: otherTb, index };
|
|
2530
|
+
startUs = firstUs;
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
// Until its queue spans max_interleave_delta, libavformat has written nothing
|
|
2534
|
+
// and will start the output at this packet if it is the lowest.
|
|
2535
|
+
if (!start || latestUs - startUs <= this.interleaveDeltaUs) {
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
const end = ts + (duration > 0n ? duration : 0n);
|
|
2539
|
+
if (avCompareTs(end, timeBase, start.ts, start.timeBase) >= 0) {
|
|
2540
|
+
return;
|
|
1956
2541
|
}
|
|
1957
|
-
|
|
1958
|
-
|
|
2542
|
+
const seconds = (Number(startUs - avRescaleQ(end, timeBase, AV_TIME_BASE_Q)) / 1_000_000).toFixed(3);
|
|
2543
|
+
const format = this.formatContext.oformat?.name ?? 'mov';
|
|
2544
|
+
const detail = `its first packet ends ${seconds}s before the start of output stream ${start.index}`;
|
|
2545
|
+
throw new Error(`Timestamp discontinuity on output stream ${streamIndex}: ${detail}, which ${format} without an edit list cannot store`);
|
|
1959
2546
|
}
|
|
1960
2547
|
/**
|
|
1961
2548
|
* Copy container metadata from input to output.
|