node-av 6.2.0-beta.3 → 6.2.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +13 -8
  2. package/dist/api/audio-frame-buffer.d.ts +438 -7
  3. package/dist/api/audio-frame-buffer.js +792 -52
  4. package/dist/api/audio-frame-buffer.js.map +1 -1
  5. package/dist/api/bitstream-filter.d.ts +82 -4
  6. package/dist/api/bitstream-filter.js +197 -100
  7. package/dist/api/bitstream-filter.js.map +1 -1
  8. package/dist/api/decoder.d.ts +5 -0
  9. package/dist/api/decoder.js +13 -6
  10. package/dist/api/decoder.js.map +1 -1
  11. package/dist/api/demuxer.d.ts +29 -4
  12. package/dist/api/demuxer.js +209 -60
  13. package/dist/api/demuxer.js.map +1 -1
  14. package/dist/api/encoder-pool.d.ts +43 -1
  15. package/dist/api/encoder-pool.js +121 -9
  16. package/dist/api/encoder-pool.js.map +1 -1
  17. package/dist/api/encoder.d.ts +58 -2
  18. package/dist/api/encoder.js +173 -57
  19. package/dist/api/encoder.js.map +1 -1
  20. package/dist/api/filter.js +15 -6
  21. package/dist/api/filter.js.map +1 -1
  22. package/dist/api/fmp4-stream.d.ts +327 -8
  23. package/dist/api/fmp4-stream.js +663 -123
  24. package/dist/api/fmp4-stream.js.map +1 -1
  25. package/dist/api/hardware.d.ts +2 -0
  26. package/dist/api/hardware.js +25 -4
  27. package/dist/api/hardware.js.map +1 -1
  28. package/dist/api/index.d.ts +2 -2
  29. package/dist/api/index.js.map +1 -1
  30. package/dist/api/io-stream.d.ts +3 -2
  31. package/dist/api/io-stream.js +77 -5
  32. package/dist/api/io-stream.js.map +1 -1
  33. package/dist/api/muxer.d.ts +289 -7
  34. package/dist/api/muxer.js +696 -109
  35. package/dist/api/muxer.js.map +1 -1
  36. package/dist/api/pipeline.d.ts +24 -0
  37. package/dist/api/pipeline.js +76 -5
  38. package/dist/api/pipeline.js.map +1 -1
  39. package/dist/api/rtp-stream.d.ts +77 -8
  40. package/dist/api/rtp-stream.js +316 -88
  41. package/dist/api/rtp-stream.js.map +1 -1
  42. package/dist/api/scaler.d.ts +124 -21
  43. package/dist/api/scaler.js +347 -114
  44. package/dist/api/scaler.js.map +1 -1
  45. package/dist/api/utilities/async-queue.d.ts +3 -0
  46. package/dist/api/utilities/async-queue.js +8 -0
  47. package/dist/api/utilities/async-queue.js.map +1 -1
  48. package/dist/api/utilities/packet-age-watchdog.d.ts +198 -0
  49. package/dist/api/utilities/packet-age-watchdog.js +594 -0
  50. package/dist/api/utilities/packet-age-watchdog.js.map +1 -0
  51. package/dist/api/webrtc-stream.d.ts +2 -0
  52. package/dist/api/webrtc-stream.js +46 -12
  53. package/dist/api/webrtc-stream.js.map +1 -1
  54. package/dist/api/whisper.d.ts +2 -0
  55. package/dist/api/whisper.js +63 -23
  56. package/dist/api/whisper.js.map +1 -1
  57. package/dist/constants/constants.d.ts +1 -0
  58. package/dist/constants/constants.js +1 -0
  59. package/dist/constants/constants.js.map +1 -1
  60. package/dist/constants/filter-options.d.ts +2 -0
  61. package/dist/ffmpeg/install.js +104 -26
  62. package/dist/ffmpeg/install.js.map +1 -1
  63. package/dist/ffmpeg/utils.js +7 -4
  64. package/dist/ffmpeg/utils.js.map +1 -1
  65. package/dist/lib/binding.d.ts +10 -1
  66. package/dist/lib/binding.js +25 -16
  67. package/dist/lib/binding.js.map +1 -1
  68. package/dist/lib/bitstream-filter-context.d.ts +2 -0
  69. package/dist/lib/bitstream-filter-context.js +2 -0
  70. package/dist/lib/bitstream-filter-context.js.map +1 -1
  71. package/dist/lib/codec-context.d.ts +0 -2
  72. package/dist/lib/codec-context.js +4 -22
  73. package/dist/lib/codec-context.js.map +1 -1
  74. package/dist/lib/filter-context.d.ts +3 -1
  75. package/dist/lib/filter-context.js +5 -11
  76. package/dist/lib/filter-context.js.map +1 -1
  77. package/dist/lib/filter-graph-segment.d.ts +3 -2
  78. package/dist/lib/filter-graph-segment.js +3 -2
  79. package/dist/lib/filter-graph-segment.js.map +1 -1
  80. package/dist/lib/filter-graph.d.ts +4 -4
  81. package/dist/lib/filter-graph.js +4 -4
  82. package/dist/lib/filter-inout.d.ts +4 -0
  83. package/dist/lib/filter-inout.js +4 -0
  84. package/dist/lib/filter-inout.js.map +1 -1
  85. package/dist/lib/format-context.d.ts +72 -7
  86. package/dist/lib/format-context.js +80 -22
  87. package/dist/lib/format-context.js.map +1 -1
  88. package/dist/lib/frame.d.ts +17 -13
  89. package/dist/lib/frame.js +24 -23
  90. package/dist/lib/frame.js.map +1 -1
  91. package/dist/lib/hardware-frames-context.d.ts +0 -1
  92. package/dist/lib/hardware-frames-context.js +2 -8
  93. package/dist/lib/hardware-frames-context.js.map +1 -1
  94. package/dist/lib/io-context.d.ts +4 -2
  95. package/dist/lib/io-context.js +4 -2
  96. package/dist/lib/io-context.js.map +1 -1
  97. package/dist/lib/native-types.d.ts +7 -5
  98. package/dist/lib/option.d.ts +26 -0
  99. package/dist/lib/option.js +42 -18
  100. package/dist/lib/option.js.map +1 -1
  101. package/dist/lib/packet.d.ts +5 -0
  102. package/dist/lib/packet.js +5 -0
  103. package/dist/lib/packet.js.map +1 -1
  104. package/dist/lib/stream.d.ts +8 -4
  105. package/dist/lib/stream.js +22 -24
  106. package/dist/lib/stream.js.map +1 -1
  107. package/install/check.js +40 -79
  108. package/package.json +45 -34
  109. package/build_mac_local.sh +0 -69
