node-av 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +47 -40
  2. package/binding.gyp +12 -0
  3. package/dist/api/bitstream-filter.d.ts +134 -2
  4. package/dist/api/bitstream-filter.js +200 -2
  5. package/dist/api/bitstream-filter.js.map +1 -1
  6. package/dist/api/decoder.d.ts +261 -105
  7. package/dist/api/decoder.js +384 -171
  8. package/dist/api/decoder.js.map +1 -1
  9. package/dist/api/encoder.d.ts +338 -74
  10. package/dist/api/encoder.js +546 -188
  11. package/dist/api/encoder.js.map +1 -1
  12. package/dist/api/filter-presets.d.ts +479 -1513
  13. package/dist/api/filter-presets.js +1044 -2005
  14. package/dist/api/filter-presets.js.map +1 -1
  15. package/dist/api/filter.d.ts +370 -150
  16. package/dist/api/filter.js +647 -364
  17. package/dist/api/filter.js.map +1 -1
  18. package/dist/api/hardware.d.ts +25 -31
  19. package/dist/api/hardware.js +36 -70
  20. package/dist/api/hardware.js.map +1 -1
  21. package/dist/api/index.d.ts +1 -1
  22. package/dist/api/index.js +1 -1
  23. package/dist/api/index.js.map +1 -1
  24. package/dist/api/io-stream.d.ts +6 -0
  25. package/dist/api/io-stream.js +2 -0
  26. package/dist/api/io-stream.js.map +1 -1
  27. package/dist/api/media-input.d.ts +208 -2
  28. package/dist/api/media-input.js +356 -8
  29. package/dist/api/media-input.js.map +1 -1
  30. package/dist/api/media-output.d.ts +142 -104
  31. package/dist/api/media-output.js +446 -179
  32. package/dist/api/media-output.js.map +1 -1
  33. package/dist/api/pipeline.d.ts +82 -17
  34. package/dist/api/pipeline.js +80 -42
  35. package/dist/api/pipeline.js.map +1 -1
  36. package/dist/api/types.d.ts +24 -57
  37. package/dist/api/utils.js +2 -0
  38. package/dist/api/utils.js.map +1 -1
  39. package/dist/lib/audio-fifo.d.ts +103 -0
  40. package/dist/lib/audio-fifo.js +109 -0
  41. package/dist/lib/audio-fifo.js.map +1 -1
  42. package/dist/lib/binding.d.ts +1 -0
  43. package/dist/lib/binding.js.map +1 -1
  44. package/dist/lib/bitstream-filter-context.d.ts +79 -0
  45. package/dist/lib/bitstream-filter-context.js +83 -0
  46. package/dist/lib/bitstream-filter-context.js.map +1 -1
  47. package/dist/lib/bitstream-filter.d.ts +2 -0
  48. package/dist/lib/bitstream-filter.js +2 -0
  49. package/dist/lib/bitstream-filter.js.map +1 -1
  50. package/dist/lib/codec-context.d.ts +168 -0
  51. package/dist/lib/codec-context.js +178 -0
  52. package/dist/lib/codec-context.js.map +1 -1
  53. package/dist/lib/codec-parameters.d.ts +3 -0
  54. package/dist/lib/codec-parameters.js +3 -0
  55. package/dist/lib/codec-parameters.js.map +1 -1
  56. package/dist/lib/codec-parser.d.ts +6 -0
  57. package/dist/lib/codec-parser.js +6 -0
  58. package/dist/lib/codec-parser.js.map +1 -1
  59. package/dist/lib/codec.d.ts +12 -0
  60. package/dist/lib/codec.js +12 -0
  61. package/dist/lib/codec.js.map +1 -1
  62. package/dist/lib/dictionary.d.ts +18 -2
  63. package/dist/lib/dictionary.js +18 -2
  64. package/dist/lib/dictionary.js.map +1 -1
  65. package/dist/lib/error.d.ts +8 -0
  66. package/dist/lib/error.js +9 -0
  67. package/dist/lib/error.js.map +1 -1
  68. package/dist/lib/filter-context.d.ts +119 -2
  69. package/dist/lib/filter-context.js +119 -0
  70. package/dist/lib/filter-context.js.map +1 -1
  71. package/dist/lib/filter-graph.d.ts +80 -0
  72. package/dist/lib/filter-graph.js +84 -0
  73. package/dist/lib/filter-graph.js.map +1 -1
  74. package/dist/lib/filter-inout.d.ts +1 -0
  75. package/dist/lib/filter-inout.js +1 -0
  76. package/dist/lib/filter-inout.js.map +1 -1
  77. package/dist/lib/filter.d.ts +2 -0
  78. package/dist/lib/filter.js +2 -0
  79. package/dist/lib/filter.js.map +1 -1
  80. package/dist/lib/format-context.d.ts +356 -20
  81. package/dist/lib/format-context.js +375 -23
  82. package/dist/lib/format-context.js.map +1 -1
  83. package/dist/lib/frame.d.ts +84 -1
  84. package/dist/lib/frame.js +96 -0
  85. package/dist/lib/frame.js.map +1 -1
  86. package/dist/lib/hardware-device-context.d.ts +8 -0
  87. package/dist/lib/hardware-device-context.js +8 -0
  88. package/dist/lib/hardware-device-context.js.map +1 -1
  89. package/dist/lib/hardware-frames-context.d.ts +55 -0
  90. package/dist/lib/hardware-frames-context.js +57 -0
  91. package/dist/lib/hardware-frames-context.js.map +1 -1
  92. package/dist/lib/input-format.d.ts +43 -3
  93. package/dist/lib/input-format.js +48 -0
  94. package/dist/lib/input-format.js.map +1 -1
  95. package/dist/lib/io-context.d.ts +212 -0
  96. package/dist/lib/io-context.js +228 -0
  97. package/dist/lib/io-context.js.map +1 -1
  98. package/dist/lib/log.d.ts +2 -0
  99. package/dist/lib/log.js +2 -0
  100. package/dist/lib/log.js.map +1 -1
  101. package/dist/lib/native-types.d.ts +39 -1
  102. package/dist/lib/option.d.ts +90 -0
  103. package/dist/lib/option.js +97 -0
  104. package/dist/lib/option.js.map +1 -1
  105. package/dist/lib/output-format.d.ts +4 -0
  106. package/dist/lib/output-format.js +4 -0
  107. package/dist/lib/output-format.js.map +1 -1
  108. package/dist/lib/packet.d.ts +7 -0
  109. package/dist/lib/packet.js +7 -0
  110. package/dist/lib/packet.js.map +1 -1
  111. package/dist/lib/rational.d.ts +1 -0
  112. package/dist/lib/rational.js +1 -0
  113. package/dist/lib/rational.js.map +1 -1
  114. package/dist/lib/software-resample-context.d.ts +64 -0
  115. package/dist/lib/software-resample-context.js +66 -0
  116. package/dist/lib/software-resample-context.js.map +1 -1
  117. package/dist/lib/software-scale-context.d.ts +98 -0
  118. package/dist/lib/software-scale-context.js +102 -0
  119. package/dist/lib/software-scale-context.js.map +1 -1
  120. package/dist/lib/stream.d.ts +1 -0
  121. package/dist/lib/stream.js +1 -0
  122. package/dist/lib/stream.js.map +1 -1
  123. package/dist/lib/utilities.d.ts +60 -0
  124. package/dist/lib/utilities.js +60 -0
  125. package/dist/lib/utilities.js.map +1 -1
  126. package/package.json +18 -18
  127. package/release_notes.md +0 -29
