node-av 3.1.3 → 4.0.0

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.
Files changed (156) hide show
  1. package/README.md +65 -52
  2. package/binding.gyp +4 -0
  3. package/dist/api/audio-frame-buffer.d.ts +201 -0
  4. package/dist/api/audio-frame-buffer.js +275 -0
  5. package/dist/api/audio-frame-buffer.js.map +1 -0
  6. package/dist/api/bitstream-filter.d.ts +319 -78
  7. package/dist/api/bitstream-filter.js +680 -151
  8. package/dist/api/bitstream-filter.js.map +1 -1
  9. package/dist/api/constants.d.ts +44 -0
  10. package/dist/api/constants.js +45 -0
  11. package/dist/api/constants.js.map +1 -0
  12. package/dist/api/data/test_av1.ivf +0 -0
  13. package/dist/api/data/test_mjpeg.mjpeg +0 -0
  14. package/dist/api/data/test_vp8.ivf +0 -0
  15. package/dist/api/data/test_vp9.ivf +0 -0
  16. package/dist/api/decoder.d.ts +279 -17
  17. package/dist/api/decoder.js +998 -209
  18. package/dist/api/decoder.js.map +1 -1
  19. package/dist/api/{media-input.d.ts → demuxer.d.ts} +294 -44
  20. package/dist/api/demuxer.js +1968 -0
  21. package/dist/api/demuxer.js.map +1 -0
  22. package/dist/api/encoder.d.ts +308 -50
  23. package/dist/api/encoder.js +1133 -111
  24. package/dist/api/encoder.js.map +1 -1
  25. package/dist/api/filter-presets.d.ts +12 -5
  26. package/dist/api/filter-presets.js +21 -7
  27. package/dist/api/filter-presets.js.map +1 -1
  28. package/dist/api/filter.d.ts +406 -40
  29. package/dist/api/filter.js +966 -139
  30. package/dist/api/filter.js.map +1 -1
  31. package/dist/api/{fmp4.d.ts → fmp4-stream.d.ts} +141 -140
  32. package/dist/api/fmp4-stream.js +539 -0
  33. package/dist/api/fmp4-stream.js.map +1 -0
  34. package/dist/api/hardware.d.ts +58 -6
  35. package/dist/api/hardware.js +127 -11
  36. package/dist/api/hardware.js.map +1 -1
  37. package/dist/api/index.d.ts +6 -4
  38. package/dist/api/index.js +14 -8
  39. package/dist/api/index.js.map +1 -1
  40. package/dist/api/io-stream.d.ts +3 -3
  41. package/dist/api/io-stream.js +5 -4
  42. package/dist/api/io-stream.js.map +1 -1
  43. package/dist/api/{media-output.d.ts → muxer.d.ts} +274 -60
  44. package/dist/api/muxer.js +1934 -0
  45. package/dist/api/muxer.js.map +1 -0
  46. package/dist/api/pipeline.d.ts +77 -29
  47. package/dist/api/pipeline.js +435 -425
  48. package/dist/api/pipeline.js.map +1 -1
  49. package/dist/api/rtp-stream.d.ts +312 -0
  50. package/dist/api/rtp-stream.js +630 -0
  51. package/dist/api/rtp-stream.js.map +1 -0
  52. package/dist/api/types.d.ts +476 -55
  53. package/dist/api/utilities/async-queue.d.ts +91 -0
  54. package/dist/api/utilities/async-queue.js +162 -0
  55. package/dist/api/utilities/async-queue.js.map +1 -0
  56. package/dist/api/utilities/audio-sample.d.ts +1 -1
  57. package/dist/api/utilities/image.d.ts +1 -1
  58. package/dist/api/utilities/index.d.ts +2 -0
  59. package/dist/api/utilities/index.js +4 -0
  60. package/dist/api/utilities/index.js.map +1 -1
  61. package/dist/api/utilities/media-type.d.ts +1 -1
  62. package/dist/api/utilities/pixel-format.d.ts +1 -1
  63. package/dist/api/utilities/sample-format.d.ts +1 -1
  64. package/dist/api/utilities/scheduler.d.ts +169 -0
  65. package/dist/api/utilities/scheduler.js +136 -0
  66. package/dist/api/utilities/scheduler.js.map +1 -0
  67. package/dist/api/utilities/streaming.d.ts +74 -15
  68. package/dist/api/utilities/streaming.js +170 -12
  69. package/dist/api/utilities/streaming.js.map +1 -1
  70. package/dist/api/utilities/timestamp.d.ts +1 -1
  71. package/dist/api/webrtc-stream.d.ts +288 -0
  72. package/dist/api/webrtc-stream.js +440 -0
  73. package/dist/api/webrtc-stream.js.map +1 -0
  74. package/dist/constants/constants.d.ts +51 -1
  75. package/dist/constants/constants.js +47 -1
  76. package/dist/constants/constants.js.map +1 -1
  77. package/dist/constants/encoders.d.ts +2 -1
  78. package/dist/constants/encoders.js +4 -3
  79. package/dist/constants/encoders.js.map +1 -1
  80. package/dist/constants/hardware.d.ts +26 -0
  81. package/dist/constants/hardware.js +27 -0
  82. package/dist/constants/hardware.js.map +1 -0
  83. package/dist/constants/index.d.ts +1 -0
  84. package/dist/constants/index.js +1 -0
  85. package/dist/constants/index.js.map +1 -1
  86. package/dist/lib/binding.d.ts +19 -8
  87. package/dist/lib/binding.js.map +1 -1
  88. package/dist/lib/codec-context.d.ts +87 -0
  89. package/dist/lib/codec-context.js +125 -4
  90. package/dist/lib/codec-context.js.map +1 -1
  91. package/dist/lib/codec-parameters.d.ts +183 -1
  92. package/dist/lib/codec-parameters.js +209 -0
  93. package/dist/lib/codec-parameters.js.map +1 -1
  94. package/dist/lib/codec-parser.d.ts +23 -0
  95. package/dist/lib/codec-parser.js +25 -0
  96. package/dist/lib/codec-parser.js.map +1 -1
  97. package/dist/lib/codec.d.ts +26 -4
  98. package/dist/lib/codec.js +35 -0
  99. package/dist/lib/codec.js.map +1 -1
  100. package/dist/lib/dictionary.js +1 -0
  101. package/dist/lib/dictionary.js.map +1 -1
  102. package/dist/lib/error.js +1 -1
  103. package/dist/lib/error.js.map +1 -1
  104. package/dist/lib/filter-context.d.ts +52 -11
  105. package/dist/lib/filter-context.js +56 -12
  106. package/dist/lib/filter-context.js.map +1 -1
  107. package/dist/lib/filter-graph.d.ts +9 -0
  108. package/dist/lib/filter-graph.js +13 -0
  109. package/dist/lib/filter-graph.js.map +1 -1
  110. package/dist/lib/filter.d.ts +21 -0
  111. package/dist/lib/filter.js +28 -0
  112. package/dist/lib/filter.js.map +1 -1
  113. package/dist/lib/format-context.d.ts +48 -14
  114. package/dist/lib/format-context.js +76 -7
  115. package/dist/lib/format-context.js.map +1 -1
  116. package/dist/lib/frame.d.ts +168 -0
  117. package/dist/lib/frame.js +212 -0
  118. package/dist/lib/frame.js.map +1 -1
  119. package/dist/lib/hardware-device-context.d.ts +3 -2
  120. package/dist/lib/hardware-device-context.js.map +1 -1
  121. package/dist/lib/index.d.ts +1 -0
  122. package/dist/lib/index.js +2 -0
  123. package/dist/lib/index.js.map +1 -1
  124. package/dist/lib/input-format.d.ts +21 -0
  125. package/dist/lib/input-format.js +42 -2
  126. package/dist/lib/input-format.js.map +1 -1
  127. package/dist/lib/native-types.d.ts +48 -26
  128. package/dist/lib/option.d.ts +25 -13
  129. package/dist/lib/option.js +28 -0
  130. package/dist/lib/option.js.map +1 -1
  131. package/dist/lib/output-format.d.ts +22 -1
  132. package/dist/lib/output-format.js +28 -0
  133. package/dist/lib/output-format.js.map +1 -1
  134. package/dist/lib/packet.d.ts +35 -0
  135. package/dist/lib/packet.js +52 -2
  136. package/dist/lib/packet.js.map +1 -1
  137. package/dist/lib/stream.d.ts +126 -0
  138. package/dist/lib/stream.js +188 -5
  139. package/dist/lib/stream.js.map +1 -1
  140. package/dist/lib/sync-queue.d.ts +179 -0
  141. package/dist/lib/sync-queue.js +197 -0
  142. package/dist/lib/sync-queue.js.map +1 -0
  143. package/dist/lib/types.d.ts +27 -1
  144. package/dist/lib/utilities.d.ts +281 -53
  145. package/dist/lib/utilities.js +298 -55
  146. package/dist/lib/utilities.js.map +1 -1
  147. package/package.json +20 -19
  148. package/dist/api/fmp4.js +0 -710
  149. package/dist/api/fmp4.js.map +0 -1
  150. package/dist/api/media-input.js +0 -1075
  151. package/dist/api/media-input.js.map +0 -1
  152. package/dist/api/media-output.js +0 -1040
  153. package/dist/api/media-output.js.map +0 -1
  154. package/dist/api/webrtc.d.ts +0 -664
  155. package/dist/api/webrtc.js +0 -1132
  156. package/dist/api/webrtc.js.map +0 -1