@@ -55,7 +55,7 @@ import { closeSync, openSync, readSync } from 'fs';
55
55
  import { open } from 'fs/promises';
56
56
  import { Readable } from 'node:stream';
57
57
  import { resolve } from 'path';
58
- import { AV_NOPTS_VALUE, AV_PIX_FMT_NONE, AV_ROUND_NEAR_INF, AV_ROUND_PASS_MINMAX, AV_TIME_BASE, AV_TIME_BASE_Q, AVFLAG_NONE, AVFMT_FLAG_CUSTOM_IO, AVFMT_FLAG_NONBLOCK, AVFMT_TS_DISCONT, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO, AVSEEK_CUR, AVSEEK_END, AVSEEK_SET, } from '../constants/constants.js';
58
+ import { AV_NOPTS_VALUE, AV_PIX_FMT_NONE, AV_ROUND_NEAR_INF, AV_ROUND_PASS_MINMAX, AV_TIME_BASE, AV_TIME_BASE_Q, AVERROR_EOF, AVERROR_EXIT, AVFLAG_NONE, AVFMT_FLAG_CUSTOM_IO, AVFMT_FLAG_NONBLOCK, AVFMT_TS_DISCONT, AVMEDIA_TYPE_AUDIO, AVMEDIA_TYPE_VIDEO, AVSEEK_CUR, AVSEEK_END, AVSEEK_SET, } from '../constants/constants.js';
59
59
  import { Dictionary } from '../lib/dictionary.js';
60
60
  import { AVERROR_EAGAIN, FFmpegError } from '../lib/error.js';
61
61
  import { FormatContext } from '../lib/format-context.js';