@@ -1,3 +1,4 @@
1
+ import { mkdirSync } from 'fs';
1
2
  import { mkdir } from 'fs/promises';
2
3
  import { dirname, resolve } from 'path';
3
4
  import { AVFMT_FLAG_CUSTOM_IO, AVFMT_NOFILE, AVIO_FLAG_WRITE } from '../constants/constants.js';
@@ -7,6 +8,8 @@ import { Encoder } from './encoder.js';
7
8
  * High-level media output for writing and muxing media files.
8
9
  *
9
10
  * Provides simplified access to media muxing and file writing operations.
11
+ * Automatically manages header and trailer writing - header is written on first packet,
12
+ * trailer is written on close. Supports lazy initialization for both encoders and streams.
10
13
  * Handles stream configuration, packet writing, and format management.
11
14
  * Supports files, URLs, and custom I/O with automatic cleanup.
12
15
  * Essential component for media encoding pipelines and transcoding.
@@ -22,14 +25,11 @@ import { Encoder } from './encoder.js';
22
25
  * const videoIdx = output.addStream(videoEncoder);
23
26
  * const audioIdx = output.addStream(audioEncoder);
24
27
  *
25
- * // Write header
26
- * await output.writeHeader();
27
- *
28
- * // Write packets
28
+ * // Write packets - header written automatically on first packet
29
29
  * await output.writePacket(packet, videoIdx);
30
30
  *
