node-av-light 0.0.0 → 7.1.0-beta.1

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