@@ -0,0 +1,1934 @@
1
+ import { mkdirSync } from 'fs';
2
+ import { mkdir } from 'fs/promises';
3
+ import { dirname, resolve } from 'path';
4
+ 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 { Dictionary } from '../lib/dictionary.js';
6
+ import { FFmpegError } from '../lib/error.js';
7
+ import { FormatContext } from '../lib/format-context.js';
8
+ import { IOContext } from '../lib/io-context.js';
9
+ import { Packet } from '../lib/packet.js';
10
+ import { Rational } from '../lib/rational.js';
11
+ import { SyncQueue, SyncQueueType } from '../lib/sync-queue.js';
12
+ import { avAddQ, avCompareTs, avGetAudioFrameDuration2, avRescaleDelta, avRescaleQ } from '../lib/utilities.js';
13
+ import { IO_BUFFER_SIZE, MAX_MUXING_QUEUE_SIZE, MAX_PACKET_SIZE, MUXING_QUEUE_DATA_THRESHOLD, SYNC_BUFFER_DURATION } from './constants.js';
14
+ import { Encoder } from './encoder.js';
15
+ import { AsyncQueue } from './utilities/async-queue.js';
16
+ /**
17
+ * High-level muxer for writing and muxing media files.
18
+ *
19
+ * Provides simplified access to media muxing and file writing operations.
20
+ * Automatically manages header and trailer writing - header is written on first packet,
21
+ * trailer is written on close. Supports lazy initialization for both encoders and streams.
22
+ * Handles stream configuration, packet writing, and format management.
23
+ * Supports files, URLs, and custom I/O with automatic cleanup.
24
+ * Essential component for media encoding pipelines and transcoding.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import { Muxer } from 'node-av/api';
29
+ *
30
+ * // Create output file
31
+ * await using output = await Muxer.open('output.mp4');
32
+ *
33
+ * // Add streams from encoders
34
+ * const videoIdx = output.addStream(videoEncoder);
35
+ * const audioIdx = output.addStream(audioEncoder);
36
+ *
37
+ * // Write packets - header written automatically on first packet
38
+ * await output.writePacket(packet, videoIdx);
39
+ *
40
+ * // Close - trailer written automatically
41
+ * // (automatic with await using)
42
+ * ```
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * // Stream copy
47
+ * await using input = await Demuxer.open('input.mp4');
48
+ * await using output = await Muxer.open('output.mp4');
49
+ *
50
+ * // Copy stream configuration
51
+ * const videoIdx = output.addStream(input.video());
52
+ *
53
+ * // Process packets - header/trailer handled automatically
54
+ * for await (const packet of input.packets()) {
55
+ * await output.writePacket(packet, videoIdx);
56
+ * packet.free();
57
+ * }
58
+ * ```
59
+ *
60
+ * @see {@link Demuxer} For reading media files
61
+ * @see {@link Encoder} For encoding frames to packets
62
+ * @see {@link FormatContext} For low-level API
63
+ */
64
+ export class Muxer {
65
+ formatContext;
66
+ options;
67
+ _streams = new Map();
68
+ ioContext;
69
+ headerWritten = false;
70
+ headerWritePromise;
71
+ trailerWritten = false;
72
+ isClosed = false;
73
+ syncQueue; // FFmpeg's native sync queue for packet interleaving
74
+ sqPacket; // Reusable packet for sync queue receive
75
+ containerMetadataCopied = false; // Track if container metadata has been copied
76
+ writeQueue; // Optional async queue for serialized writes
77
+ writeWorkerPromise; // Background worker promise
78
+ /**
79
+ * @param options - Media output options
80
+ *
81
+ * @internal
82
+ */
83
+ constructor(options) {
84
+ this.options = {
85
+ copyInitialNonkeyframes: false,
86
+ exitOnError: true,
87
+ useSyncQueue: true,
88
+ useAsyncWrite: true,
89
+ ...options,
90
+ };
91
+ this.formatContext = new FormatContext();
92
+ }
93
+ static async open(target, options) {
94
+ const output = new Muxer(options);
95
+ try {
96
+ if (typeof target === 'string') {
97
+ // File or stream URL - resolve relative paths and create directories
98
+ // Check if it's a URL (starts with protocol://) or a file path
99
+ const isUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(target);
100
+ const resolvedTarget = isUrl ? target : resolve(target);
101
+ // Create directory structure for local files (not URLs)
102
+ if (!isUrl && target !== '') {
103
+ const dir = dirname(resolvedTarget);
104
+ await mkdir(dir, { recursive: true });
105
+ }
106
+ // Allocate output context
107
+ const ret = output.formatContext.allocOutputContext2(null, options?.format ?? null, resolvedTarget === '' ? null : resolvedTarget);
108
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
109
+ // Set format options if provided
110
+ if (options?.options) {
111
+ for (const [key, value] of Object.entries(options.options)) {
112
+ output.formatContext.setOption(key, value);
113
+ }
114
+ }
115
+ // Check if we need to open IO
116
+ const oformat = output.formatContext.oformat;
117
+ if (resolvedTarget && oformat && !oformat.hasFlags(AVFMT_NOFILE)) {
118
+ // For file-based formats, we need to open the file using avio_open2
119
+ // FFmpeg will manage the AVIOContext internally
120
+ output.ioContext = new IOContext();
121
+ const openRet = await output.ioContext.open2(resolvedTarget, AVIO_FLAG_WRITE);
122
+ FFmpegError.throwIfError(openRet, `Failed to open output file: ${resolvedTarget}`);
123
+ output.formatContext.pb = output.ioContext;
124
+ }
125
+ }
126
+ else {
127
+ // Custom IO with callbacks - format is required
128
+ if (!options?.format) {
129
+ throw new Error('Format must be specified for custom IO');
130
+ }
131
+ const ret = output.formatContext.allocOutputContext2(null, options.format, null);
132
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
133
+ // Set format options if provided
134
+ if (options?.options) {
135
+ for (const [key, value] of Object.entries(options.options)) {
136
+ output.formatContext.setOption(key, value);
137
+ }
138
+ }
139
+ // Setup custom IO with callbacks
140
+ output.ioContext = new IOContext();
141
+ output.ioContext.allocContextWithCallbacks(options.bufferSize ?? IO_BUFFER_SIZE, 1, target.read, target.write, target.seek);
142
+ output.ioContext.maxPacketSize = options.maxPacketSize ?? MAX_PACKET_SIZE;
143
+ output.formatContext.pb = output.ioContext;
144
+ output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
145
+ }
146
+ return output;
147
+ }
148
+ catch (error) {
149
+ // Cleanup on error
150
+ if (output.ioContext) {
151
+ try {
152
+ const isCustomIO = output.formatContext.hasFlags(AVFMT_FLAG_CUSTOM_IO);
153
+ if (isCustomIO) {
154
+ // Clear the pb reference first
155
+ output.formatContext.pb = null;
156
+ // For custom IO with callbacks, free the context
157
+ output.ioContext.freeContext();
158
+ }
159
+ else {
160
+ // For file-based IO, close the file handle
161
+ await output.ioContext.closep();
162
+ }
163
+ }
164
+ catch {
165
+ // Ignore errors
166
+ }
167
+ }
168
+ if (output.formatContext) {
169
+ try {
170
+ output.formatContext.freeContext();
171
+ }
172
+ catch {
173
+ // Ignore errors
174
+ }
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+ static openSync(target, options) {
180
+ const output = new Muxer(options);
181
+ try {
182
+ if (typeof target === 'string') {
183
+ // File or stream URL - resolve relative paths and create directories
184
+ // Check if it's a URL (starts with protocol://) or a file path
185
+ const isUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(target);
186
+ const resolvedTarget = isUrl ? target : resolve(target);
187
+ // Create directory structure for local files (not URLs)
188
+ if (!isUrl && target !== '') {
189
+ const dir = dirname(resolvedTarget);
190
+ mkdirSync(dir, { recursive: true });
191
+ }
192
+ // Allocate output context
193
+ const ret = output.formatContext.allocOutputContext2(null, options?.format ?? null, resolvedTarget === '' ? null : resolvedTarget);
194
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
195
+ // Set format options if provided
196
+ if (options?.options) {
197
+ for (const [key, value] of Object.entries(options.options)) {
198
+ output.formatContext.setOption(key, value);
199
+ }
200
+ }
201
+ // Check if we need to open IO
202
+ const oformat = output.formatContext.oformat;
203
+ if (resolvedTarget && oformat && !oformat.hasFlags(AVFMT_NOFILE)) {
204
+ // For file-based formats, we need to open the file using avio_open2
205
+ // FFmpeg will manage the AVIOContext internally
206
+ output.ioContext = new IOContext();
207
+ const openRet = output.ioContext.open2Sync(resolvedTarget, AVIO_FLAG_WRITE);
208
+ FFmpegError.throwIfError(openRet, `Failed to open output file: ${resolvedTarget}`);
209
+ output.formatContext.pb = output.ioContext;
210
+ }
211
+ }
212
+ else {
213
+ // Custom IO with callbacks - format is required
214
+ if (!options?.format) {
215
+ throw new Error('Format must be specified for custom IO');
216
+ }
217
+ const ret = output.formatContext.allocOutputContext2(null, options.format, null);
218
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
219
+ // Set format options if provided
220
+ if (options?.options) {
221
+ for (const [key, value] of Object.entries(options.options)) {
222
+ output.formatContext.setOption(key, value);
223
+ }
224
+ }
225
+ // Setup custom IO with callbacks
226
+ output.ioContext = new IOContext();
227
+ output.ioContext.allocContextWithCallbacks(options.bufferSize ?? IO_BUFFER_SIZE, 1, target.read, target.write, target.seek);
228
+ output.ioContext.maxPacketSize = options.maxPacketSize ?? MAX_PACKET_SIZE;
229
+ output.formatContext.pb = output.ioContext;
230
+ output.formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
231
+ }
232
+ return output;
233
+ }
234
+ catch (error) {
235
+ // Cleanup on error
236
+ if (output.ioContext) {
237
+ try {
238
+ const isCustomIO = output.formatContext.hasFlags(AVFMT_FLAG_CUSTOM_IO);
239
+ if (isCustomIO) {
240
+ // Clear the pb reference first
241
+ output.formatContext.pb = null;
242
+ // For custom IO with callbacks, free the context
243
+ output.ioContext.freeContext();
244
+ }
245
+ else {
246
+ // For file-based IO, close the file handle
247
+ output.ioContext.closepSync();
248
+ }
249
+ }
250
+ catch {
251
+ // Ignore errors
252
+ }
253
+ }
254
+ if (output.formatContext) {
255
+ try {
256
+ output.formatContext.freeContext();
257
+ }
258
+ catch {
259
+ // Ignore errors
260
+ }
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ /**
266
+ * Check if output is open.
267
+ *
268
+ * @example
269
+ * ```typescript
270
+ * if (!output.isOutputOpen) {
271
+ * console.log('Output is not open');
272
+ * }
273
+ * ```
274
+ */
275
+ get isOpen() {
276
+ return !this.isClosed;
277
+ }
278
+ /**
279
+ * Check if output is initialized.
280
+ *
281
+ * All streams have been initialized.
282
+ * This occurs after the first packet has been written to each stream.
283
+ *
284
+ * @example
285
+ * ```typescript
286
+ * if (!output.isOutputInitialized) {
287
+ * console.log('Output is not initialized');
288
+ * }
289
+ * ```
290
+ */
291
+ get streamsInitialized() {
292
+ if (this._streams.size === 0) {
293
+ return false;
294
+ }
295
+ if (this.isClosed) {
296
+ return false;
297
+ }
298
+ return Array.from(this._streams).every(([_, stream]) => stream.initialized);
299
+ }
300
+ /**
301
+ * Get all streams in the media.
302
+ *
303
+ * @example
304
+ * ```typescript
305
+ * for (const stream of output.streams) {
306
+ * console.log(`Stream ${stream.index}: ${stream.codecpar.codecType}`);
307
+ * }
308
+ * ```
309
+ */
310
+ get streams() {
311
+ return this.formatContext.streams;
312
+ }
313
+ /**
314
+ * Get format name.
315
+ *
316
+ * Returns 'unknown' if output is closed or format is not available.
317
+ *
318
+ * @example
319
+ * ```typescript
320
+ * console.log(`Format: ${output.formatName}`); // "mov,mp4,m4a,3gp,3g2,mj2"
321
+ * ```
322
+ */
323
+ get formatName() {
324
+ if (this.isClosed) {
325
+ return 'unknown';
326
+ }
327
+ return this.formatContext.oformat?.name ?? 'unknown';
328
+ }
329
+ /**
330
+ * Get format long name.
331
+ *
332
+ * Returns 'Unknown Format' if output is closed or format is not available.
333
+ *
334
+ * @example
335
+ * ```typescript
336
+ * console.log(`Format: ${output.formatLongName}`); // "QuickTime / MOV"
337
+ * ```
338
+ */
339
+ get formatLongName() {
340
+ if (this.isClosed) {
341
+ return 'Unknown Format';
342
+ }
343
+ return this.formatContext.oformat?.longName ?? 'Unknown Format';
344
+ }
345
+ /**
346
+ * Get MIME type of the output format.
347
+ *
348
+ * Returns format's native MIME type.
349
+ * For DASH/HLS formats, use {@link getStreamMimeType} for stream-specific MIME types.
350
+ *
351
+ * Returns null if output is closed or format is not available.
352
+ *
353
+ * @example
354
+ * ```typescript
355
+ * console.log(mp4Output.mimeType); // "video/mp4"
356
+ * console.log(dashOutput.mimeType); // null (DASH has no global MIME type)
357
+ *
358
+ * // For DASH/HLS, get MIME type per stream:
359
+ * console.log(dashOutput.getStreamMimeType(0)); // "video/mp4"
360
+ * ```
361
+ */
362
+ get mimeType() {
363
+ if (this.isClosed) {
364
+ return null;
365
+ }
366
+ return this.formatContext.oformat?.mimeType ?? null;
367
+ }
368
+ addStream(streamOrEncoder, options) {
369
+ if (this.isClosed) {
370
+ throw new Error('Muxer is closed');
371
+ }
372
+ if (this.headerWritten) {
373
+ throw new Error('Cannot add streams after packets have been written');
374
+ }
375
+ const outStream = this.formatContext.newStream(null);
376
+ if (!outStream) {
377
+ throw new Error('Failed to create new stream');
378
+ }
379
+ // Determine if first parameter is Encoder or Stream
380
+ const isEncoderFirst = streamOrEncoder instanceof Encoder;
381
+ let stream;
382
+ let encoder;
383
+ if (isEncoderFirst) {
384
+ // First parameter is Encoder
385
+ encoder = streamOrEncoder;
386
+ stream = options?.inputStream;
387
+ }
388
+ else {
389
+ // First parameter is Stream
390
+ stream = streamOrEncoder;
391
+ encoder = options?.encoder;
392
+ }
393
+ const isStreamCopy = !encoder;
394
+ // Auto-set GLOBAL_HEADER flag if format requires it
395
+ if (encoder) {
396
+ const oformat = this.formatContext.oformat;
397
+ if (oformat?.hasFlags(AVFMT_GLOBALHEADER)) {
398
+ encoder.setCodecFlags(AV_CODEC_FLAG_GLOBAL_HEADER);
399
+ }
400
+ }
401
+ // For stream copy, initialize immediately since we have all the info
402
+ if (isStreamCopy) {
403
+ if (!stream) {
404
+ throw new Error('Stream copy mode requires an input stream');
405
+ }
406
+ const ret = stream.codecpar.copy(outStream.codecpar);
407
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters');
408
+ // Set the timebases
409
+ const sourceTimeBase = stream.timeBase;
410
+ outStream.timeBase = new Rational(stream.timeBase.num, stream.timeBase.den);
411
+ // Copy frame rates and aspect ratios
412
+ outStream.avgFrameRate = stream.avgFrameRate;
413
+ if (stream.sampleAspectRatio.num > 0) {
414
+ outStream.sampleAspectRatio = stream.sampleAspectRatio;
415
+ }
416
+ outStream.rFrameRate = stream.rFrameRate;
417
+ // Copy duration
418
+ if (stream.duration > 0n) {
419
+ outStream.duration = stream.duration;
420
+ }
421
+ // Copy metadata
422
+ const metadata = stream.metadata;
423
+ if (metadata) {
424
+ outStream.metadata = metadata;
425
+ }
426
+ // Copy disposition
427
+ outStream.disposition = stream.disposition;
428
+ // Copy coded_side_data (HDR/Dolby Vision)
429
+ // Iterate over all side_data entries and copy them
430
+ const allSideData = stream.codecpar.getAllCodedSideData();
431
+ for (const sd of allSideData) {
432
+ outStream.codecpar.addCodedSideData(sd.type, sd.data);
433
+ }
434
+ this._streams.set(outStream.index, {
435
+ initialized: true,
436
+ outputStream: outStream,
437
+ inputStream: stream,
438
+ encoder: undefined,
439
+ sourceTimeBase,
440
+ isStreamCopy: true,
441
+ sqIdxMux: -1, // Will be set if sync queue is needed
442
+ preMuxQueue: [],
443
+ preMuxQueueDataSize: 0,
444
+ eofReceived: false,
445
+ lastMuxDts: AV_NOPTS_VALUE,
446
+ tsRescaleDeltaLast: { value: AV_NOPTS_VALUE },
447
+ streamcopyStarted: false,
448
+ });
449
+ }
450
+ else {
451
+ // Encoding path - lazy initialization
452
+ // stream is optional here - if provided, we copy metadata/disposition
453
+ // If not provided (encoder-only mode), stream will be initialized from first encoded frame
454
+ this._streams.set(outStream.index, {
455
+ initialized: false,
456
+ outputStream: outStream,
457
+ inputStream: stream,
458
+ encoder,
459
+ sourceTimeBase: undefined, // Will be set on initialization
460
+ isStreamCopy: false,
461
+ sqIdxMux: -1, // Will be set if sync queue is needed
462
+ preMuxQueue: [],
463
+ preMuxQueueDataSize: 0,
464
+ eofReceived: false,
465
+ lastMuxDts: AV_NOPTS_VALUE,
466
+ tsRescaleDeltaLast: { value: AV_NOPTS_VALUE },
467
+ streamcopyStarted: false,
468
+ });
469
+ }
470
+ return outStream.index;
471
+ }
472
+ /**
473
+ * Get output stream by index.
474
+ *
475
+ * Returns the stream at the specified index.
476
+ * Use the stream index returned by addStream().
477
+ *
478
+ * @param index - Stream index (returned by addStream)
479
+ *
480
+ * @returns Stream or undefined if index is invalid
481
+ *
482
+ * @example
483
+ * ```typescript
484
+ * const output = await Muxer.open('output.mp4');
485
+ * const videoIdx = output.addStream(encoder);
486
+ *
487
+ * // Get the output stream to inspect codec parameters
488
+ * const stream = output.getStream(videoIdx);
489
+ * if (stream) {
490
+ * console.log(`Output codec: ${stream.codecpar.codecId}`);
491
+ * }
492
+ * ```
493
+ *
494
+ * @see {@link addStream} For adding streams
495
+ * @see {@link video} For getting video streams
496
+ * @see {@link audio} For getting audio streams
497
+ */
498
+ getStream(index) {
499
+ const streams = this.formatContext.streams;
500
+ if (!streams || index < 0 || index >= streams.length) {
501
+ return undefined;
502
+ }
503
+ return streams[index];
504
+ }
505
+ /**
506
+ * Get video stream by index.
507
+ *
508
+ * Returns the nth video stream (0-based index).
509
+ * Returns undefined if stream doesn't exist.
510
+ *
511
+ * @param index - Video stream index (default: 0)
512
+ *
513
+ * @returns Video stream or undefined
514
+ *
515
+ * @example
516
+ * ```typescript
517
+ * const output = await Muxer.open('output.mp4');
518
+ * output.addStream(videoEncoder);
519
+ *
520
+ * // Get first video stream
521
+ * const videoStream = output.video();
522
+ * if (videoStream) {
523
+ * console.log(`Video output: ${videoStream.codecpar.width}x${videoStream.codecpar.height}`);
524
+ * }
525
+ * ```
526
+ *
527
+ * @see {@link audio} For audio streams
528
+ * @see {@link getStream} For direct stream access
529
+ */
530
+ video(index = 0) {
531
+ const streams = this.formatContext.streams;
532
+ if (!streams)
533
+ return undefined;
534
+ const videoStreams = streams.filter((s) => s.codecpar.codecType === AVMEDIA_TYPE_VIDEO);
535
+ return videoStreams[index];
536
+ }
537
+ /**
538
+ * Get audio stream by index.
539
+ *
540
+ * Returns the nth audio stream (0-based index).
541
+ * Returns undefined if stream doesn't exist.
542
+ *
543
+ * @param index - Audio stream index (default: 0)
544
+ *
545
+ * @returns Audio stream or undefined
546
+ *
547
+ * @example
548
+ * ```typescript
549
+ * const output = await Muxer.open('output.mp4');
550
+ * output.addStream(audioEncoder);
551
+ *
552
+ * // Get first audio stream
553
+ * const audioStream = output.audio();
554
+ * if (audioStream) {
555
+ * console.log(`Audio output: ${audioStream.codecpar.sampleRate}Hz`);
556
+ * }
557
+ * ```
558
+ *
559
+ * @see {@link video} For video streams
560
+ * @see {@link getStream} For direct stream access
561
+ */
562
+ audio(index = 0) {
563
+ const streams = this.formatContext.streams;
564
+ if (!streams)
565
+ return undefined;
566
+ const audioStreams = streams.filter((s) => s.codecpar.codecType === AVMEDIA_TYPE_AUDIO);
567
+ return audioStreams[index];
568
+ }
569
+ /**
570
+ * Get output format.
571
+ *
572
+ * Returns the output format used for muxing.
573
+ * May be null if format context not initialized.
574
+ *
575
+ * @returns Output format or null
576
+ *
577
+ * @example
578
+ * ```typescript
579
+ * const output = await Muxer.open('output.mp4');
580
+ * const format = output.outputFormat();
581
+ * if (format) {
582
+ * console.log(`Output format: ${format.name}`);
583
+ * }
584
+ * ```
585
+ *
586
+ * @see {@link OutputFormat} For format details
587
+ */
588
+ outputFormat() {
589
+ return this.formatContext.oformat;
590
+ }
591
+ /**
592
+ * Write a packet to the output.
593
+ *
594
+ * Writes muxed packet to the specified stream.
595
+ * Automatically handles:
596
+ * - Stream initialization on first packet (lazy initialization)
597
+ * - Codec parameter configuration from encoder or input stream
598
+ * - Header writing on first packet
599
+ * - Timestamp rescaling between source and output timebases
600
+ * - Sync queue for proper interleaving
601
+ *
602
+ * For encoder sources, the encoder must have processed at least one frame
603
+ * before packets can be written (encoder must be initialized).
604
+ *
605
+ * Uses FFmpeg CLI's sync queue pattern: buffers packets per stream and writes
606
+ * them in DTS order using av_compare_ts for timebase-aware comparison.
607
+ *
608
+ * To signal EOF for a stream, pass null as the packet.
609
+ * This tells the muxer that no more packets will be sent for this stream.
610
+ * The trailer is written only when close() is called.
611
+ *
612
+ * Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
613
+ *
614
+ * @param packet - Packet to write
615
+ *
616
+ * @param streamIndex - Target stream index
617
+ *
618
+ * @throws {Error} If stream invalid or encoder not initialized
619
+ *
620
+ * @throws {FFmpegError} If write fails
621
+ *
622
+ * @example
623
+ * ```typescript
624
+ * // Write encoded packet - header written automatically on first packet
625
+ * const packet = await encoder.encode(frame);
626
+ * if (packet) {
627
+ * await output.writePacket(packet, videoIdx);
628
+ * packet.free();
629
+ * }
630
+ * ```
631
+ *
632
+ * @example
633
+ * ```typescript
634
+ * // Stream copy with packet processing
635
+ * for await (const packet of input.packets()) {
636
+ * if (packet.streamIndex === inputVideoIdx) {
637
+ * await output.writePacket(packet, outputVideoIdx);
638
+ * }
639
+ * packet.free();
640
+ * }
641
+ * ```
642
+ *
643
+ * @see {@link addStream} For adding streams
644
+ */
645
+ async writePacket(packet, streamIndex) {
646
+ if (this.isClosed) {
647
+ throw new Error('Muxer is closed');
648
+ }
649
+ if (this.trailerWritten) {
650
+ throw new Error('Cannot write packets after output is finalized');
651
+ }
652
+ if (!this._streams.get(streamIndex)) {
653
+ throw new Error(`Invalid stream index: ${streamIndex}`);
654
+ }
655
+ // Initialize any encoder streams that are ready
656
+ for (const streamInfo of this._streams.values()) {
657
+ if (!streamInfo.initialized && streamInfo.encoder) {
658
+ const encoder = streamInfo.encoder;
659
+ const codecContext = encoder.getCodecContext();
660
+ // Skip if encoder not ready yet
661
+ if (!encoder.isEncoderInitialized || !codecContext) {
662
+ continue;
663
+ }
664
+ // This encoder is ready, initialize it now
665
+ // Read codecType from codecContext, not from stream (which is still uninitialized)
666
+ // const codecType = codecContext.codecType;
667
+ // 1. Set stream timebase
668
+ if (streamInfo.outputStream.timeBase.num <= 0 || streamInfo.outputStream.timeBase.den <= 0) {
669
+ const tb = avAddQ(codecContext.timeBase, { num: 0, den: 1 });
670
+ streamInfo.outputStream.timeBase = new Rational(tb.num, tb.den);
671
+ }
672
+ // 2. Set stream avg_frame_rate, r_frame_rate and sample_aspect_ratio
673
+ const fr = codecContext.framerate;
674
+ streamInfo.outputStream.avgFrameRate = new Rational(fr.num, fr.den);
675
+ streamInfo.outputStream.sampleAspectRatio = codecContext.sampleAspectRatio;
676
+ // 3. Copy codec parameters from encoder context
677
+ const ret = streamInfo.outputStream.codecpar.fromContext(codecContext);
678
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
679
+ // 4. Copy metadata from input stream
680
+ if (streamInfo.inputStream) {
681
+ const metadata = streamInfo.inputStream.metadata;
682
+ if (metadata) {
683
+ streamInfo.outputStream.metadata = metadata;
684
+ }
685
+ // 5. Copy disposition from input stream
686
+ streamInfo.outputStream.disposition = streamInfo.inputStream.disposition;
687
+ // 6. Copy duration hint from input stream
688
+ if (streamInfo.inputStream.duration > 0n) {
689
+ const inputTb = streamInfo.inputStream.timeBase;
690
+ const outputTb = streamInfo.outputStream.timeBase;
691
+ const rescaledDuration = avRescaleQ(streamInfo.inputStream.duration, inputTb, outputTb);
692
+ streamInfo.outputStream.duration = rescaledDuration;
693
+ }
694
+ }
695
+ // Update the source timebase for timestamp rescaling
696
+ streamInfo.sourceTimeBase = codecContext.timeBase;
697
+ // Mark as initialized
698
+ streamInfo.initialized = true;
699
+ }
700
+ }
701
+ const streamInfo = this._streams.get(streamIndex);
702
+ // Handle NULL packet - signals EOF for this stream (FFmpeg pattern: av_interleaved_write_frame(s, NULL))
703
+ // FFmpeg's behavior:
704
+ // - If muxer not started (uninitialized streams), buffer NULL in PreMuxQueue as EOF marker
705
+ // - If muxer started, send NULL to SyncQueue to signal EOF and flush
706
+ if (packet === null) {
707
+ // Mark stream as EOF received
708
+ streamInfo.eofReceived = true;
709
+ // Check if any streams are still uninitialized (PreMuxQueue phase)
710
+ const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
711
+ // PHASE 1: Before muxer starts - buffer NULL packet in PreMuxQueue
712
+ // This matches FFmpeg's mux_queue_packet() which writes NULL to PreMuxQueue FIFO
713
+ if (uninitialized || this.headerWritePromise) {
714
+ // Buffer NULL as EOF marker (no size contribution)
715
+ streamInfo.preMuxQueue.push(null);
716
+ return;
717
+ }
718
+ // PHASE 2: After muxer started - send EOF to SyncQueue and flush
719
+ if (!this.headerWritten) {
720
+ return;
721
+ }
722
+ // If using SyncQueue, send EOF for this stream
723
+ if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
724
+ // Send NULL to signal EOF to sync queue
725
+ // Native side handles null correctly (sets sqframe.p = nullptr)
726
+ const ret = this.syncQueue.send(streamInfo.sqIdxMux, null);
727
+ if (ret < 0 && ret !== AVERROR_EOF) {
728
+ if (this.options.exitOnError) {
729
+ FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue');
730
+ }
731
+ }
732
+ // Receive and write any remaining packets from sync queue
733
+ while (!this.isClosed) {
734
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
735
+ if (recvRet === AVERROR_EAGAIN) {
736
+ break; // No more packets ready
737
+ }
738
+ if (recvRet === AVERROR_EOF) {
739
+ break; // All streams finished
740
+ }
741
+ if (recvRet >= 0) {
742
+ const recvStreamInfo = this._streams.get(recvRet);
743
+ const pkt = this.sqPacket.clone();
744
+ if (!pkt) {
745
+ throw new Error('Failed to clone packet from sync queue');
746
+ }
747
+ pkt.streamIndex = recvRet;
748
+ await this.write(pkt, recvStreamInfo, recvRet);
749
+ }
750
+ }
751
+ }
752
+ return; // EOF signaled, nothing more to do
753
+ }
754
+ // Clone packet immediately - we will modify it and caller retains ownership
755
+ const clonedPacket = packet.clone();
756
+ if (!clonedPacket) {
757
+ throw new Error('Failed to clone packet for writing');
758
+ }
759
+ // Apply streamcopy filtering BEFORE buffering
760
+ // This ensures rejected packets never enter the queue/buffer
761
+ if (streamInfo.isStreamCopy) {
762
+ const shouldWrite = this.ofStreamcopy(clonedPacket, streamInfo, streamIndex);
763
+ if (!shouldWrite) {
764
+ clonedPacket.free(); // Free the clone since we won't use it
765
+ return;
766
+ }
767
+ }
768
+ // Check if any streams are still uninitialized or header is being written
769
+ const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
770
+ // PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
771
+ // PreMuxQueue is used during initialization phase ONLY (regardless of SyncQueue presence)
772
+ // After header write, PreMuxQueue is flushed in DTS-sorted order
773
+ if (uninitialized || this.headerWritePromise) {
774
+ // Check PreMuxQueue limits
775
+ const maxPackets = this.options.maxMuxingQueueSize ?? MAX_MUXING_QUEUE_SIZE;
776
+ const dataThreshold = this.options.muxingQueueDataThreshold ?? MUXING_QUEUE_DATA_THRESHOLD;
777
+ const currentPackets = streamInfo.preMuxQueue.length;
778
+ const currentBytes = streamInfo.preMuxQueueDataSize;
779
+ const packetSize = clonedPacket.size;
780
+ const thresholdReached = currentBytes + packetSize > dataThreshold;
781
+ const effectiveMaxPackets = thresholdReached ? maxPackets : Number.MAX_SAFE_INTEGER;
782
+ // Check if we would exceed packet limit (only if threshold reached)
783
+ if (currentPackets >= effectiveMaxPackets) {
784
+ clonedPacket.free(); // Free the clone since we can't buffer it
785
+ throw new Error(
786
+ // eslint-disable-next-line @stylistic/max-len
787
+ `Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
788
+ }
789
+ // Buffer in PreMuxQueue (per-stream FIFO)
790
+ streamInfo.preMuxQueue.push(clonedPacket);
791
+ streamInfo.preMuxQueueDataSize += packetSize;
792
+ return; // Don't proceed to header write yet
793
+ }
794
+ // Automatically write header if not written yet
795
+ if (!this.headerWritten) {
796
+ this.headerWritePromise ??= (async () => {
797
+ this.startWriteWorker();
798
+ this.setupSyncQueues();
799
+ this.updateDefaultDisposition();
800
+ this.copyContainerMetadata();
801
+ const ret = await this.formatContext.writeHeader();
802
+ FFmpegError.throwIfError(ret, 'Failed to write header');
803
+ this.headerWritten = true;
804
+ // PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
805
+ // Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
806
+ await this.flushPreMuxQueues();
807
+ })();
808
+ await this.headerWritePromise;
809
+ if (this.headerWritten) {
810
+ this.headerWritePromise = undefined;
811
+ }
812
+ }
813
+ // PHASE 3: Write packet - normal muxing after header
814
+ if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
815
+ // Use SyncQueue for packet interleaving
816
+ // NOTE: Do NOT set clonedPacket.timeBase here!
817
+ // Packet must keep its source timebase (encoder timebase) so muxFixupTs can rescale correctly
818
+ // Send packet to sync queue
819
+ const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
820
+ // Handle errors from sq_send
821
+ if (ret < 0) {
822
+ if (ret === AVERROR_EOF) {
823
+ // Stream finished - this is normal, just return
824
+ return;
825
+ }
826
+ if (this.options.exitOnError) {
827
+ FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue');
828
+ }
829
+ return;
830
+ }
831
+ // Receive synchronized packets from queue and write to muxer
832
+ while (!this.isClosed) {
833
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
834
+ if (recvRet === AVERROR_EAGAIN) {
835
+ break; // No more packets ready
836
+ }
837
+ if (recvRet === AVERROR_EOF) {
838
+ break; // All streams finished
839
+ }
840
+ if (recvRet >= 0) {
841
+ // recvRet is the stream index
842
+ const recvStreamInfo = this._streams.get(recvRet);
843
+ // Clone packet before writing (muxer takes ownership and will unref it)
844
+ // We need to keep sqPacket alive for the next receive() call
845
+ const pkt = this.sqPacket.clone();
846
+ if (!pkt) {
847
+ throw new Error('Failed to clone packet from sync queue');
848
+ }
849
+ pkt.streamIndex = recvRet;
850
+ // Write packet (muxer takes ownership)
851
+ await this.write(pkt, recvStreamInfo, recvRet);
852
+ }
853
+ }
854
+ }
855
+ else {
856
+ // No sync queue needed - write directly
857
+ clonedPacket.streamIndex = streamIndex;
858
+ await this.write(clonedPacket, streamInfo, streamIndex);
859
+ }
860
+ }
861
+ /**
862
+ * Write a packet to the output synchronously.
863
+ * Synchronous version of writePacket.
864
+ *
865
+ * Writes muxed packet to the specified stream.
866
+ * Automatically handles:
867
+ * - Stream initialization on first packet (lazy initialization)
868
+ * - Codec parameter configuration from encoder or input stream
869
+ * - Header writing on first packet
870
+ * - Timestamp rescaling between source and output timebases
871
+ * - Sync queue for proper interleaving
872
+ *
873
+ * For encoder sources, the encoder must have processed at least one frame
874
+ * before packets can be written (encoder must be initialized).
875
+ *
876
+ * Uses FFmpeg CLI's sync queue pattern: buffers packets per stream and writes
877
+ * them in DTS order using av_compare_ts for timebase-aware comparison.
878
+ *
879
+ * To signal EOF for a stream, pass null as the packet.
880
+ * This tells the muxer that no more packets will be sent for this stream.
881
+ * The trailer is written only when close() is called.
882
+ *
883
+ * Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
884
+ *
885
+ * @param packet - Packet to write
886
+ *
887
+ * @param streamIndex - Target stream index
888
+ *
889
+ * @throws {Error} If stream invalid or encoder not initialized
890
+ *
891
+ * @throws {FFmpegError} If write fails
892
+ *
893
+ * @example
894
+ * ```typescript
895
+ * // Write encoded packet - header written automatically on first packet
896
+ * const packet = encoder.encodeSync(frame);
897
+ * if (packet) {
898
+ * output.writePacketSync(packet, videoIdx);
899
+ * packet.free();
900
+ * }
901
+ * ```
902
+ *
903
+ * @example
904
+ * ```typescript
905
+ * // Stream copy with packet processing
906
+ * for (const packet of input.packetsSync()) {
907
+ * if (packet.streamIndex === inputVideoIdx) {
908
+ * output.writePacketSync(packet, outputVideoIdx);
909
+ * }
910
+ * packet.free();
911
+ * }
912
+ * ```
913
+ *
914
+ * @see {@link writePacket} For async version
915
+ */
916
+ writePacketSync(packet, streamIndex) {
917
+ if (this.isClosed) {
918
+ throw new Error('Muxer is closed');
919
+ }
920
+ if (this.trailerWritten) {
921
+ throw new Error('Cannot write packets after output is finalized');
922
+ }
923
+ if (!this._streams.get(streamIndex)) {
924
+ throw new Error(`Invalid stream index: ${streamIndex}`);
925
+ }
926
+ // Initialize any encoder streams that are ready
927
+ for (const streamInfo of this._streams.values()) {
928
+ if (!streamInfo.initialized && streamInfo.encoder) {
929
+ const encoder = streamInfo.encoder;
930
+ const codecContext = encoder.getCodecContext();
931
+ // Skip if encoder not ready yet
932
+ if (!encoder.isEncoderInitialized || !codecContext) {
933
+ continue;
934
+ }
935
+ // This encoder is ready, initialize it now
936
+ // Read codecType from codecContext, not from stream (which is still uninitialized)
937
+ const codecType = codecContext.codecType;
938
+ // 1. Set stream timebase
939
+ // Use encoder's timebase unless user specified custom timebase
940
+ if (streamInfo.timeBase) {
941
+ // User specified custom timebase
942
+ streamInfo.outputStream.timeBase = new Rational(streamInfo.timeBase.num, streamInfo.timeBase.den);
943
+ }
944
+ else {
945
+ // Use encoder's timebase directly
946
+ // The encoder timebase is already set from the first frame in Encoder.initialize()
947
+ streamInfo.outputStream.timeBase = new Rational(codecContext.timeBase.num, codecContext.timeBase.den);
948
+ }
949
+ // 2. Set stream avg_frame_rate, r_frame_rate and sample_aspect_ratio
950
+ if (codecType === AVMEDIA_TYPE_VIDEO) {
951
+ const fr = codecContext.framerate;
952
+ streamInfo.outputStream.avgFrameRate = new Rational(fr.num, fr.den);
953
+ streamInfo.outputStream.rFrameRate = new Rational(fr.num, fr.den);
954
+ streamInfo.outputStream.sampleAspectRatio = codecContext.sampleAspectRatio;
955
+ }
956
+ // 3. Copy codec parameters from encoder context
957
+ const ret = streamInfo.outputStream.codecpar.fromContext(codecContext);
958
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
959
+ // 4. Copy metadata from input stream
960
+ if (streamInfo.inputStream) {
961
+ const metadata = streamInfo.inputStream.metadata;
962
+ if (metadata) {
963
+ streamInfo.outputStream.metadata = metadata;
964
+ }
965
+ // 5. Copy disposition from input stream
966
+ streamInfo.outputStream.disposition = streamInfo.inputStream.disposition;
967
+ // 6. Copy duration hint from input stream
968
+ if (streamInfo.inputStream.duration > 0n) {
969
+ const inputTb = streamInfo.inputStream.timeBase;
970
+ const outputTb = streamInfo.outputStream.timeBase;
971
+ const rescaledDuration = avRescaleQ(streamInfo.inputStream.duration, inputTb, outputTb);
972
+ streamInfo.outputStream.duration = rescaledDuration;
973
+ }
974
+ }
975
+ // Update the source timebase for timestamp rescaling
976
+ streamInfo.sourceTimeBase = codecContext.timeBase;
977
+ // Mark as initialized
978
+ streamInfo.initialized = true;
979
+ }
980
+ }
981
+ const streamInfo = this._streams.get(streamIndex);
982
+ // Handle NULL packet - signals EOF for this stream (FFmpeg pattern: av_interleaved_write_frame(s, NULL))
983
+ // FFmpeg's behavior:
984
+ // - If muxer not started (uninitialized streams), buffer NULL in PreMuxQueue as EOF marker
985
+ // - If muxer started, send NULL to SyncQueue to signal EOF and flush
986
+ if (packet === null) {
987
+ // Mark stream as EOF received
988
+ streamInfo.eofReceived = true;
989
+ // Check if any streams are still uninitialized (PreMuxQueue phase)
990
+ const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
991
+ // PHASE 1: Before muxer starts - buffer NULL packet in PreMuxQueue
992
+ // This matches FFmpeg's mux_queue_packet() which writes NULL to PreMuxQueue FIFO
993
+ if (uninitialized || this.headerWritePromise) {
994
+ // Buffer NULL as EOF marker (no size contribution)
995
+ streamInfo.preMuxQueue.push(null);
996
+ return;
997
+ }
998
+ // PHASE 2: After muxer started - send EOF to SyncQueue and flush
999
+ if (!this.headerWritten) {
1000
+ return;
1001
+ }
1002
+ // If using SyncQueue, send EOF for this stream
1003
+ if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
1004
+ // Send NULL to signal EOF to sync queue
1005
+ // Native side handles null correctly (sets sqframe.p = nullptr)
1006
+ const ret = this.syncQueue.send(streamInfo.sqIdxMux, null);
1007
+ if (ret < 0 && ret !== AVERROR_EOF) {
1008
+ if (this.options.exitOnError) {
1009
+ FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue');
1010
+ }
1011
+ }
1012
+ // Receive and write any remaining packets from sync queue
1013
+ while (!this.isClosed) {
1014
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
1015
+ if (recvRet === AVERROR_EAGAIN) {
1016
+ break; // No more packets ready
1017
+ }
1018
+ if (recvRet === AVERROR_EOF) {
1019
+ break; // All streams finished
1020
+ }
1021
+ if (recvRet >= 0) {
1022
+ const recvStreamInfo = this._streams.get(recvRet);
1023
+ const pkt = this.sqPacket.clone();
1024
+ if (!pkt) {
1025
+ throw new Error('Failed to clone packet from sync queue');
1026
+ }
1027
+ pkt.streamIndex = recvRet;
1028
+ this.writeSync(pkt, recvStreamInfo, recvRet);
1029
+ }
1030
+ }
1031
+ }
1032
+ return; // EOF signaled, nothing more to do
1033
+ }
1034
+ // Clone packet immediately - we will modify it and caller retains ownership
1035
+ const clonedPacket = packet.clone();
1036
+ if (!clonedPacket) {
1037
+ throw new Error('Failed to clone packet for writing');
1038
+ }
1039
+ // Apply streamcopy filtering BEFORE buffering
1040
+ // This ensures rejected packets never enter the queue/buffer
1041
+ if (streamInfo.isStreamCopy) {
1042
+ const shouldWrite = this.ofStreamcopy(clonedPacket, streamInfo, streamIndex);
1043
+ if (!shouldWrite) {
1044
+ clonedPacket.free(); // Free the clone since we won't use it
1045
+ return;
1046
+ }
1047
+ }
1048
+ // Check if any streams are still uninitialized
1049
+ const uninitialized = Array.from(this._streams.values()).some((s) => !s.initialized);
1050
+ // PHASE 1: Before header write - ALWAYS buffer in PreMuxQueue
1051
+ // PreMuxQueue is used during initialization phase ONLY (regardless of SyncQueue presence)
1052
+ // After header write, PreMuxQueue is flushed in DTS-sorted order
1053
+ if (uninitialized) {
1054
+ // Check PreMuxQueue limits
1055
+ const maxPackets = this.options.maxMuxingQueueSize ?? MAX_MUXING_QUEUE_SIZE;
1056
+ const dataThreshold = this.options.muxingQueueDataThreshold ?? MUXING_QUEUE_DATA_THRESHOLD;
1057
+ const currentPackets = streamInfo.preMuxQueue.length;
1058
+ const currentBytes = streamInfo.preMuxQueueDataSize;
1059
+ const packetSize = clonedPacket.size;
1060
+ const thresholdReached = currentBytes + packetSize > dataThreshold;
1061
+ const effectiveMaxPackets = thresholdReached ? maxPackets : Number.MAX_SAFE_INTEGER;
1062
+ // Check if we would exceed packet limit (only if threshold reached)
1063
+ if (currentPackets >= effectiveMaxPackets) {
1064
+ clonedPacket.free(); // Free the clone since we can't buffer it
1065
+ throw new Error(
1066
+ // eslint-disable-next-line @stylistic/max-len
1067
+ `Too many packets buffered for output stream ${streamIndex} (packets: ${currentPackets}, bytes: ${currentBytes}, threshold: ${dataThreshold}, max: ${maxPackets})`);
1068
+ }
1069
+ // Buffer in PreMuxQueue (per-stream FIFO)
1070
+ streamInfo.preMuxQueue.push(clonedPacket);
1071
+ streamInfo.preMuxQueueDataSize += packetSize;
1072
+ return; // Don't proceed to header write yet
1073
+ }
1074
+ // Automatically write header if not written yet
1075
+ if (!this.headerWritten) {
1076
+ this.setupSyncQueues();
1077
+ this.updateDefaultDisposition();
1078
+ this.copyContainerMetadata();
1079
+ const ret = this.formatContext.writeHeaderSync();
1080
+ FFmpegError.throwIfError(ret, 'Failed to write header');
1081
+ this.headerWritten = true;
1082
+ // PHASE 2: Flush PreMuxQueue in DTS-sorted order (once after header write)
1083
+ // Packets go: PreMuxQueue → SyncQueue (if present) → Muxer
1084
+ this.flushPreMuxQueuesSync();
1085
+ }
1086
+ // PHASE 3: Write packet - normal muxing after header
1087
+ if (this.syncQueue && streamInfo.sqIdxMux >= 0) {
1088
+ // Use SyncQueue for packet interleaving
1089
+ // NOTE: Do NOT set clonedPacket.timeBase here!
1090
+ // Packet must keep its source timebase (encoder timebase) so muxFixupTs can rescale correctly
1091
+ // Send packet to sync queue
1092
+ const ret = this.syncQueue.send(streamInfo.sqIdxMux, clonedPacket);
1093
+ // Handle errors from sq_send
1094
+ if (ret < 0) {
1095
+ if (ret === AVERROR_EOF) {
1096
+ // Stream finished - this is normal, just return
1097
+ return;
1098
+ }
1099
+ if (this.options.exitOnError) {
1100
+ FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue');
1101
+ }
1102
+ return;
1103
+ }
1104
+ // Receive synchronized packets from queue and write to muxer
1105
+ while (!this.isClosed) {
1106
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
1107
+ if (recvRet === AVERROR_EAGAIN) {
1108
+ break; // No more packets ready
1109
+ }
1110
+ if (recvRet === AVERROR_EOF) {
1111
+ break; // All streams finished
1112
+ }
1113
+ if (recvRet >= 0) {
1114
+ // recvRet is the stream index
1115
+ const recvStreamInfo = this._streams.get(recvRet);
1116
+ // Clone packet before writing (muxer takes ownership and will unref it)
1117
+ // We need to keep sqPacket alive for the next receive() call
1118
+ const pkt = this.sqPacket.clone();
1119
+ if (!pkt) {
1120
+ throw new Error('Failed to clone packet from sync queue');
1121
+ }
1122
+ pkt.streamIndex = recvRet;
1123
+ // Write packet (muxer takes ownership)
1124
+ this.writeSync(pkt, recvStreamInfo, recvRet);
1125
+ }
1126
+ }
1127
+ }
1128
+ else {
1129
+ // No sync queue needed - write directly
1130
+ clonedPacket.streamIndex = streamIndex;
1131
+ this.writeSync(clonedPacket, streamInfo, streamIndex);
1132
+ }
1133
+ }
1134
+ /**
1135
+ * Close muxer and free resources.
1136
+ *
1137
+ * Automatically writes trailer if header was written.
1138
+ * Closes the output file and releases all resources.
1139
+ * Safe to call multiple times.
1140
+ * Automatically called by Symbol.asyncDispose.
1141
+ *
1142
+ * @example
1143
+ * ```typescript
1144
+ * const output = await Muxer.open('output.mp4');
1145
+ * try {
1146
+ * // Use output - trailer written automatically on close
1147
+ * } finally {
1148
+ * await output.close();
1149
+ * }
1150
+ * ```
1151
+ *
1152
+ * @see {@link Symbol.asyncDispose} For automatic cleanup
1153
+ */
1154
+ async close() {
1155
+ if (this.isClosed) {
1156
+ return;
1157
+ }
1158
+ this.isClosed = true;
1159
+ // Close write queue and wait for worker to finish
1160
+ if (this.writeQueue) {
1161
+ this.writeQueue.close();
1162
+ await this.writeWorkerPromise;
1163
+ }
1164
+ // Free PreMuxQueue packets
1165
+ for (const streamInfo of this._streams.values()) {
1166
+ // Free any packets in PreMuxQueue
1167
+ for (const pkt of streamInfo.preMuxQueue) {
1168
+ pkt?.free();
1169
+ }
1170
+ streamInfo.preMuxQueue = [];
1171
+ }
1172
+ // Free sync queue resources
1173
+ if (this.sqPacket) {
1174
+ this.sqPacket.free();
1175
+ this.sqPacket = undefined;
1176
+ }
1177
+ if (this.syncQueue) {
1178
+ this.syncQueue.free();
1179
+ this.syncQueue = undefined;
1180
+ }
1181
+ // Try to write trailer if header was written but trailer wasn't
1182
+ try {
1183
+ if (this.headerWritten && !this.trailerWritten) {
1184
+ await this.formatContext.writeTrailer();
1185
+ this.trailerWritten = true;
1186
+ }
1187
+ }
1188
+ catch {
1189
+ // Ignore errors
1190
+ }
1191
+ // Clear pb reference first to prevent use-after-free
1192
+ if (this.ioContext) {
1193
+ this.formatContext.pb = null;
1194
+ }
1195
+ // Determine if this is custom IO before freeing format context
1196
+ const isCustomIO = this.formatContext.hasFlags(AVFMT_FLAG_CUSTOM_IO);
1197
+ // For file-based IO, close the file handle via closep
1198
+ // For custom IO, the context will be freed below
1199
+ if (this.ioContext && !isCustomIO) {
1200
+ try {
1201
+ await this.ioContext.closep();
1202
+ }
1203
+ catch {
1204
+ // Ignore errors
1205
+ }
1206
+ }
1207
+ // Free format context
1208
+ if (this.formatContext) {
1209
+ try {
1210
+ this.formatContext.freeContext();
1211
+ }
1212
+ catch {
1213
+ // Ignore errors
1214
+ }
1215
+ }
1216
+ // Now free custom IO context if present
1217
+ if (this.ioContext && isCustomIO) {
1218
+ try {
1219
+ this.ioContext.freeContext();
1220
+ }
1221
+ catch {
1222
+ // Ignore errors
1223
+ }
1224
+ }
1225
+ }
1226
+ /**
1227
+ * Close muxer and free resources synchronously.
1228
+ * Synchronous version of close.
1229
+ *
1230
+ * Automatically writes trailer if header was written.
1231
+ * Closes the output file and releases all resources.
1232
+ * Safe to call multiple times.
1233
+ * Automatically called by Symbol.dispose.
1234
+ *
1235
+ * @example
1236
+ * ```typescript
1237
+ * const output = Muxer.openSync('output.mp4');
1238
+ * try {
1239
+ * // Use output - trailer written automatically on close
1240
+ * } finally {
1241
+ * output.closeSync();
1242
+ * }
1243
+ * ```
1244
+ *
1245
+ * @see {@link close} For async version
1246
+ */
1247
+ closeSync() {
1248
+ if (this.isClosed) {
1249
+ return;
1250
+ }
1251
+ this.isClosed = true;
1252
+ // Free PreMuxQueue packets
1253
+ for (const streamInfo of this._streams.values()) {
1254
+ // Free any packets in PreMuxQueue
1255
+ for (const pkt of streamInfo.preMuxQueue) {
1256
+ pkt?.free();
1257
+ }
1258
+ streamInfo.preMuxQueue = [];
1259
+ }
1260
+ // Free sync queue resources
1261
+ if (this.sqPacket) {
1262
+ this.sqPacket.free();
1263
+ this.sqPacket = undefined;
1264
+ }
1265
+ if (this.syncQueue) {
1266
+ this.syncQueue.free();
1267
+ this.syncQueue = undefined;
1268
+ }
1269
+ // Try to write trailer if header was written but trailer wasn't
1270
+ try {
1271
+ if (this.headerWritten && !this.trailerWritten) {
1272
+ this.formatContext.writeTrailerSync();
1273
+ this.trailerWritten = true;
1274
+ }
1275
+ }
1276
+ catch {
1277
+ // Ignore errors
1278
+ }
1279
+ // Clear pb reference first to prevent use-after-free
1280
+ if (this.ioContext) {
1281
+ this.formatContext.pb = null;
1282
+ }
1283
+ // Determine if this is custom IO before freeing format context
1284
+ const isCustomIO = this.formatContext.hasFlags(AVFMT_FLAG_CUSTOM_IO);
1285
+ // For file-based IO, close the file handle via closep
1286
+ // For custom IO, the context will be freed below
1287
+ if (this.ioContext && !isCustomIO) {
1288
+ try {
1289
+ this.ioContext.closepSync();
1290
+ }
1291
+ catch {
1292
+ // Ignore errors
1293
+ }
1294
+ }
1295
+ // Free format context
1296
+ if (this.formatContext) {
1297
+ try {
1298
+ this.formatContext.freeContext();
1299
+ }
1300
+ catch {
1301
+ // Ignore errors
1302
+ }
1303
+ }
1304
+ // Now free custom IO context if present
1305
+ if (this.ioContext && isCustomIO) {
1306
+ try {
1307
+ this.ioContext.freeContext();
1308
+ }
1309
+ catch {
1310
+ // Ignore errors
1311
+ }
1312
+ }
1313
+ }
1314
+ /**
1315
+ * Get underlying format context.
1316
+ *
1317
+ * Returns the internal format context for advanced operations.
1318
+ *
1319
+ * @returns Format context
1320
+ *
1321
+ * @internal
1322
+ */
1323
+ getFormatContext() {
1324
+ return this.formatContext;
1325
+ }
1326
+ /**
1327
+ * Setup sync queues based on stream configuration.
1328
+ *
1329
+ * Called before writing header.
1330
+ * Muxing sync queue is created only if nb_interleaved > nb_av_enc
1331
+ * (i.e., when there are streamcopy streams).
1332
+ *
1333
+ * All streams are added as non-limiting (FFmpeg default without -shortest),
1334
+ * which means no timestamp-based synchronization - frames are output immediately.
1335
+ *
1336
+ * @internal
1337
+ */
1338
+ setupSyncQueues() {
1339
+ const nbInterleaved = this._streams.size; // All streams are interleaved (no attachments)
1340
+ const nbAvEnc = Array.from(this._streams.values()).filter((s) => !s.isStreamCopy).length;
1341
+ // FFmpeg's condition: if there are streamcopy streams (nb_interleaved > nb_av_enc),
1342
+ // then ALL streams use the sync queue (but as non-limiting, so no actual sync happens)
1343
+ const needsSyncQueue = this.options.useSyncQueue && nbInterleaved > nbAvEnc;
1344
+ if (needsSyncQueue && !this.syncQueue) {
1345
+ // Create sync queue
1346
+ const bufDurationSec = this.options.syncQueueBufferDuration ?? SYNC_BUFFER_DURATION;
1347
+ const bufSizeUs = bufDurationSec * 1000000; // Convert to microseconds
1348
+ this.syncQueue = SyncQueue.create(SyncQueueType.PACKETS, bufSizeUs);
1349
+ this.sqPacket = new Packet();
1350
+ this.sqPacket.alloc();
1351
+ // Add all streams to sync queue
1352
+ // FFmpeg standard (without -shortest): limiting = 0 (non-limiting)
1353
+ // This means frames are output immediately without synchronization
1354
+ for (const streamInfo of this._streams.values()) {
1355
+ streamInfo.sqIdxMux = this.syncQueue.addStream(0); // 0 = non-limiting
1356
+ }
1357
+ }
1358
+ else if (!needsSyncQueue && this.syncQueue) {
1359
+ // Free sync queue if we don't need it anymore
1360
+ this.sqPacket?.free();
1361
+ this.sqPacket = undefined;
1362
+ this.syncQueue.free();
1363
+ this.syncQueue = undefined;
1364
+ // Reset all sqIdxMux to -1
1365
+ for (const streamInfo of this._streams.values()) {
1366
+ streamInfo.sqIdxMux = -1;
1367
+ }
1368
+ }
1369
+ }
1370
+ /**
1371
+ * Flush all PreMuxQueues in DTS-sorted order.
1372
+ *
1373
+ * Implements FFmpeg's PreMuxQueue flush algorithm from mux_task_start().
1374
+ * Repeatedly finds the stream with the earliest DTS packet and sends it:
1375
+ * - WITH SyncQueue: Sends to SyncQueue for interleaving
1376
+ * - WITHOUT SyncQueue: Writes directly to muxer
1377
+ * NULL packets (EOF markers) and packets with AV_NOPTS_VALUE have priority (sent first).
1378
+ *
1379
+ * @internal
1380
+ */
1381
+ async flushPreMuxQueues() {
1382
+ while (true) {
1383
+ let minStreamInfo = null;
1384
+ let minStreamIndex = -1;
1385
+ let minDts = AV_NOPTS_VALUE;
1386
+ let minTimeBase = { num: 1, den: 1 };
1387
+ // 1. Find stream with earliest DTS across all PreMuxQueues
1388
+ // FFmpeg logic: NULL packets and AV_NOPTS_VALUE packets have priority
1389
+ for (const [streamIndex, streamInfo] of this._streams) {
1390
+ if (streamInfo.preMuxQueue.length === 0) {
1391
+ continue;
1392
+ }
1393
+ const pkt = streamInfo.preMuxQueue[0]; // Peek at first packet (can be null)
1394
+ // NULL packets (EOF markers) have highest priority (FFmpeg: if (!pkt) -> priority)
1395
+ // Packets with AV_NOPTS_VALUE also have priority
1396
+ if (!pkt || pkt.dts === AV_NOPTS_VALUE) {
1397
+ minStreamInfo = streamInfo;
1398
+ minStreamIndex = streamIndex;
1399
+ break;
1400
+ }
1401
+ // Compare DTS with current minimum
1402
+ if (minDts === AV_NOPTS_VALUE || avCompareTs(pkt.dts, pkt.timeBase, minDts, minTimeBase) < 0) {
1403
+ minStreamInfo = streamInfo;
1404
+ minStreamIndex = streamIndex;
1405
+ minDts = pkt.dts;
1406
+ minTimeBase = pkt.timeBase;
1407
+ }
1408
+ }
1409
+ // 2. No more packets - all queues empty
1410
+ if (!minStreamInfo) {
1411
+ break;
1412
+ }
1413
+ // 3. Take packet from stream with earliest DTS (or NULL for EOF)
1414
+ const pkt = minStreamInfo.preMuxQueue.shift();
1415
+ // 4. Handle NULL packet (EOF marker)
1416
+ // FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
1417
+ if (!pkt) {
1418
+ // Signal EOF to SyncQueue for this stream
1419
+ if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
1420
+ const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, null);
1421
+ if (ret < 0 && ret !== AVERROR_EOF) {
1422
+ if (this.options.exitOnError) {
1423
+ FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue during PreMuxQueue flush');
1424
+ }
1425
+ }
1426
+ }
1427
+ // If not using SyncQueue, nothing to do - stream finished without data
1428
+ continue;
1429
+ }
1430
+ // 5. Normal packet - update data size and send
1431
+ minStreamInfo.preMuxQueueDataSize -= pkt.size;
1432
+ // 6. Send to SyncQueue or write directly
1433
+ pkt.streamIndex = minStreamIndex;
1434
+ if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
1435
+ // Send to SyncQueue for interleaving
1436
+ // NOTE: Do NOT set pkt.timeBase here!
1437
+ // Packet must keep its source timebase so muxFixupTs can rescale correctly
1438
+ // pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
1439
+ const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
1440
+ if (ret < 0 && ret !== AVERROR_EOF) {
1441
+ if (this.options.exitOnError) {
1442
+ FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
1443
+ }
1444
+ }
1445
+ }
1446
+ else {
1447
+ // Write directly to muxer
1448
+ await this.write(pkt, minStreamInfo, minStreamIndex);
1449
+ }
1450
+ }
1451
+ // If using SyncQueue, receive and write all interleaved packets
1452
+ if (this.syncQueue) {
1453
+ while (!this.isClosed) {
1454
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
1455
+ if (recvRet === AVERROR_EAGAIN) {
1456
+ break; // No more packets ready
1457
+ }
1458
+ if (recvRet === AVERROR_EOF) {
1459
+ break; // All streams finished
1460
+ }
1461
+ if (recvRet >= 0) {
1462
+ // recvRet is the stream index
1463
+ const recvStreamInfo = this._streams.get(recvRet);
1464
+ // Clone packet before writing (muxer takes ownership and will unref it)
1465
+ const pkt = this.sqPacket.clone();
1466
+ if (!pkt) {
1467
+ throw new Error('Failed to clone packet from sync queue during PreMuxQueue flush');
1468
+ }
1469
+ pkt.streamIndex = recvRet;
1470
+ // Write packet (muxer takes ownership)
1471
+ await this.write(pkt, recvStreamInfo, recvRet);
1472
+ }
1473
+ }
1474
+ }
1475
+ }
1476
+ /**
1477
+ * Flush all PreMuxQueues in DTS-sorted order (synchronous version).
1478
+ *
1479
+ * Implements FFmpeg's PreMuxQueue flush algorithm from mux_task_start().
1480
+ * Repeatedly finds the stream with the earliest DTS packet and sends it:
1481
+ * - WITH SyncQueue: Sends to SyncQueue for interleaving
1482
+ * - WITHOUT SyncQueue: Writes directly to muxer
1483
+ * NULL packets (EOF markers) and packets with AV_NOPTS_VALUE have priority (sent first).
1484
+ *
1485
+ * @internal
1486
+ */
1487
+ flushPreMuxQueuesSync() {
1488
+ while (true) {
1489
+ let minStreamInfo = null;
1490
+ let minStreamIndex = -1;
1491
+ let minDts = AV_NOPTS_VALUE;
1492
+ let minTimeBase = { num: 1, den: 1 };
1493
+ // 1. Find stream with earliest DTS across all PreMuxQueues
1494
+ // FFmpeg logic: NULL packets and AV_NOPTS_VALUE packets have priority
1495
+ for (const [streamIndex, streamInfo] of this._streams) {
1496
+ if (streamInfo.preMuxQueue.length === 0)
1497
+ continue;
1498
+ const pkt = streamInfo.preMuxQueue[0]; // Peek at first packet (can be null)
1499
+ // NULL packets (EOF markers) have highest priority (FFmpeg: if (!pkt) -> priority)
1500
+ // Packets with AV_NOPTS_VALUE also have priority
1501
+ if (!pkt || pkt.dts === AV_NOPTS_VALUE) {
1502
+ minStreamInfo = streamInfo;
1503
+ minStreamIndex = streamIndex;
1504
+ break;
1505
+ }
1506
+ // Compare DTS with current minimum
1507
+ if (minDts === AV_NOPTS_VALUE || avCompareTs(pkt.dts, pkt.timeBase, minDts, minTimeBase) < 0) {
1508
+ minStreamInfo = streamInfo;
1509
+ minStreamIndex = streamIndex;
1510
+ minDts = pkt.dts;
1511
+ minTimeBase = pkt.timeBase;
1512
+ }
1513
+ }
1514
+ // 2. No more packets - all queues empty
1515
+ if (!minStreamInfo)
1516
+ break;
1517
+ // 3. Take packet from stream with earliest DTS (or NULL for EOF)
1518
+ const pkt = minStreamInfo.preMuxQueue.shift();
1519
+ // 4. Handle NULL packet (EOF marker)
1520
+ // FFmpeg: if (pkt) { send packet } else { tq_send_finish() }
1521
+ if (!pkt) {
1522
+ // Signal EOF to SyncQueue for this stream
1523
+ if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
1524
+ const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, null);
1525
+ if (ret < 0 && ret !== AVERROR_EOF) {
1526
+ if (this.options.exitOnError) {
1527
+ FFmpegError.throwIfError(ret, 'Failed to send EOF to sync queue during PreMuxQueue flush');
1528
+ }
1529
+ }
1530
+ }
1531
+ // If not using SyncQueue, nothing to do - stream finished without data
1532
+ continue;
1533
+ }
1534
+ // 5. Normal packet - update data size and send
1535
+ minStreamInfo.preMuxQueueDataSize -= pkt.size;
1536
+ // 6. Send to SyncQueue or write directly
1537
+ pkt.streamIndex = minStreamIndex;
1538
+ if (this.syncQueue && minStreamInfo.sqIdxMux >= 0) {
1539
+ // Send to SyncQueue for interleaving
1540
+ // NOTE: Do NOT set pkt.timeBase here!
1541
+ // Packet must keep its source timebase so muxFixupTs can rescale correctly
1542
+ // pkt.timeBase = minStreamInfo.stream.timeBase; // ❌ WRONG!
1543
+ const ret = this.syncQueue.send(minStreamInfo.sqIdxMux, pkt);
1544
+ if (ret < 0 && ret !== AVERROR_EOF) {
1545
+ if (this.options.exitOnError) {
1546
+ FFmpegError.throwIfError(ret, 'Failed to send packet to sync queue during PreMuxQueue flush');
1547
+ }
1548
+ }
1549
+ }
1550
+ else {
1551
+ // Write directly to muxer
1552
+ this.writeSync(pkt, minStreamInfo, minStreamIndex);
1553
+ }
1554
+ }
1555
+ // If using SyncQueue, receive and write all interleaved packets
1556
+ if (this.syncQueue) {
1557
+ while (!this.isClosed) {
1558
+ const recvRet = this.syncQueue.receive(-1, this.sqPacket);
1559
+ if (recvRet === AVERROR_EAGAIN) {
1560
+ break; // No more packets ready
1561
+ }
1562
+ if (recvRet === AVERROR_EOF) {
1563
+ break; // All streams finished
1564
+ }
1565
+ if (recvRet >= 0) {
1566
+ // recvRet is the stream index
1567
+ const recvStreamInfo = this._streams.get(recvRet);
1568
+ // Clone packet before writing (muxer takes ownership and will unref it)
1569
+ const pkt = this.sqPacket.clone();
1570
+ if (!pkt) {
1571
+ throw new Error('Failed to clone packet from sync queue during PreMuxQueue flush');
1572
+ }
1573
+ pkt.streamIndex = recvRet;
1574
+ // Write packet (muxer takes ownership)
1575
+ this.writeSync(pkt, recvStreamInfo, recvRet);
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+ /**
1581
+ * Write a packet to the output.
1582
+ *
1583
+ * @param pkt - Packet to write
1584
+ *
1585
+ * @param streamInfo - Stream description
1586
+ *
1587
+ * @param streamIndex - Stream index
1588
+ *
1589
+ * @internal
1590
+ */
1591
+ async write(pkt, streamInfo, streamIndex) {
1592
+ if (this.writeQueue) {
1593
+ // Use async queue for serialized writes
1594
+ await this.writeQueue.send({ pkt, streamInfo, streamIndex });
1595
+ }
1596
+ else {
1597
+ // Direct write without serialization
1598
+ await this.writeInternal(pkt, streamInfo, streamIndex);
1599
+ }
1600
+ }
1601
+ /**
1602
+ * Internal write implementation.
1603
+ * Called either directly or through the write worker.
1604
+ *
1605
+ * @param pkt - Packet to write
1606
+ *
1607
+ * @param streamInfo - Stream description
1608
+ *
1609
+ * @param streamIndex - Stream index
1610
+ *
1611
+ * @internal
1612
+ */
1613
+ async writeInternal(pkt, streamInfo, streamIndex) {
1614
+ // Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
1615
+ this.muxFixupTs(pkt, streamInfo, streamIndex);
1616
+ // Write the packet (muxer takes ownership and will unref it)
1617
+ // NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
1618
+ const ret = await this.formatContext.interleavedWriteFrame(pkt);
1619
+ // Handle write errors
1620
+ if (ret < 0 && ret !== AVERROR_EOF) {
1621
+ if (this.options.exitOnError) {
1622
+ FFmpegError.throwIfError(ret, 'Failed to write packet');
1623
+ }
1624
+ }
1625
+ }
1626
+ /**
1627
+ * Start background worker for async write queue.
1628
+ * Processes write jobs sequentially to prevent race conditions.
1629
+ *
1630
+ * @internal
1631
+ */
1632
+ startWriteWorker() {
1633
+ if (!this.options.useAsyncWrite || this._streams.size <= 1) {
1634
+ return;
1635
+ }
1636
+ this.writeQueue ??= new AsyncQueue(1); // size=1 for strict serialization
1637
+ this.writeWorkerPromise ??= (async () => {
1638
+ while (true) {
1639
+ const job = await this.writeQueue.receive();
1640
+ if (!job)
1641
+ break; // Queue closed
1642
+ await this.writeInternal(job.pkt, job.streamInfo, job.streamIndex);
1643
+ }
1644
+ })();
1645
+ }
1646
+ /**
1647
+ * Write a packet to the output synchronously.
1648
+ * Synchronous version of write.
1649
+ *
1650
+ * @param pkt - Packet to write
1651
+ *
1652
+ * @param streamInfo - Stream description
1653
+ *
1654
+ * @param streamIndex - Stream index
1655
+ *
1656
+ * @internal
1657
+ */
1658
+ writeSync(pkt, streamInfo, streamIndex) {
1659
+ // Fix timestamps (rescale, DTS>PTS fix, monotonic DTS enforcement)
1660
+ this.muxFixupTs(pkt, streamInfo, streamIndex);
1661
+ // Write the packet (muxer takes ownership and will unref it)
1662
+ // NOTE: Caller must clone packet if they need to keep it (e.g., for SyncQueue)
1663
+ const ret = this.formatContext.interleavedWriteFrameSync(pkt);
1664
+ FFmpegError.throwIfError(ret, 'Failed to write packet');
1665
+ }
1666
+ /**
1667
+ * Streamcopy packet filtering and timestamp offset.
1668
+ *
1669
+ * Applies streamcopy-specific logic before muxing:
1670
+ * 1. Recording time limit check
1671
+ * 2. Skip non-keyframe packets at start (unless copyInitialNonkeyframes)
1672
+ * 3. Skip packets before ts_copy_start (unless copyPriorStart)
1673
+ * 4. Skip packets before startTime
1674
+ * 5. Apply start_time timestamp offset
1675
+ *
1676
+ * @param pkt - Packet to process
1677
+ *
1678
+ * @param streamInfo - Stream description
1679
+ *
1680
+ * @param streamIndex - Stream index
1681
+ *
1682
+ * @returns true if packet should be written, false if packet should be skipped
1683
+ *
1684
+ * @throws {Error} If recording time limit reached
1685
+ *
1686
+ * @internal
1687
+ */
1688
+ ofStreamcopy(pkt, streamInfo, streamIndex) {
1689
+ const outputStream = this.formatContext.streams[streamIndex];
1690
+ if (!outputStream) {
1691
+ return false;
1692
+ }
1693
+ // Get DTS in AV_TIME_BASE for comparison
1694
+ // Use packet DTS directly
1695
+ const dts = pkt.dts !== AV_NOPTS_VALUE ? avRescaleQ(pkt.dts, pkt.timeBase, AV_TIME_BASE_Q) : AV_NOPTS_VALUE;
1696
+ const startTimeUs = this.options.startTime !== undefined ? BigInt(Math.floor(this.options.startTime * 1000000)) : AV_NOPTS_VALUE;
1697
+ // 1. Skip non-keyframes at start
1698
+ const copyInitialNonkeyframes = this.options.copyInitialNonkeyframes ?? false;
1699
+ if (!streamInfo.streamcopyStarted && !pkt.isKeyframe && !copyInitialNonkeyframes) {
1700
+ return false; // skip packet
1701
+ }
1702
+ // 2. Copy from specific start point
1703
+ if (!streamInfo.streamcopyStarted) {
1704
+ const copyPriorStart = this.options.copyPriorStart ?? -1;
1705
+ // Calculate ts_copy_start
1706
+ // Since we don't have input file timestamps, ts_copy_start is simply startTime or 0
1707
+ const tsCopyStart = startTimeUs !== AV_NOPTS_VALUE ? startTimeUs : 0n;
1708
+ // Only check ts_copy_start if copyPriorStart is not set (0 or -1)
1709
+ if (copyPriorStart !== 1 && tsCopyStart > 0n) {
1710
+ const pktTsUs = pkt.pts !== AV_NOPTS_VALUE ? avRescaleQ(pkt.pts, pkt.timeBase, AV_TIME_BASE_Q) : dts;
1711
+ if (pktTsUs !== AV_NOPTS_VALUE && pktTsUs < tsCopyStart) {
1712
+ return false; // skip packet
1713
+ }
1714
+ }
1715
+ // 3. Skip packets before startTime
1716
+ if (startTimeUs !== AV_NOPTS_VALUE && dts !== AV_NOPTS_VALUE && dts < startTimeUs) {
1717
+ return false; // skip packet
1718
+ }
1719
+ }
1720
+ // 4. Apply start_time timestamp offset
1721
+ // FFmpeg uses: start_time = (of->start_time == AV_NOPTS_VALUE) ? 0 : of->start_time
1722
+ const startForOffset = startTimeUs !== AV_NOPTS_VALUE ? startTimeUs : 0n;
1723
+ const tsOffset = avRescaleQ(startForOffset, AV_TIME_BASE_Q, pkt.timeBase);
1724
+ if (pkt.pts !== AV_NOPTS_VALUE) {
1725
+ pkt.pts -= tsOffset;
1726
+ }
1727
+ if (pkt.dts === AV_NOPTS_VALUE) {
1728
+ // If DTS missing, use our estimated DTS
1729
+ if (dts !== AV_NOPTS_VALUE) {
1730
+ pkt.dts = avRescaleQ(dts, AV_TIME_BASE_Q, pkt.timeBase);
1731
+ }
1732
+ }
1733
+ else if (outputStream.codecpar.codecType === AVMEDIA_TYPE_AUDIO) {
1734
+ // Audio: PTS = DTS - ts_offset
1735
+ pkt.pts = pkt.dts - tsOffset;
1736
+ }
1737
+ if (pkt.dts !== AV_NOPTS_VALUE) {
1738
+ pkt.dts -= tsOffset;
1739
+ }
1740
+ // Mark streamcopy as started
1741
+ streamInfo.streamcopyStarted = true;
1742
+ return true; // Packet should be written
1743
+ }
1744
+ /**
1745
+ * Fix packet timestamps before muxing.
1746
+ *
1747
+ * Performs timestamp corrections:
1748
+ * 1. Rescales timestamps to output timebase (av_rescale_delta for audio streamcopy)
1749
+ * 2. Sets pkt.timeBase to output stream timebase
1750
+ * 3. Fixes invalid DTS > PTS relationships
1751
+ * 4. Enforces monotonic DTS (never decreasing)
1752
+ *
1753
+ * @param pkt - Packet to fix
1754
+ *
1755
+ * @param streamInfo - Stream description
1756
+ *
1757
+ * @param streamIndex - Stream index
1758
+ *
1759
+ * @internal
1760
+ */
1761
+ muxFixupTs(pkt, streamInfo, streamIndex) {
1762
+ const outputStream = this.formatContext.streams[streamIndex];
1763
+ if (!outputStream)
1764
+ return;
1765
+ const codecType = streamInfo.outputStream.codecpar.codecType;
1766
+ const dstTb = outputStream.timeBase;
1767
+ // const srcTb = streamInfo.sourceTimeBase!;
1768
+ // Check if timestamps are valid before rescaling
1769
+ // FFmpeg's av_rescale_q/av_rescale_delta don't accept AV_NOPTS_VALUE
1770
+ if (pkt.dts === AV_NOPTS_VALUE && pkt.pts === AV_NOPTS_VALUE) {
1771
+ // Set packet timebase anyway for muxer
1772
+ pkt.timeBase = dstTb;
1773
+ return;
1774
+ }
1775
+ // 1. Rescale timestamps to the stream timebase
1776
+ if (codecType === AVMEDIA_TYPE_AUDIO && streamInfo.isStreamCopy) {
1777
+ let duration = avGetAudioFrameDuration2(streamInfo.outputStream.codecpar, pkt.size);
1778
+ if (!duration) {
1779
+ duration = streamInfo.outputStream.codecpar.frameSize;
1780
+ }
1781
+ const srcTb = streamInfo.sourceTimeBase;
1782
+ const sampleRate = streamInfo.outputStream.codecpar.sampleRate;
1783
+ const fsTb = { num: 1, den: sampleRate };
1784
+ pkt.dts = avRescaleDelta(srcTb, pkt.dts, fsTb, duration, streamInfo.tsRescaleDeltaLast, dstTb);
1785
+ pkt.pts = pkt.dts;
1786
+ pkt.duration = avRescaleQ(pkt.duration, srcTb, dstTb);
1787
+ }
1788
+ else {
1789
+ // For video or encoded audio, use regular rescaling
1790
+ const srcTb = streamInfo.sourceTimeBase;
1791
+ pkt.rescaleTs(srcTb, dstTb);
1792
+ }
1793
+ // 2. Set packet timeBase
1794
+ // av_interleaved_write_frame uses this for sorting!
1795
+ pkt.timeBase = dstTb;
1796
+ // 3. Fix DTS > PTS (invalid relationship)
1797
+ // FFmpeg formula: median of (pts, dts, last_mux_dts+1)
1798
+ if (pkt.dts !== AV_NOPTS_VALUE && pkt.pts !== AV_NOPTS_VALUE && pkt.dts > pkt.pts) {
1799
+ const last = streamInfo.lastMuxDts !== AV_NOPTS_VALUE ? streamInfo.lastMuxDts + 1n : 0n;
1800
+ const min = pkt.pts < pkt.dts ? (pkt.pts < last ? pkt.pts : last) : pkt.dts < last ? pkt.dts : last;
1801
+ const max = pkt.pts > pkt.dts ? (pkt.pts > last ? pkt.pts : last) : pkt.dts > last ? pkt.dts : last;
1802
+ const median = pkt.pts + pkt.dts + last - min - max;
1803
+ pkt.pts = median;
1804
+ pkt.dts = median;
1805
+ }
1806
+ // 4. Enforce monotonic DTS
1807
+ if ((codecType === AVMEDIA_TYPE_AUDIO || codecType === AVMEDIA_TYPE_VIDEO) && pkt.dts !== AV_NOPTS_VALUE && streamInfo.lastMuxDts !== AV_NOPTS_VALUE) {
1808
+ // FFmpeg: max = last_mux_dts + !(oformat->flags & AVFMT_TS_NONSTRICT)
1809
+ // AVFMT_TS_NONSTRICT allows non-strict monotonic timestamps (equal DTS is OK)
1810
+ const tsNonStrict = this.formatContext.oformat?.hasFlags(AVFMT_TS_NONSTRICT) ?? false;
1811
+ const max = streamInfo.lastMuxDts + (tsNonStrict ? 0n : 1n);
1812
+ if (pkt.dts < max) {
1813
+ // Adjust PTS if it would create invalid relationship
1814
+ if (pkt.pts !== AV_NOPTS_VALUE && pkt.pts >= pkt.dts) {
1815
+ pkt.pts = pkt.pts > max ? pkt.pts : max;
1816
+ }
1817
+ pkt.dts = max;
1818
+ }
1819
+ }
1820
+ // 5. Update last mux DTS for next packet
1821
+ streamInfo.lastMuxDts = pkt.dts;
1822
+ }
1823
+ /**
1824
+ * Copy container metadata from input to output.
1825
+ *
1826
+ * Automatically copies global metadata from input Demuxer to output format context.
1827
+ * Only copies once (on first call). Removes duration/creation_time metadata.
1828
+ *
1829
+ * @internal
1830
+ */
1831
+ copyContainerMetadata() {
1832
+ if (this.containerMetadataCopied || !this.options.input) {
1833
+ return;
1834
+ }
1835
+ const mediaInput = 'input' in this.options.input ? this.options.input.input : this.options.input;
1836
+ const inputFormatContext = mediaInput.getFormatContext();
1837
+ const inputMetadata = inputFormatContext.metadata;
1838
+ if (inputMetadata) {
1839
+ // Keys that FFmpeg removes after copying
1840
+ const keysToSkip = new Set(['duration', 'creation_time', 'company_name', 'product_name', 'product_version']);
1841
+ // Get all input metadata entries
1842
+ const entries = inputMetadata.getAll();
1843
+ // Filter out keys that should be skipped
1844
+ const filteredEntries = {};
1845
+ for (const [key, value] of Object.entries(entries)) {
1846
+ if (!keysToSkip.has(key)) {
1847
+ filteredEntries[key] = value;
1848
+ }
1849
+ }
1850
+ // Create new dictionary with filtered entries
1851
+ const metadata = Dictionary.fromObject(filteredEntries);
1852
+ // Set metadata to format context
1853
+ // This will copy the dictionary content via av_dict_copy
1854
+ this.formatContext.metadata = metadata;
1855
+ }
1856
+ this.containerMetadataCopied = true;
1857
+ }
1858
+ /**
1859
+ * Auto-set DEFAULT disposition for first stream of each type.
1860
+ *
1861
+ * FFmpeg automatically sets DEFAULT flag for the first stream of each type
1862
+ * if no stream of that type has DEFAULT set yet.
1863
+ *
1864
+ * @internal
1865
+ */
1866
+ updateDefaultDisposition() {
1867
+ // Group streams by media type
1868
+ const streamsByType = new Map();
1869
+ for (const streamInfo of this._streams.values()) {
1870
+ const codecType = streamInfo.outputStream.codecpar.codecType;
1871
+ if (!streamsByType.has(codecType)) {
1872
+ streamsByType.set(codecType, []);
1873
+ }
1874
+ streamsByType.get(codecType).push(streamInfo.outputStream);
1875
+ }
1876
+ // For each media type, check if any stream has DEFAULT disposition
1877
+ // If not, set DEFAULT on first stream
1878
+ for (const [_, streams] of streamsByType.entries()) {
1879
+ // Skip if only one stream of this type
1880
+ if (streams.length < 2) {
1881
+ continue;
1882
+ }
1883
+ // Check if any stream already has DEFAULT disposition
1884
+ const hasDefault = streams.some((s) => s.hasDisposition(AV_DISPOSITION_DEFAULT));
1885
+ if (!hasDefault) {
1886
+ // Find first stream that is not an attached picture
1887
+ const firstNonAttachedPic = streams.find((s) => !s.hasDisposition(AV_DISPOSITION_ATTACHED_PIC));
1888
+ if (firstNonAttachedPic) {
1889
+ // Set DEFAULT on first non-attached-picture stream
1890
+ firstNonAttachedPic.setDisposition(AV_DISPOSITION_DEFAULT);
1891
+ }
1892
+ }
1893
+ }
1894
+ }
1895
+ /**
1896
+ * Dispose of muxer.
1897
+ *
1898
+ * Implements AsyncDisposable interface for automatic cleanup.
1899
+ * Equivalent to calling close().
1900
+ *
1901
+ * @example
1902
+ * ```typescript
1903
+ * {
1904
+ * await using output = await Muxer.open('output.mp4');
1905
+ * // Use output...
1906
+ * } // Automatically closed
1907
+ * ```
1908
+ *
1909
+ * @see {@link close} For manual cleanup
1910
+ */
1911
+ async [Symbol.asyncDispose]() {
1912
+ await this.close();
1913
+ }
1914
+ /**
1915
+ * Dispose of muxer synchronously.
1916
+ *
1917
+ * Implements Disposable interface for automatic cleanup.
1918
+ * Equivalent to calling closeSync().
1919
+ *
1920
+ * @example
1921
+ * ```typescript
1922
+ * {
1923
+ * using output = Muxer.openSync('output.mp4');
1924
+ * // Use output...
1925
+ * } // Automatically closed
1926
+ * ```
1927
+ *
1928
+ * @see {@link closeSync} For manual cleanup
1929
+ */
1930
+ [Symbol.dispose]() {
1931
+ this.closeSync();
1932
+ }
1933
+ }
1934
+ //# sourceMappingURL=muxer.js.map