31
- * // Write trailer and close
32
- * await output.writeTrailer();
31
+ * // Close - trailer written automatically
32
+ * // (automatic with await using)
33
33
  * ```
34
34
  *
35
35
  * @example
@@ -40,8 +40,8 @@ import { Encoder } from './encoder.js';
40
40
  *
41
41
  * // Copy stream configuration
42
42
  * const videoIdx = output.addStream(input.video());
43
- * await output.writeHeader();
44
43
  *
44
+ * // Process packets - header/trailer handled automatically
45
45
  * for await (const packet of input.packets()) {
46
46
  * await output.writePacket(packet, videoIdx);
47
47
  * packet.free();
@@ -58,7 +58,8 @@ export class MediaOutput {
58
58
  ioContext;
59
59
  headerWritten = false;
60
60
  trailerWritten = false;
61
- closed = false;
61
+ isClosed = false;
62
+ headerWritePromise;
62
63
  /**
63
64
  * @internal
64
65
  */
@@ -75,7 +76,9 @@ export class MediaOutput {
75
76
  * Direct mapping to avformat_alloc_output_context2() and avio_open2().
76
77
  *
77
78
  * @param target - File path, URL, or I/O callbacks
79
+ *
78
80
  * @param options - Output configuration options
81
+ *
79
82
  * @returns Opened media output instance
80
83
  *
81
84
  * @throws {Error} If format required for custom I/O
@@ -193,25 +196,142 @@ export class MediaOutput {
193
196
  throw error;
194
197
  }
195
198
  }
199
+ /**
200
+ * Open media output for writing synchronously.
201
+ * Synchronous version of open.
202
+ *
203
+ * Creates and configures output context for muxing.
204
+ * Automatically creates directories for file output.
205
+ * Supports files, URLs, and custom I/O callbacks.
206
+ *
207
+ * Direct mapping to avformat_alloc_output_context2() and avio_open2().
208
+ *
209
+ * @param target - File path, URL, or I/O callbacks
210
+ *
211
+ * @param options - Output configuration options
212
+ *
213
+ * @returns Opened media output instance
214
+ *
215
+ * @throws {Error} If format required for custom I/O
216
+ *
217
+ * @throws {FFmpegError} If allocation or opening fails
218
+ *
219
+ * @example
220
+ * ```typescript
221
+ * // Create file output
222
+ * using output = MediaOutput.openSync('output.mp4');
223
+ * ```
224
+ *
225
+ * @example
226
+ * ```typescript
227
+ * // Create output with specific format
228
+ * using output = MediaOutput.openSync('output.ts', {
229
+ * format: 'mpegts'
230
+ * });
231
+ * ```
232
+ *
233
+ * @see {@link open} For async version
234
+ */
235
+ static openSync(target, options) {
236
+ const output = new MediaOutput();
237
+ try {
238
+ if (typeof target === 'string') {
239
+ // File or stream URL - resolve relative paths and create directories
240
+ // Check if it's a URL (starts with protocol://) or a file path
241
+ const isUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(target);
242
+ const resolvedTarget = isUrl ? target : resolve(target);
243
+ // Create directory structure for local files (not URLs)
244
+ if (!isUrl && target !== '') {
245
+ const dir = dirname(resolvedTarget);
246
+ mkdirSync(dir, { recursive: true });
247
+ }
248
+ // Allocate output context
249
+ const ret = output.formatContext.allocOutputContext2(null, options?.format ?? null, resolvedTarget === '' ? null : resolvedTarget);
250
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
251
+ // Check if we need to open IO
252
+ const oformat = output.formatContext.oformat;
253
+ if (resolvedTarget && oformat && !(oformat.flags & AVFMT_NOFILE)) {
254
+ // For file-based formats, we need to open the file using avio_open2
255
+ // FFmpeg will manage the AVIOContext internally
256
+ output.ioContext = new IOContext();
257
+ const openRet = output.ioContext.open2Sync(resolvedTarget, AVIO_FLAG_WRITE);
258
+ FFmpegError.throwIfError(openRet, `Failed to open output file: ${resolvedTarget}`);
259
+ output.formatContext.pb = output.ioContext;
260
+ }
261
+ }
262
+ else {
263
+ // Custom IO with callbacks - format is required
264
+ if (!options?.format) {
265
+ throw new Error('Format must be specified for custom IO');
266
+ }
267
+ const ret = output.formatContext.allocOutputContext2(null, options.format, null);
268
+ FFmpegError.throwIfError(ret, 'Failed to allocate output context');
269
+ // Setup custom IO with callbacks
270
+ output.ioContext = new IOContext();
271
+ output.ioContext.allocContextWithCallbacks(options.bufferSize ?? 4096, 1, target.read, target.write, target.seek);
272
+ output.ioContext.maxPacketSize = options.bufferSize ?? 4096;
273
+ output.formatContext.pb = output.ioContext;
274
+ output.formatContext.flags = AVFMT_FLAG_CUSTOM_IO;
275
+ }
276
+ return output;
277
+ }
278
+ catch (error) {
279
+ // Cleanup on error
280
+ if (output.ioContext) {
281
+ try {
282
+ const isCustomIO = (output.formatContext.flags & AVFMT_FLAG_CUSTOM_IO) !== 0;
283
+ if (isCustomIO) {
284
+ // Clear the pb reference first
285
+ output.formatContext.pb = null;
286
+ // For custom IO with callbacks, free the context
287
+ output.ioContext.freeContext();
288
+ }
289
+ else {
290
+ // For file-based IO, close the file handle
291
+ output.ioContext.closepSync();
292
+ }
293
+ }
294
+ catch {
295
+ // Ignore errors
296
+ }
297
+ }
298
+ if (output.formatContext) {
299
+ try {
300
+ output.formatContext.freeContext();
301
+ }
302
+ catch {
303
+ // Ignore errors
304
+ }
305
+ }
306
+ throw error;
307
+ }
308
+ }
196
309
  /**
197
310
  * Add a stream to the output.
198
311
  *
199
312
  * Configures output stream from encoder or input stream.
200
- * Must be called before writeHeader().
313
+ * Must be called before writing any packets.
201
314
  * Returns stream index for packet writing.
202
315
  *
203
- * Direct mapping to avformat_new_stream() and avcodec_parameters_copy().
316
+ * Streams are initialized lazily - codec parameters are configured
317
+ * automatically when the first packet is written. This allows encoders
318
+ * to be initialized from frame properties.
319
+ *
320
+ * Direct mapping to avformat_new_stream().
204
321
  *
205
322
  * @param source - Encoder or stream to add
323
+ *
206
324
  * @param options - Stream configuration options
325
+ *
207
326
  * @param options.timeBase - Optional custom timebase for the stream
327
+ *
208
328
  * @returns Stream index for packet writing
209
329
  *
210
- * @throws {Error} If called after header written or output closed
330
+ * @throws {Error} If called after packets have been written or output closed
211
331
  *
212
332
  * @example
213
333
  * ```typescript