@@ -67,6 +67,9 @@ import { avGetPixFmtName, avGetSampleFmtName, avRescaleQ, avRescaleQRnd, dtsPred
67
67
  import { DELTA_THRESHOLD, DTS_ERROR_THRESHOLD, IO_BUFFER_SIZE, MAX_INPUT_QUEUE_SIZE } from './constants.js';
68
68
  import { IOStream } from './io-stream.js';
69
69
  import { StreamingUtils } from './utilities/streaming.js';
70
+ // Scratch buffer for Atomics.wait() - gives packetsSync a blocking sleep for
71
+ // EAGAIN retries (mirrors FFmpeg CLI's av_usleep()) without spinning the CPU.
72
+ const SYNC_SLEEP_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
70
73
  /**
71
74
  * High-level demuxer for reading and demuxing media files.
72
75
  *
@@ -110,6 +113,7 @@ export class Demuxer {
110
113
  formatContext;
111
114
  _streams = [];
112
115
  ioContext;
116
+ externalIO = false; // ioContext was supplied by the caller - may own a protocol handle from open2()
113
117
  isClosed = false;
114
118
  options;
115
119
  // Timestamp processing state (per-stream)
@@ -122,9 +126,11 @@ export class Demuxer {
122
126
  demuxThread = null;
123
127
  packetQueues = new Map(); // streamIndex or 'all' -> queue
124
128
  packetQueueConsumers = new Map(); // active consumer count per queue key
125
- queueResolvers = new Map(); // Promise resolvers for waiting consumers
129
+ queueResolvers = new Map(); // Promise resolvers for waiting consumers (multiple per key)
130
+ drainResolver = null; // Promise resolver for the demux thread parked on a full queue
126
131
  demuxThreadActive = false;
127
132
  demuxEof = false;
133
+ lastError = null; // read error that ended the demux loop (null = clean EOF)
128
134
  signal;
129
135
  signalCleanup;
130
136
  /**
@@ -134,11 +140,14 @@ export class Demuxer {
134
140
  *
135
141
  * @param ioContext - Optional IO context for custom I/O (e.g., from Buffer)
136
142
  *
143
+ * @param externalIO - Whether ioContext was supplied by the caller instead of built here
144
+ *
137
145
  * @internal
138
146
  */
139
- constructor(formatContext, options, ioContext) {
147
+ constructor(formatContext, options, ioContext, externalIO = false) {
140
148
  this.formatContext = formatContext;
141
149
  this.ioContext = ioContext;
150
+ this.externalIO = externalIO;
142
151
  this._streams = formatContext.streams ?? [];
143
152
  this.options = options;
144
153
  }
@@ -339,8 +348,17 @@ export class Demuxer {
339
348
  // Original implementation for non-raw data
340
349
  const formatContext = new FormatContext();
341
350
  let ioContext;
351
+ let externalIO = false; // caller-supplied context - released via dispose, not freeContext
342
352
  let optionsDict = null;
343
353
  let inputFormat = null;
354
+ // Abort during the open phase: avformat_open_input()/find_stream_info()
355
+ // block for seconds on an unresponsive network source, so the signal must
356
+ // fire the native interrupt callback to unblock them.
357
+ const onOpenAbort = () => formatContext.interrupt();
358
+ if (options.signal) {
359
+ options.signal.throwIfAborted();
360
+ options.signal.addEventListener('abort', onOpenAbort, { once: true });
361
+ }
344
362
  try {
345
363
  // Create options dictionary if options are provided
346
364
  if (options.options) {
@@ -381,6 +399,7 @@ export class Demuxer {
381
399
  }
382
400
  formatContext.allocContext();
383
401
  ioContext = input;
402
+ externalIO = true;
384
403
  formatContext.pb = ioContext;
385
404
  formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
386
405
  const ret = await formatContext.openInput('', inputFormat, optionsDict);
@@ -454,20 +473,43 @@ export class Demuxer {
454
473
  copyTs: options.copyTs ?? false,
455
474
  options: options.options ?? {},
456
475
  };
457
- const demuxer = new Demuxer(formatContext, fullOptions, ioContext);
476
+ const demuxer = new Demuxer(formatContext, fullOptions, ioContext, externalIO);
458
477
  if (options.signal) {
459
- options.signal.throwIfAborted();
460
- demuxer.signal = options.signal;
478
+ const signal = options.signal;
479
+ signal.removeEventListener('abort', onOpenAbort);
480
+ demuxer.signal = signal;
481
+ // An abort that raced the successful open must not hand out a usable
482
+ // demuxer - tear it down and surface the abort instead.
483
+ if (signal.aborted) {
484
+ await demuxer.close();
485
+ signal.throwIfAborted();
486
+ }
487
+ // Keep abort -> interrupt wired for the demuxer's lifetime so a
488
+ // blocked av_read_frame() unwinds too; close() removes the listener.
489
+ const onAbort = () => {
490
+ demuxer.demuxThreadActive = false;
491
+ demuxer.interrupt();
492
+ };
493
+ signal.addEventListener('abort', onAbort, { once: true });
494
+ demuxer.signalCleanup = () => signal.removeEventListener('abort', onAbort);
461
495
  }
462
496
  return demuxer;
463
497
  }
464
498
  catch (error) {
499
+ options.signal?.removeEventListener('abort', onOpenAbort);
465
500
  // Clean up only on error
466
501
  if (ioContext) {
467
502
  // Clear the pb reference first
468
503
  formatContext.pb = null;
469
- // Free the IOContext (for both custom I/O and buffer-based I/O)
470
- ioContext.freeContext();
504
+ // Release the IOContext (for both custom I/O and buffer-based I/O).
505
+ // A caller-supplied context may own a protocol handle from open2(),
506
+ // which only dispose closes - see close().
507
+ if (externalIO) {
508
+ await ioContext[Symbol.asyncDispose]();
509
+ }
510
+ else {
511
+ ioContext.freeContext();
512
+ }
471
513
  }
472
514
  // Clean up FormatContext
473
515
  await formatContext.closeInput();
@@ -513,6 +555,7 @@ export class Demuxer {
513
555
  // Original implementation for non-raw data
514
556
  const formatContext = new FormatContext();
515
557
  let ioContext;
558
+ let externalIO = false; // caller-supplied context - released via dispose, not freeContext
516
559
  let optionsDict = null;
517
560
  let inputFormat = null;
518
561
  try {
@@ -555,6 +598,7 @@ export class Demuxer {
555
598
  }
556
599
  formatContext.allocContext();
557
600
  ioContext = input;
601
+ externalIO = true;
558
602
  formatContext.pb = ioContext;
559
603
  formatContext.setFlags(AVFMT_FLAG_CUSTOM_IO);
560
604
  const ret = formatContext.openInputSync('', inputFormat, optionsDict);
@@ -616,7 +660,7 @@ export class Demuxer {
616
660
  copyTs: options.copyTs ?? false,
617
661
  options: options.options ?? {},
618
662
  };
619
- const demuxer = new Demuxer(formatContext, fullOptions, ioContext);
663
+ const demuxer = new Demuxer(formatContext, fullOptions, ioContext, externalIO);
620
664
  if (options.signal) {
621
665
  options.signal.throwIfAborted();
622
666
  demuxer.signal = options.signal;
@@ -628,8 +672,13 @@ export class Demuxer {
628
672
  if (ioContext) {
629
673
  // Clear the pb reference first
630
674
  formatContext.pb = null;
631
- // Free the IOContext (for both custom I/O and buffer-based I/O)
632
- ioContext.freeContext();
675
+ // Release the IOContext (see open() for why dispose)
676
+ if (externalIO) {
677
+ ioContext[Symbol.dispose]();
678
+ }
679
+ else {
680
+ ioContext.freeContext();
681
+ }
633
682
  }
634
683
  // Clean up FormatContext
635
684
  formatContext.closeInputSync();
@@ -1197,6 +1246,9 @@ export class Demuxer {
1197
1246
  *
1198
1247
  * @yields {Packet} Demuxed packets (must be freed by caller)
1199
1248
  *
1249
+ * @throws {FFmpegError} If reading fails with an error other than EOF (e.g. I/O failure
1250
+ * or a dropped network source) - only a real end-of-file ends the stream cleanly
1251
+ *
1200
1252
  * @throws {Error} If packet cloning fails
1201
1253
  *
1202
1254
  * @example
@@ -1253,42 +1305,64 @@ export class Demuxer {
1253
1305
  this.packetQueueConsumers.set(queueKey, (this.packetQueueConsumers.get(queueKey) ?? 0) + 1);
1254
1306
  // Always start demux thread (handles single and multiple generators)
1255
1307
  this.startDemuxThread();
1256
- let aborted = false;
1257
1308
  try {
1258
1309
  let hasSeenKeyframe = !this.options.startWithKeyframe;
1259
1310
  // Read from queue (demux thread is handling av_read_frame)
1260
1311
  const queue = this.packetQueues.get(queueKey);
1261
1312
  while (!this.isClosed) {
1262
1313
  if (this.signal?.aborted) {
1263
- aborted = true;
1264
1314
  break;
1265
1315
  }
1266
1316
  // Try to get packet from queue
1267
1317
  let packet = queue.shift();
1318
+ if (packet) {
1319
+ this.wakeDemuxThread();
1320
+ }
1268
1321
  // If queue is empty, wait for next packet
1269
1322
  if (!packet) {
1270
- // Check for EOF first
1323
+ // Queue drained - check for end of stream. Buffered packets always
1324
+ // drain before EOF/error is honored, so successfully read data is
1325
+ // never dropped.
1271
1326
  if (this.demuxEof) {
1327
+ // The read loop ended with a real error (not EOF): propagate it
1328
+ // instead of ending the stream silently - a dead live source must
1329
+ // be distinguishable from a finished file.
1330
+ if (this.lastError) {
1331
+ throw this.lastError;
1332
+ }
1272
1333
  break; // End of stream
1273
1334
  }
1274
- // Create promise and register resolver
1335
+ // Create promise and register resolver. Multiple consumers may wait
1336
+ // on the same queue key concurrently, so resolvers are collected per
1337
+ // key - a single slot would be overwritten by the second consumer,
1338
+ // parking the first one forever (whole-demuxer deadlock).
1275
1339
  const { promise, resolve } = Promise.withResolvers();
1276
- this.queueResolvers.set(queueKey, resolve);
1340
+ const resolvers = this.queueResolvers.get(queueKey);
1341
+ if (resolvers) {
1342
+ resolvers.push(resolve);
1343
+ }
1344
+ else {
1345
+ this.queueResolvers.set(queueKey, [resolve]);
1346
+ }
1277
1347
  // Wait for demux thread to add packet
1278
1348
  await promise;
1279
1349
  // Check for abort after wakeup
1280
1350
  if (this.signal?.aborted) {
1281
- aborted = true;
1282
- break;
1283
- }
1284
- // Check again after wakeup
1285
- if (this.demuxEof) {
1286
1351
  break;
1287
1352
  }
1288
1353
  packet = queue.shift();
1289
1354
  if (!packet) {
1355
+ // Woken without a packet for us - either EOF/error or another
1356
+ // consumer grabbed it. Loop back and re-evaluate.
1357
+ if (this.demuxEof) {
1358
+ if (this.lastError) {
1359
+ throw this.lastError;
1360
+ }
1361
+ break;
1362
+ }
1290
1363
  continue;
1291
1364
  }
1365
+ this.wakeDemuxThread();
1292
1366
  }
1293
1367
  // Apply keyframe filtering if needed
1294
1368
  if (!hasSeenKeyframe) {
@@ -1323,6 +1397,8 @@ export class Demuxer {
1323
1397
  p.free();
1324
1398
  }
1325
1399
  }
1400
+ // The demux thread may be waiting on this queue, so wake it to check the remaining ones.
1401
+ this.wakeDemuxThread();
1326
1402
  }
1327
1403
  else {
1328
1404
  this.packetQueueConsumers.set(queueKey, remaining);
@@ -1331,13 +1407,13 @@ export class Demuxer {
1331
1407
  if (this.activeGenerators === 0) {
1332
1408
  await this.stopDemuxThread();
1333
1409
  }
1334
- if (!aborted) {
1335
- yield null; // Signal EOF
1336
- }
1337
1410
  }
1338
- // Throw after generator cleanup — only reachable when aborted
1339
- // (when not aborted, yield null in finally already terminated the generator)
1411
+ // Only reached when the loop ended naturally (EOF, abort, close). A consumer
1412
+ // break resumes inside the finally above and completes the generator there -
1413
+ // yielding from the finally would suspend forever inside iterator.return(),
1414
+ // violating the iterator protocol.
1340
1415
  this.signal?.throwIfAborted();
1416
+ yield null; // Signal EOF
1341
1417
  }
1342
1418
  /**
1343
1419
  * Read packets from media as generator synchronously.
@@ -1353,6 +1429,9 @@ export class Demuxer {
1353
1429
  *
1354
1430
  * @yields {Packet} Demuxed packets (must be freed by caller)
1355
1431
  *
1432
+ * @throws {FFmpegError} If reading fails with an error other than EOF (e.g. I/O failure
1433
+ * or a dropped network source) - only a real end-of-file ends the stream cleanly
1434
+ *
1356
1435
  * @throws {Error} If packet cloning fails
1357
1436
  *
1358
1437
  * @example
@@ -1385,6 +1464,16 @@ export class Demuxer {
1385
1464
  while (!this.isClosed) {
1386
1465
  const ret = this.formatContext.readFrameSync(packet);
1387
1466
  if (ret < 0) {
1467
+ // No data available yet (live device capture) - blocking retry
1468
+ if (ret === AVERROR_EAGAIN) {
1469
+ Atomics.wait(SYNC_SLEEP_SIGNAL, 0, 0, 10);
1470
+ continue;
1471
+ }
1472
+ // Real read errors propagate; end-of-file and a deliberate
1473
+ // interrupt() (AVERROR_EXIT) end cleanly
1474
+ if (!FFmpegError.is(ret, AVERROR_EOF) && !FFmpegError.is(ret, AVERROR_EXIT)) {
1475
+ throw new FFmpegError(ret);
1476
+ }
1388
1477
  break;
1389
1478
  }
1390
1479
  // Get stream for timestamp processing
@@ -1531,13 +1620,26 @@ export class Demuxer {
1531
1620
  const ts = BigInt(Math.floor(timestamp * 1000000));
1532
1621
  return this.formatContext.seekFrameSync(streamIndex, ts, flags);
1533
1622
  }
1623
+ /**
1624
+ * Wake the demux thread if it is parked waiting for a full queue to drain.
1625
+ *
1626
+ * @internal
1627
+ */
1628
+ wakeDemuxThread() {
1629
+ const resolve = this.drainResolver;
1630
+ if (resolve) {
1631
+ this.drainResolver = null;
1632
+ resolve();
1633
+ }
1634
+ }
1534
1635
  /**
1535
1636
  * Interrupt a blocking read without closing the demuxer.
1536
1637
  *
1537
1638
  * Aborts any in-progress `av_read_frame()` (e.g. on a quiet RTSP/network source
1538
- * that is waiting for data) and signals end-of-stream to packet consumers, so
1539
- * an active pipeline can drain and finish. The demuxer is not freed and its
1540
- * streams stay valid - call {@link close} afterwards to release resources.
1639
+ * or a stalled Readable/custom I/O source that is waiting for data) and signals
1640
+ * end-of-stream to packet consumers, so an active pipeline can drain and finish.
1641
+ * The demuxer is not freed and its streams stay valid - call {@link close}
1642
+ * afterwards to release resources.
1541
1643
  *
1542
1644
  * Mainly used by pipeline teardown: a blocking read only unblocks on close, but
1543
1645
  * closing while the pipeline is still draining would free the context too early,
@@ -1561,8 +1663,11 @@ export class Demuxer {
1561
1663
  this.formatContext.interrupt();
1562
1664
  // Signal EOF to consumers and wake any generator parked on an empty queue.
1563
1665
  this.demuxEof = true;
1564
- for (const resolve of this.queueResolvers.values()) {
1565
- resolve();
1666
+ this.wakeDemuxThread();
1667
+ for (const resolvers of this.queueResolvers.values()) {
1668
+ for (const resolve of resolvers) {
1669
+ resolve();
1670
+ }
1566
1671
  }
1567
1672
  this.queueResolvers.clear();
1568
1673
  }
@@ -1610,10 +1715,21 @@ export class Demuxer {
1610
1715
  await new Promise((resolve) => setTimeout(resolve, 10));
1611
1716
  continue;
1612
1717
  }
1613
- // Actual end of stream - notify all waiting consumers
1718
+ // Anything but a genuine end-of-file is a real read error (I/O
1719
+ // failure, dropped network source, ...). Store it so consumers throw
1720
+ // instead of seeing a clean EOF - matching FFmpeg CLI, where a read
1721
+ // error ends processing with a failure, not a normal finish.
1722
+ // AVERROR_EXIT is exempt: it is how interrupt() aborts a blocking
1723
+ // read on purpose (pipeline stop/teardown), not a source failure.
1724
+ if (!FFmpegError.is(ret, AVERROR_EOF) && !FFmpegError.is(ret, AVERROR_EXIT)) {
1725
+ this.lastError = new FFmpegError(ret);
1726
+ }
1727
+ // End of stream (or error) - notify all waiting consumers
1614
1728
  this.demuxEof = true;
1615
- for (const resolve of this.queueResolvers.values()) {
1616
- resolve();
1729
+ for (const resolvers of this.queueResolvers.values()) {
1730
+ for (const resolve of resolvers) {
1731
+ resolve();
1732
+ }
1617
1733
  }
1618
1734
  this.queueResolvers.clear();
1619
1735
  break;
@@ -1678,7 +1794,10 @@ export class Demuxer {
1678
1794
  if (!queueFull) {
1679
1795
  break;
1680
1796
  }
1681
- await new Promise(setImmediate);
1797
+ // Wait until a consumer frees a slot or the demuxer shuts down. Polling here would keep a CPU core busy.
1798
+ const { promise: drained, resolve: onDrain } = Promise.withResolvers();
1799
+ this.drainResolver = onDrain;
1800
+ await drained;
1682
1801
  }
1683
1802
  if (abandoned || !this.demuxThreadActive || this.isClosed) {
1684
1803
  packet.unref();
@@ -1700,10 +1819,14 @@ export class Demuxer {
1700
1819
  throw new Error('Failed to clone packet in demux thread (out of memory)');
1701
1820
  }
1702
1821
  target.queue.push(clone);
1703
- const resolver = this.queueResolvers.get(target.key);
1704
- if (resolver) {
1705
- resolver();
1822
+ // Wake every consumer waiting on this key - only one gets the packet,
1823
+ // the others re-park, but a lost wakeup would deadlock them.
1824
+ const resolvers = this.queueResolvers.get(target.key);
1825
+ if (resolvers) {
1706
1826
  this.queueResolvers.delete(target.key);
1827
+ for (const resolve of resolvers) {
1828
+ resolve();
1829
+ }
1707
1830
  }
1708
1831
  }
1709
1832
  packet.unref();
@@ -1727,9 +1850,12 @@ export class Demuxer {
1727
1850
  async stopDemuxThread() {
1728
1851
  this.demuxThreadActive = false;
1729
1852
  this.demuxEof = true;
1853
+ this.wakeDemuxThread();
1730
1854
  // Wake up any waiting generators
1731
- for (const resolve of this.queueResolvers.values()) {
1732
- resolve();
1855
+ for (const resolvers of this.queueResolvers.values()) {
1856
+ for (const resolve of resolvers) {
1857
+ resolve();
1858
+ }
1733
1859
  }
1734
1860
  this.queueResolvers.clear();
1735
1861
  // Wait for demux thread with timeout to avoid hanging on blocked reads
@@ -1993,6 +2119,8 @@ export class Demuxer {
1993
2119
  * Close demuxer and free resources.
1994
2120
  *
1995
2121
  * Releases format context and I/O context.
2122
+ * A read waiting for a stalled Readable or custom I/O source is abandoned,
2123
+ * so closing never waits for the source to deliver.
1996
2124
  * Safe to call multiple times.
1997
2125
  * Automatically called by Symbol.asyncDispose.
1998
2126
  *
@@ -2022,18 +2150,19 @@ export class Demuxer {
2022
2150
  this.demuxThreadActive = false;
2023
2151
  // Set EOF flag so generators know to exit
2024
2152
  this.demuxEof = true;
2153
+ this.wakeDemuxThread();
2025
2154
  // Wake up all waiting generators BEFORE closing format context
2026
2155
  // This ensures generators can exit cleanly even if readFrame() is blocking
2027
- for (const resolve of this.queueResolvers.values()) {
2028
- resolve();
2156
+ for (const resolvers of this.queueResolvers.values()) {
2157
+ for (const resolve of resolvers) {
2158
+ resolve();
2159
+ }
2029
2160
  }
2030
2161
  this.queueResolvers.clear();
2031
- // Clear pb reference to prevent use-after-free
2032
- if (this.ioContext) {
2033
- this.formatContext.pb = null;
2034
- }
2035
- // Close FormatContext - this may interrupt blocking readFrame()
2036
- await this.formatContext.closeInput();
2162
+ // Close FormatContext - this may interrupt blocking readFrame(). A custom
2163
+ // pb is detached inside, after the reader thread stopped: nulling it from
2164
+ // here would pull it out from under a running av_read_frame()
2165
+ await this.formatContext.closeInput(!!this.ioContext);
2037
2166
  // Wait for demux thread with timeout to avoid hanging on blocked reads
2038
2167
  if (this.demuxThread) {
2039
2168
  const threadPromise = this.demuxThread;
@@ -2055,9 +2184,18 @@ export class Demuxer {
2055
2184
  }
2056
2185
  this.packetQueues.clear();
2057
2186
  this.packetQueueConsumers.clear();
2058
- // NOW we can safely free the IOContext
2187
+ // NOW we can safely release the IOContext. Contexts we built ourselves are
2188
+ // callback-backed and go through freeContext(), which IOStream hooks to
2189
+ // detach its stream listeners. A caller-supplied context may instead own a
2190
+ // protocol handle from open2(); dispose picks avio_closep() for those, as
2191
+ // freeing them alone leaks the handle and keeps the file locked on Windows.
2059
2192
  if (this.ioContext) {
2060
- this.ioContext.freeContext();
2193
+ if (this.externalIO) {
2194
+ await this.ioContext[Symbol.asyncDispose]();
2195
+ }
2196
+ else {
2197
+ this.ioContext.freeContext();
2198
+ }
2061
2199
  this.ioContext = undefined;
2062
2200
  }
2063
2201
  }
@@ -2066,6 +2204,7 @@ export class Demuxer {
2066
2204
  * Synchronous version of close.
2067
2205
  *
2068
2206
  * Releases format context and I/O context.
2207
+ * A read waiting for a stalled Readable or custom I/O source is abandoned.
2069
2208
  * Safe to call multiple times.
2070
2209
  * Automatically called by Symbol.dispose.
2071
2210
  *
@@ -2091,13 +2230,19 @@ export class Demuxer {
2091
2230
  // Clean up abort signal listener
2092
2231
  this.signalCleanup?.();
2093
2232
  this.signalCleanup = undefined;
2094
- // IMPORTANT: Clear pb reference FIRST to prevent use-after-free
2095
- if (this.ioContext) {
2096
- this.formatContext.pb = null;
2097
- }
2098
- // Close FormatContext
2099
- this.formatContext.closeInputSync();
2233
+ // Stop the demux thread and wake generators parked on an empty queue: they
2234
+ // resume once this returns and end, as in close()
2100
2235
  this.demuxThreadActive = false;
2236
+ this.demuxEof = true;
2237
+ this.wakeDemuxThread();
2238
+ for (const resolvers of this.queueResolvers.values()) {
2239
+ for (const resolve of resolvers) {
2240
+ resolve();
2241
+ }
2242
+ }
2243
+ this.queueResolvers.clear();
2244
+ // Close FormatContext; a custom pb is detached inside so it is not closed with the input
2245
+ this.formatContext.closeInputSync(!!this.ioContext);
2101
2246
  for (const queue of this.packetQueues.values()) {
2102
2247
  for (const packet of queue) {
2103
2248
  packet.free();
@@ -2105,11 +2250,15 @@ export class Demuxer {
2105
2250
  queue.length = 0;
2106
2251
  }
2107
2252
  this.packetQueues.clear();
2108
- this.queueResolvers.clear();
2109
- this.demuxEof = false;
2110
- // NOW we can safely free the IOContext
2253
+ this.packetQueueConsumers.clear();
2254
+ // NOW we can safely release the IOContext (see close() for why dispose)
2111
2255
  if (this.ioContext) {
2112
- this.ioContext.freeContext();
2256
+ if (this.externalIO) {
2257
+ this.ioContext[Symbol.dispose]();
2258
+ }
2259
+ else {
2260
+ this.ioContext.freeContext();
2261
+ }
2113
2262
  this.ioContext = undefined;
2114
2263
  }
2115
2264
  }