214
- * // Add stream from encoder
334
+ * // Add stream from encoder (lazy initialization)
215
335
  * const videoIdx = output.addStream(videoEncoder);
216
336
  * const audioIdx = output.addStream(audioEncoder);
217
337
  * ```
@@ -228,68 +348,74 @@ export class MediaOutput {
228
348
  * @see {@link Encoder} For transcoding source
229
349
  */
230
350
  addStream(source, options) {
231
- if (this.closed) {
351
+ if (this.isClosed) {
232
352
  throw new Error('MediaOutput is closed');
233
353
  }
234
354
  if (this.headerWritten) {
235
- throw new Error('Cannot add streams after header is written');
355
+ throw new Error('Cannot add streams after packets have been written');
236
356
  }
237
357
  const stream = this.formatContext.newStream(null);
238
358
  if (!stream) {
239
359
  throw new Error('Failed to create new stream');
240
360
  }
241
- let isStreamCopy = false;
242
- let sourceTimeBase;
243
- if (source instanceof Encoder) {
244
- // Transcoding with encoder
245
- const codecContext = source.getCodecContext();
246
- if (!codecContext) {
247
- throw new Error('Failed to get codec context from encoder');
248
- }
249
- const ret = stream.codecpar.fromContext(codecContext);
250
- FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
251
- // Store the encoder's timebase as source (we'll need it for rescaling)
252
- sourceTimeBase = codecContext.timeBase;
253
- // Output stream uses encoder's timebase (or custom if specified)
254
- stream.timeBase = options?.timeBase ? new Rational(options.timeBase.num, options.timeBase.den) : codecContext.timeBase;
361
+ const isStreamCopy = !(source instanceof Encoder);
362
+ // For stream copy, initialize immediately since we have all the info
363
+ if (isStreamCopy) {
364
+ const inputStream = source;
365
+ const ret = inputStream.codecpar.copy(stream.codecpar);
366
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters');
367
+ // Set the timebases
368
+ const sourceTimeBase = inputStream.timeBase;
369
+ stream.timeBase = options?.timeBase ? new Rational(options.timeBase.num, options.timeBase.den) : inputStream.timeBase;
370
+ this.streams.set(stream.index, {
371
+ initialized: true,
372
+ stream,
373
+ source,
374
+ timeBase: options?.timeBase,
375
+ sourceTimeBase,
376
+ isStreamCopy: true,
377
+ bufferedPackets: [],
378
+ });
255
379
  }
256
380
  else {
257
- // Stream copy
258
- const ret = source.codecpar.copy(stream.codecpar);
259
- FFmpegError.throwIfError(ret, 'Failed to copy codec parameters');
260
- // Store the input stream's timebase as source (we'll need it for rescaling)
261
- sourceTimeBase = source.timeBase;
262
- // Output stream uses input stream's timebase (or custom if specified)
263
- stream.timeBase = options?.timeBase ? new Rational(options.timeBase.num, options.timeBase.den) : source.timeBase;
264
- stream.codecpar.codecTag = 0; // Important for format compatibility
265
- isStreamCopy = true;
266
- }
267
- this.streams.set(stream.index, {
268
- stream,
269
- timeBase: stream.timeBase,
270
- isStreamCopy,
271
- sourceTimeBase,
272
- });
381
+ this.streams.set(stream.index, {
382
+ initialized: false,
383
+ stream,
384
+ source,
385
+ timeBase: options?.timeBase,
386
+ sourceTimeBase: undefined, // Will be set on initialization
387
+ isStreamCopy: false,
388
+ bufferedPackets: [],
389
+ });
390
+ }
273
391
  return stream.index;
274
392
  }
275
393
  /**
276
394
  * Write a packet to the output.
277
395
  *
278
396
  * Writes muxed packet to the specified stream.
279
- * Automatically handles timestamp rescaling.
280
- * Must be called after writeHeader() and before writeTrailer().
397
+ * Automatically handles:
398
+ * - Stream initialization on first packet (lazy initialization)
399
+ * - Codec parameter configuration from encoder or input stream
400
+ * - Header writing on first packet
401
+ * - Timestamp rescaling between source and output timebases
402
+ *
403
+ * For encoder sources, the encoder must have processed at least one frame
404
+ * before packets can be written (encoder must be initialized).
281
405
  *
282
- * Direct mapping to av_interleaved_write_frame().
406
+ * Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
283
407
  *
284
408
  * @param packet - Packet to write
409
+ *
285
410
  * @param streamIndex - Target stream index
286
- * @throws {Error} If stream invalid or called at wrong time
411
+ *
412
+ * @throws {Error} If stream invalid or encoder not initialized
287
413
  *
288
414
  * @throws {FFmpegError} If write fails
289
415
  *
290
416
  * @example
291
417
  * ```typescript
292
- * // Write encoded packet
418
+ * // Write encoded packet - header written automatically on first packet
293
419
  * const packet = await encoder.encode(frame);
294
420
  * if (packet) {
295
421
  * await output.writePacket(packet, videoIdx);
@@ -309,119 +435,216 @@ export class MediaOutput {
309
435
  * ```
310
436
  *
311
437
  * @see {@link addStream} For adding streams
312
- * @see {@link writeHeader} Must be called first
313
438
  */
314
439
  async writePacket(packet, streamIndex) {
315
- if (this.closed) {
440
+ if (this.isClosed) {
316
441
  throw new Error('MediaOutput is closed');
317
442
  }
318
- if (!this.headerWritten) {
319
- throw new Error('Header must be written before packets');
320
- }
321
443
  if (this.trailerWritten) {
322
- throw new Error('Cannot write packets after trailer');
444
+ throw new Error('Cannot write packets after output is finalized');
323
445
  }
324
- const streamInfo = this.streams.get(streamIndex);
325
- if (!streamInfo) {
446
+ if (!this.streams.get(streamIndex)) {
326
447
  throw new Error(`Invalid stream index: ${streamIndex}`);
327
448
  }
449
+ // Initialize any encoder streams that are ready
450
+ for (const streamInfo of this.streams.values()) {
451
+ if (!streamInfo.initialized && streamInfo.source instanceof Encoder) {
452
+ const encoder = streamInfo.source;
453
+ const codecContext = encoder.getCodecContext();
454
+ // Skip if encoder not ready yet
455
+ if (!encoder.isEncoderInitialized || !codecContext) {
456
+ continue;
457
+ }
458
+ // This encoder is ready, initialize it now
459
+ const ret = streamInfo.stream.codecpar.fromContext(codecContext);
460
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
461
+ // Update the timebase from the encoder
462
+ streamInfo.sourceTimeBase = codecContext.timeBase;
463
+ // Output stream uses encoder's timebase (or custom if specified)
464
+ streamInfo.stream.timeBase = streamInfo.timeBase ? new Rational(streamInfo.timeBase.num, streamInfo.timeBase.den) : codecContext.timeBase;
465
+ // Mark as initialized
466
+ streamInfo.initialized = true;
467
+ }
468
+ }
469
+ const streamInfo = this.streams.get(streamIndex);
470
+ // Check if any streams are still uninitialized
471
+ const uninitialized = Array.from(this.streams.values()).some((s) => !s.initialized);
472
+ if (uninitialized) {
473
+ const clonedPacket = packet.clone();
474
+ packet.free();
475
+ if (clonedPacket) {
476
+ streamInfo.bufferedPackets.push(clonedPacket);
477
+ }
478
+ return;
479
+ }
480
+ // Automatically write header if not written yet
481
+ // Use a promise to ensure only one thread writes the header
482
+ if (!this.headerWritten) {
483
+ this.headerWritePromise ??= (async () => {
484
+ const ret = await this.formatContext.writeHeader();
485
+ FFmpegError.throwIfError(ret, 'Failed to write header');
486
+ this.headerWritten = true;
487
+ })();
488
+ // All threads wait for the header to be written
489
+ await this.headerWritePromise;
490
+ }
328
491
  // Set stream index
329
492
  packet.streamIndex = streamIndex;
330
- // Rescale packet timestamps if source and output timebases differ
331
- // Note: The stream's timebase may have been changed by writeHeader (e.g., MP4 uses 1/time_scale)
332
- if (streamInfo.sourceTimeBase) {
333
- const outputStream = this.formatContext.streams?.[streamIndex];
334
- if (outputStream) {
335
- // Only rescale if timebases actually differ
336
- const srcTb = streamInfo.sourceTimeBase;
337
- const dstTb = outputStream.timeBase;
338
- if (srcTb.num !== dstTb.num || srcTb.den !== dstTb.den) {
339
- packet.rescaleTs(streamInfo.sourceTimeBase, outputStream.timeBase);
493
+ const write = async (pkt) => {
494
+ // Rescale packet timestamps if source and output timebases differ
495
+ // Note: The stream's timebase may have been changed by writeHeader (e.g., MP4 uses 1/time_scale)
496
+ if (streamInfo.sourceTimeBase) {
497
+ const outputStream = this.formatContext.streams?.[streamIndex];
498
+ if (outputStream) {
499
+ // Only rescale if timebases actually differ
500
+ const srcTb = streamInfo.sourceTimeBase;
501
+ const dstTb = outputStream.timeBase;
502
+ if (srcTb.num !== dstTb.num || srcTb.den !== dstTb.den) {
503
+ pkt.rescaleTs(streamInfo.sourceTimeBase, outputStream.timeBase);
504
+ }
340
505
  }
341
506
  }
507
+ // Write the packet
508
+ const ret = await this.formatContext.interleavedWriteFrame(pkt);
509
+ FFmpegError.throwIfError(ret, 'Failed to write packet');
510
+ };
511
+ // Write any buffered packets first
512
+ for (const bufferedPacket of streamInfo.bufferedPackets) {
513
+ await write(bufferedPacket);
514
+ bufferedPacket.free();
342
515
  }
343
- // Write the packet
344
- const ret = await this.formatContext.interleavedWriteFrame(packet);
345
- FFmpegError.throwIfError(ret, 'Failed to write packet');
516
+ streamInfo.bufferedPackets = [];
517
+ // Write the current packet
518
+ await write(packet);
346
519
  }
347
520
  /**
348
- * Write file header.
521
+ * Write a packet to the output synchronously.
522
+ * Synchronous version of writePacket.
523
+ *
524
+ * Writes muxed packet to the specified stream.
525
+ * Automatically handles:
526
+ * - Stream initialization on first packet (lazy initialization)
527
+ * - Codec parameter configuration from encoder or input stream
528
+ * - Header writing on first packet
529
+ * - Timestamp rescaling between source and output timebases
349
530
  *
350
- * Writes format header with stream configuration.
351
- * Must be called after adding all streams and before writing packets.
352
- * Finalizes stream parameters and initializes muxer.
531
+ * For encoder sources, the encoder must have processed at least one frame
532
+ * before packets can be written (encoder must be initialized).
353
533
  *
354
- * Direct mapping to avformat_write_header().
534
+ * Direct mapping to avformat_write_header() (on first packet) and av_interleaved_write_frame().
355
535
  *
356
- * @throws {Error} If already written or output closed
536
+ * @param packet - Packet to write
537
+ *
538
+ * @param streamIndex - Target stream index
539
+ *
540
+ * @throws {Error} If stream invalid or encoder not initialized
357
541
  *
358
542
  * @throws {FFmpegError} If write fails
359
543
  *
360
544
  * @example
361
545
  * ```typescript
362
- * // Standard workflow
363
- * const output = await MediaOutput.open('output.mp4');
364
- * output.addStream(encoder);
365
- * await output.writeHeader();
366
- * // Now ready to write packets
546
+ * // Write encoded packet - header written automatically on first packet
547
+ * const packet = encoder.encodeSync(frame);
548
+ * if (packet) {
549
+ * output.writePacketSync(packet, videoIdx);
550
+ * packet.free();
551
+ * }
367
552
  * ```
368
553
  *
369
- * @see {@link addStream} Must add streams first
370
- * @see {@link writePacket} Can write packets after
371
- * @see {@link writeTrailer} Must call at end
372
- */
373
- async writeHeader() {
374
- if (this.closed) {
375
- throw new Error('MediaOutput is closed');
376
- }
377
- if (this.headerWritten) {
378
- throw new Error('Header already written');
379
- }
380
- const ret = await this.formatContext.writeHeader();
381
- FFmpegError.throwIfError(ret, 'Failed to write header');
382
- this.headerWritten = true;
383
- }
384
- /**
385
- * Write file trailer.
386
- *
387
- * Writes format trailer and finalizes the file.
388
- * Must be called after all packets are written.
389
- * Flushes any buffered data and updates file headers.
390
- *
391
- * Direct mapping to av_write_trailer().
392
- *
393
- * @throws {Error} If header not written or already written
394
- *
395
- * @throws {FFmpegError} If write fails
396
- *
397
554
  * @example
398
555
  * ```typescript
399
- * // Finalize output
400
- * await output.writeTrailer();
401
- * await output.close();
556
+ * // Stream copy with packet processing
557
+ * for (const packet of input.packetsSync()) {
558
+ * if (packet.streamIndex === inputVideoIdx) {
559
+ * output.writePacketSync(packet, outputVideoIdx);
560
+ * }
561
+ * packet.free();
562
+ * }
402
563
  * ```
403
564
  *
404
- * @see {@link writeHeader} Must be called first
405
- * @see {@link close} For cleanup after trailer
565
+ * @see {@link writePacket} For async version
406
566
  */
407
- async writeTrailer() {
408
- if (this.closed) {
567
+ writePacketSync(packet, streamIndex) {
568
+ if (this.isClosed) {
409
569
  throw new Error('MediaOutput is closed');
410
570
  }
571
+ if (this.trailerWritten) {
572
+ throw new Error('Cannot write packets after output is finalized');
573
+ }
574
+ if (!this.streams.get(streamIndex)) {
575
+ throw new Error(`Invalid stream index: ${streamIndex}`);
576
+ }
577
+ // Initialize any encoder streams that are ready
578
+ for (const streamInfo of this.streams.values()) {
579
+ if (!streamInfo.initialized && streamInfo.source instanceof Encoder) {
580
+ const encoder = streamInfo.source;
581
+ const codecContext = encoder.getCodecContext();
582
+ // Skip if encoder not ready yet
583
+ if (!encoder.isEncoderInitialized || !codecContext) {
584
+ continue;
585
+ }
586
+ // This encoder is ready, initialize it now
587
+ const ret = streamInfo.stream.codecpar.fromContext(codecContext);
588
+ FFmpegError.throwIfError(ret, 'Failed to copy codec parameters from encoder');
589
+ // Update the timebase from the encoder
590
+ streamInfo.sourceTimeBase = codecContext.timeBase;
591
+ // Output stream uses encoder's timebase (or custom if specified)
592
+ streamInfo.stream.timeBase = streamInfo.timeBase ? new Rational(streamInfo.timeBase.num, streamInfo.timeBase.den) : codecContext.timeBase;
593
+ // Mark as initialized
594
+ streamInfo.initialized = true;
595
+ }
596
+ }
597
+ const streamInfo = this.streams.get(streamIndex);
598
+ // Check if any streams are still uninitialized
599
+ const uninitialized = Array.from(this.streams.values()).some((s) => !s.initialized);
600
+ if (uninitialized) {
601
+ const clonedPacket = packet.clone();
602
+ packet.free();
603
+ if (clonedPacket) {
604
+ streamInfo.bufferedPackets.push(clonedPacket);
605
+ }
606
+ return;
607
+ }
608
+ // Automatically write header if not written yet
411
609
  if (!this.headerWritten) {
412
- throw new Error('Cannot write trailer without header');
610
+ const ret = this.formatContext.writeHeaderSync();
611
+ FFmpegError.throwIfError(ret, 'Failed to write header');
612
+ this.headerWritten = true;
413
613
  }
414
- if (this.trailerWritten) {
415
- throw new Error('Trailer already written');
614
+ // Set stream index
615
+ packet.streamIndex = streamIndex;
616
+ const write = (pkt) => {
617
+ // Rescale packet timestamps if source and output timebases differ
618
+ // Note: The stream's timebase may have been changed by writeHeader (e.g., MP4 uses 1/time_scale)
619
+ if (streamInfo.sourceTimeBase) {
620
+ const outputStream = this.formatContext.streams?.[streamIndex];
621
+ if (outputStream) {
622
+ // Only rescale if timebases actually differ
623
+ const srcTb = streamInfo.sourceTimeBase;
624
+ const dstTb = outputStream.timeBase;
625
+ if (srcTb.num !== dstTb.num || srcTb.den !== dstTb.den) {
626
+ pkt.rescaleTs(streamInfo.sourceTimeBase, outputStream.timeBase);
627
+ }
628
+ }
629
+ }
630
+ // Write the packet
631
+ const ret = this.formatContext.interleavedWriteFrameSync(pkt);
632
+ FFmpegError.throwIfError(ret, 'Failed to write packet');
633
+ };
634
+ // Write any buffered packets first
635
+ for (const bufferedPacket of streamInfo.bufferedPackets) {
636
+ write(bufferedPacket);
637
+ bufferedPacket.free();
416
638
  }
417
- const ret = await this.formatContext.writeTrailer();
418
- FFmpegError.throwIfError(ret, 'Failed to write trailer');
419
- this.trailerWritten = true;
639
+ streamInfo.bufferedPackets = [];
640
+ // Write the current packet
641
+ write(packet);
420
642
  }
421
643
  /**
422
644
  * Close media output and free resources.
423
645
  *
424
- * Writes trailer if needed and releases all resources.
646
+ * Automatically writes trailer if header was written.
647
+ * Closes the output file and releases all resources.
425
648
  * Safe to call multiple times.
426
649
  * Automatically called by Symbol.asyncDispose.
427
650
  *
@@ -429,7 +652,7 @@ export class MediaOutput {
429
652
  * ```typescript
430
653
  * const output = await MediaOutput.open('output.mp4');
431
654
  * try {
432
- * // Use output
655
+ * // Use output - trailer written automatically on close
433
656
  * } finally {
434
657
  * await output.close();
435
658
  * }
@@ -438,14 +661,23 @@ export class MediaOutput {
438
661
  * @see {@link Symbol.asyncDispose} For automatic cleanup
439
662
  */
440
663
  async close() {
441
- if (this.closed) {
664
+ if (this.isClosed) {
442
665
  return;
443
666
  }
444
- this.closed = true;
667
+ this.isClosed = true;
668
+ // Free any buffered packets
669
+ for (const streamInfo of this.streams.values()) {
670
+ // Free any buffered packets
671
+ for (const pkt of streamInfo.bufferedPackets) {
672
+ pkt.free();
673
+ }
674
+ streamInfo.bufferedPackets = [];
675
+ }
445
676
  // Try to write trailer if header was written but trailer wasn't
446
677
  try {
447
678
  if (this.headerWritten && !this.trailerWritten) {
448
679
  await this.formatContext.writeTrailer();
680
+ this.trailerWritten = true;
449
681
  }
450
682
  }
451
683
  catch {
@@ -487,67 +719,83 @@ export class MediaOutput {
487
719
  }
488
720
  }
489
721
  /**
490
- * Get stream information.
491
- *
492
- * Returns internal stream info for the specified index.
493
- *
494
- * @param streamIndex - Stream index
495
- * @returns Stream info or undefined
496
- *
497
- * @example
498
- * ```typescript
499
- * const info = output.getStreamInfo(0);
500
- * console.log(`Stream 0 timebase: ${info?.timeBase.num}/${info?.timeBase.den}`);
501
- * ```
502
- */
503
- getStreamInfo(streamIndex) {
504
- return this.streams.get(streamIndex);
505
- }
506
- /**
507
- * Get all stream indices.
508
- *
509
- * Returns array of all added stream indices.
510
- *
511
- * @returns Array of stream indices
512
- *
513
- * @example
514
- * ```typescript
515
- * const indices = output.getStreamIndices();
516
- * console.log(`Output has ${indices.length} streams`);
517
- * ```
518
- */
519
- getStreamIndices() {
520
- return Array.from(this.streams.keys());
521
- }
522
- /**
523
- * Check if header has been written.
722
+ * Close media output and free resources synchronously.
723
+ * Synchronous version of close.
524
724
  *
525
- * @returns true if header written
725
+ * Automatically writes trailer if header was written.
726
+ * Closes the output file and releases all resources.
727
+ * Safe to call multiple times.
728
+ * Automatically called by Symbol.dispose.
526
729
  *
527
730
  * @example
528
731
  * ```typescript
529
- * if (!output.isHeaderWritten()) {
530
- * await output.writeHeader();
732
+ * const output = MediaOutput.openSync('output.mp4');
733
+ * try {
734
+ * // Use output - trailer written automatically on close
735
+ * } finally {
736
+ * output.closeSync();
531
737
  * }
532
738
  * ```
533
- */
534
- isHeaderWritten() {
535
- return this.headerWritten;
536
- }
537
- /**
538
- * Check if trailer has been written.
539
739
  *
540
- * @returns true if trailer written
541
- *
542
- * @example
543
- * ```typescript
544
- * if (!output.isTrailerWritten()) {
545
- * await output.writeTrailer();
546
- * }
547
- * ```
740
+ * @see {@link close} For async version
548
741
  */
549
- isTrailerWritten() {
550
- return this.trailerWritten;
742
+ closeSync() {
743
+ if (this.isClosed) {
744
+ return;
745
+ }
746
+ this.isClosed = true;
747
+ // Free any buffered packets
748
+ for (const streamInfo of this.streams.values()) {
749
+ // Free any buffered packets
750
+ for (const pkt of streamInfo.bufferedPackets) {
751
+ pkt.free();
752
+ }
753
+ streamInfo.bufferedPackets = [];
754
+ }
755
+ // Try to write trailer if header was written but trailer wasn't
756
+ try {
757
+ if (this.headerWritten && !this.trailerWritten) {
758
+ this.formatContext.writeTrailerSync();
759
+ this.trailerWritten = true;
760
+ }
761
+ }
762
+ catch {
763
+ // Ignore errors
764
+ }
765
+ // Clear pb reference first to prevent use-after-free
766
+ if (this.ioContext) {
767
+ this.formatContext.pb = null;
768
+ }
769
+ // Determine if this is custom IO before freeing format context
770
+ const isCustomIO = (this.formatContext.flags & AVFMT_FLAG_CUSTOM_IO) !== 0;
771
+ // For file-based IO, close the file handle via closep
772
+ // For custom IO, the context will be freed below
773
+ if (this.ioContext && !isCustomIO) {
774
+ try {
775
+ this.ioContext.closepSync();
776
+ }
777
+ catch {
778
+ // Ignore errors
779
+ }
780
+ }
781
+ // Free format context
782
+ if (this.formatContext) {
783
+ try {
784
+ this.formatContext.freeContext();
785
+ }
786
+ catch {
787
+ // Ignore errors
788
+ }
789
+ }
790
+ // Now free custom IO context if present
791
+ if (this.ioContext && isCustomIO) {
792
+ try {
793
+ this.ioContext.freeContext();
794
+ }
795
+ catch {
796
+ // Ignore errors
797
+ }
798
+ }
551
799
  }
552
800
  /**
553
801
  * Get underlying format context.
@@ -580,5 +828,24 @@ export class MediaOutput {
580
828
  async [Symbol.asyncDispose]() {
581
829
  await this.close();
582
830
  }
831
+ /**
832
+ * Dispose of media output synchronously.
833
+ *
834
+ * Implements Disposable interface for automatic cleanup.
835
+ * Equivalent to calling closeSync().
836
+ *
837
+ * @example
838
+ * ```typescript
839
+ * {
840
+ * using output = MediaOutput.openSync('output.mp4');
841
+ * // Use output...
842
+ * } // Automatically closed
843
+ * ```
844
+ *
845
+ * @see {@link closeSync} For manual cleanup
846
+ */
847
+ [Symbol.dispose]() {
848
+ this.closeSync();
849
+ }
583
850
  }
584
851
  //# sourceMappingURL=media-output.js.map