node-av 6.2.0-beta.2 → 6.2.0-beta.21

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 (93) hide show
  1. package/README.md +13 -8
  2. package/dist/api/audio-frame-buffer.d.ts +73 -1
  3. package/dist/api/audio-frame-buffer.js +127 -1
  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 +15 -1
  12. package/dist/api/demuxer.js +165 -54
  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 +19 -0
  18. package/dist/api/encoder.js +112 -45
  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 +167 -3
  23. package/dist/api/fmp4-stream.js +423 -90
  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/io-stream.d.ts +3 -2
  29. package/dist/api/io-stream.js +61 -5
  30. package/dist/api/io-stream.js.map +1 -1
  31. package/dist/api/muxer.d.ts +14 -0
  32. package/dist/api/muxer.js +131 -51
  33. package/dist/api/muxer.js.map +1 -1
  34. package/dist/api/pipeline.d.ts +24 -0
  35. package/dist/api/pipeline.js +76 -5
  36. package/dist/api/pipeline.js.map +1 -1
  37. package/dist/api/rtp-stream.d.ts +56 -6
  38. package/dist/api/rtp-stream.js +252 -80
  39. package/dist/api/rtp-stream.js.map +1 -1
  40. package/dist/api/scaler.d.ts +105 -17
  41. package/dist/api/scaler.js +326 -108
  42. package/dist/api/scaler.js.map +1 -1
  43. package/dist/api/utilities/async-queue.d.ts +3 -0
  44. package/dist/api/utilities/async-queue.js +8 -0
  45. package/dist/api/utilities/async-queue.js.map +1 -1
  46. package/dist/api/webrtc-stream.d.ts +2 -0
  47. package/dist/api/webrtc-stream.js +46 -12
  48. package/dist/api/webrtc-stream.js.map +1 -1
  49. package/dist/api/whisper.d.ts +2 -0
  50. package/dist/api/whisper.js +63 -23
  51. package/dist/api/whisper.js.map +1 -1
  52. package/dist/constants/constants.d.ts +1 -0
  53. package/dist/constants/constants.js +1 -0
  54. package/dist/constants/constants.js.map +1 -1
  55. package/dist/constants/filter-options.d.ts +2 -0
  56. package/dist/ffmpeg/install.js +115 -29
  57. package/dist/ffmpeg/install.js.map +1 -1
  58. package/dist/ffmpeg/utils.js +7 -4
  59. package/dist/ffmpeg/utils.js.map +1 -1
  60. package/dist/lib/binding.d.ts +1 -0
  61. package/dist/lib/binding.js +12 -16
  62. package/dist/lib/binding.js.map +1 -1
  63. package/dist/lib/codec-context.d.ts +0 -2
  64. package/dist/lib/codec-context.js +4 -22
  65. package/dist/lib/codec-context.js.map +1 -1
  66. package/dist/lib/filter-context.d.ts +0 -1
  67. package/dist/lib/filter-context.js +2 -11
  68. package/dist/lib/filter-context.js.map +1 -1
  69. package/dist/lib/format-context.d.ts +63 -7
  70. package/dist/lib/format-context.js +70 -21
  71. package/dist/lib/format-context.js.map +1 -1
  72. package/dist/lib/frame.d.ts +11 -11
  73. package/dist/lib/frame.js +13 -20
  74. package/dist/lib/frame.js.map +1 -1
  75. package/dist/lib/hardware-frames-context.d.ts +0 -1
  76. package/dist/lib/hardware-frames-context.js +2 -8
  77. package/dist/lib/hardware-frames-context.js.map +1 -1
  78. package/dist/lib/io-context.d.ts +4 -2
  79. package/dist/lib/io-context.js +4 -2
  80. package/dist/lib/io-context.js.map +1 -1
  81. package/dist/lib/native-types.d.ts +4 -4
  82. package/dist/lib/option.d.ts +26 -0
  83. package/dist/lib/option.js +42 -18
  84. package/dist/lib/option.js.map +1 -1
  85. package/dist/lib/packet.d.ts +5 -0
  86. package/dist/lib/packet.js +5 -0
  87. package/dist/lib/packet.js.map +1 -1
  88. package/dist/lib/stream.d.ts +3 -4
  89. package/dist/lib/stream.js +17 -24
  90. package/dist/lib/stream.js.map +1 -1
  91. package/install/check.js +40 -79
  92. package/package.json +43 -32
  93. package/build_mac_local.sh +0 -69
@@ -1,9 +1,9 @@
1
- import { AV_CODEC_FLAG_QSCALE, AV_HWDEVICE_TYPE_OPENCL, AV_PIX_FMT_GRAY8, AV_PIX_FMT_NONE, AV_PIX_FMT_NV12, AV_PIX_FMT_RGB24, AV_PIX_FMT_RGBA, AV_PIX_FMT_YUV420P, AVCOL_RANGE_JPEG, FF_QP2LAMBDA, SWS_BILINEAR, } from '../constants/constants.js';
1
+ import { AV_CODEC_FLAG_QSCALE, AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_OPENCL, AV_HWDEVICE_TYPE_QSV, AV_HWDEVICE_TYPE_VAAPI, AV_PIX_FMT_GRAY8, AV_PIX_FMT_NV12, AV_PIX_FMT_RGB24, AV_PIX_FMT_RGBA, AV_PIX_FMT_YUV420P, AVCOL_RANGE_JPEG, FF_QP2LAMBDA, SWS_BILINEAR, } from '../constants/constants.js';
2
2
  import { FF_ENCODER_MJPEG, FF_ENCODER_PNG } from '../constants/encoders.js';
3
3
  import { bindings } from '../lib/binding.js';
4
4
  import { FFmpegError } from '../lib/error.js';
5
5
  import { Frame } from '../lib/frame.js';
6
- import { EncoderPool } from './encoder-pool.js';
6
+ import { EncoderPool, Mutex } from './encoder-pool.js';
7
7
  import { FilterPreset } from './filter-presets.js';
8
8
  import { FilterAPI } from './filter.js';
9
9
  const FORMAT_TO_PIXFMT = {
@@ -23,6 +23,10 @@ const FORMAT_TO_PIXFMT = {
23
23
  * Software frames are scaled with swscale; hardware frames are cropped, scaled,
24
24
  * and converted on the GPU with only the small result downloaded.
25
25
  *
26
+ * Safe for concurrent use: calls that share a cached graph or pooled encoder are
27
+ * serialized on that resource, while distinct output configurations run fully in
28
+ * parallel, and cache eviction never disposes a resource that is still in flight.
29
+ *
26
30
  * @example
27
31
  * ```typescript
28
32
  * import { Scaler } from 'node-av/api';
@@ -41,13 +45,14 @@ const FORMAT_TO_PIXFMT = {
41
45
  */
42
46
  export class Scaler {
43
47
  native;
48
+ syncNative;
49
+ nativeLock = new Mutex();
44
50
  hardware;
45
51
  flags;
46
52
  maxCacheSize;
47
53
  graphs = new Map();
48
54
  jpegPool;
49
55
  pngPool;
50
- downloadFrame;
51
56
  disposed = false;
52
57
  /**
53
58
  * Create a new scaler.
@@ -103,9 +108,15 @@ export class Scaler {
103
108
  out.free();
104
109
  }
105
110
  }
106
- return await this.native.process((await this.downloadToSoftware(frame)).getNative(), options);
111
+ const sw = await this.downloadToSoftware(frame);
112
+ try {
113
+ return await this.processNative(sw, options);
114
+ }
115
+ finally {
116
+ sw.free();
117
+ }
107
118
  }
108
- return await this.native.process(frame.getNative(), options);
119
+ return await this.processNative(frame, options);
109
120
  }
110
121
  /**
111
122
  * Scale, crop, and/or convert a frame and return the raw pixel data.
@@ -140,9 +151,15 @@ export class Scaler {
140
151
  out.free();
141
152
  }
142
153
  }
143
- return this.native.processSync(this.downloadToSoftwareSync(frame).getNative(), options);
154
+ const sw = this.downloadToSoftwareSync(frame);
155
+ try {
156
+ return this.getSyncNative().processSync(sw.getNative(), options);
157
+ }
158
+ finally {
159
+ sw.free();
160
+ }
144
161
  }
145
- return this.native.processSync(frame.getNative(), options);
162
+ return this.getSyncNative().processSync(frame.getNative(), options);
146
163
  }
147
164
  /**
148
165
  * Scale/crop a frame and encode it to a JPEG image.
@@ -172,16 +189,21 @@ export class Scaler {
172
189
  throw new Error('Scaler has been disposed');
173
190
  }
174
191
  const quality = options.quality ?? 90;
175
- const src = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : frame;
176
- // mjpeg wants planar YUV; tag it full-range so colors are correct without the
177
- // deprecated yuvj* formats.
178
- const out = await this.toFrame(src, options.crop, options.resize, 'yuv420p');
192
+ const downloaded = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : null;
179
193
  try {
180
- this.prepareJpegFrame(out, quality);
181
- return await this.getJpegPool().encode(out);
194
+ // mjpeg wants planar YUV; tag it full-range so colors are correct without the
195
+ // deprecated yuvj* formats.
196
+ const out = await this.toFrame(downloaded ?? frame, options.crop, options.resize, 'yuv420p');
197
+ try {
198
+ this.prepareJpegFrame(out, quality);
199
+ return await this.getJpegPool().encode(out);
200
+ }
201
+ finally {
202
+ out.free();
203
+ }
182
204
  }
183
205
  finally {
184
- out.free();
206
+ downloaded?.free();
185
207
  }
186
208
  }
187
209
  /**
@@ -208,14 +230,19 @@ export class Scaler {
208
230
  throw new Error('Scaler has been disposed');
209
231
  }
210
232
  const quality = options.quality ?? 90;
211
- const src = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : frame;
212
- const out = this.toFrameSync(src, options.crop, options.resize, 'yuv420p');
233
+ const downloaded = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : null;
213
234
  try {
214
- this.prepareJpegFrame(out, quality);
215
- return this.getJpegPool().encodeSync(out);
235
+ const out = this.toFrameSync(downloaded ?? frame, options.crop, options.resize, 'yuv420p');
236
+ try {
237
+ this.prepareJpegFrame(out, quality);
238
+ return this.getJpegPool().encodeSync(out);
239
+ }
240
+ finally {
241
+ out.free();
242
+ }
216
243
  }
217
244
  finally {
218
- out.free();
245
+ downloaded?.free();
219
246
  }
220
247
  }
221
248
  /**
@@ -245,13 +272,18 @@ export class Scaler {
245
272
  throw new Error('Scaler has been disposed');
246
273
  }
247
274
  const format = options.format ?? 'rgb';
248
- const src = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : frame;
249
- const out = await this.toFrame(src, options.crop, options.resize, format);
275
+ const downloaded = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : null;
250
276
  try {
251
- return await this.getPngPool().encode(out);
277
+ const out = await this.toFrame(downloaded ?? frame, options.crop, options.resize, format);
278
+ try {
279
+ return await this.getPngPool().encode(out);
280
+ }
281
+ finally {
282
+ out.free();
283
+ }
252
284
  }
253
285
  finally {
254
- out.free();
286
+ downloaded?.free();
255
287
  }
256
288
  }
257
289
  /**
@@ -278,13 +310,18 @@ export class Scaler {
278
310
  throw new Error('Scaler has been disposed');
279
311
  }
280
312
  const format = options.format ?? 'rgb';
281
- const src = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : frame;
282
- const out = this.toFrameSync(src, options.crop, options.resize, format);
313
+ const downloaded = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : null;
283
314
  try {
284
- return this.getPngPool().encodeSync(out);
315
+ const out = this.toFrameSync(downloaded ?? frame, options.crop, options.resize, format);
316
+ try {
317
+ return this.getPngPool().encodeSync(out);
318
+ }
319
+ finally {
320
+ out.free();
321
+ }
285
322
  }
286
323
  finally {
287
- out.free();
324
+ downloaded?.free();
288
325
  }
289
326
  }
290
327
  /**
@@ -301,34 +338,41 @@ export class Scaler {
301
338
  }
302
339
  this.disposed = true;
303
340
  this.native.close();
341
+ this.syncNative?.close();
342
+ this.syncNative = undefined;
343
+ // In-use graphs are retired, not disposed: the last in-flight scale disposes
344
+ // them on release, so close() during concurrent calls never crashes them.
304
345
  for (const entry of this.graphs.values()) {
305
- entry.filter[Symbol.dispose]();
346
+ this.retireEntry(entry);
306
347
  }
307
348
  this.graphs.clear();
308
349
  this.jpegPool?.close();
309
350
  this.jpegPool = undefined;
310
351
  this.pngPool?.close();
311
352
  this.pngPool = undefined;
312
- this.downloadFrame?.free();
313
- this.downloadFrame = undefined;
314
353
  }
315
354
  /**
316
355
  * Download a hardware frame to system memory (no hardware context available).
317
356
  *
318
- * Reuses a single target frame across calls. The frame's own hwframe context
319
- * selects a supported software format.
357
+ * Allocates a fresh target per call - concurrent downloads must not share a
358
+ * staging frame. The frame's own hwframe context selects a supported software
359
+ * format. The caller frees the returned frame.
320
360
  *
321
361
  * @param frame - Hardware source frame
322
362
  *
323
- * @returns Software frame
363
+ * @returns Software frame (caller frees)
324
364
  *
325
365
  * @throws {FFmpegError} If the transfer fails
326
366
  *
327
367
  * @internal
328
368
  */
329
369
  async downloadToSoftware(frame) {
330
- const dst = this.prepareDownloadTarget();
370
+ const dst = new Frame();
371
+ dst.alloc();
331
372
  const ret = await frame.hwframeTransferData(dst, 0);
373
+ if (ret < 0) {
374
+ dst.free();
375
+ }
332
376
  FFmpegError.throwIfError(ret, 'Failed to download hardware frame');
333
377
  dst.copyProps(frame);
334
378
  return dst;
@@ -338,35 +382,56 @@ export class Scaler {
338
382
  *
339
383
  * @param frame - Hardware source frame
340
384
  *
341
- * @returns Software frame
385
+ * @returns Software frame (caller frees)
342
386
  *
343
387
  * @throws {FFmpegError} If the transfer fails
344
388
  *
345
389
  * @internal
346
390
  */
347
391
  downloadToSoftwareSync(frame) {
348
- const dst = this.prepareDownloadTarget();
392
+ const dst = new Frame();
393
+ dst.alloc();
349
394
  const ret = frame.hwframeTransferDataSync(dst, 0);
395
+ if (ret < 0) {
396
+ dst.free();
397
+ }
350
398
  FFmpegError.throwIfError(ret, 'Failed to download hardware frame');
351
399
  dst.copyProps(frame);
352
400
  return dst;
353
401
  }
354
402
  /**
355
- * Lazily allocate and reset the reused download target frame.
403
+ * Run the pooled native software scaler, serialized per instance.
404
+ *
405
+ * The native scaler reuses staging frames and sws contexts per configuration;
406
+ * overlapping async calls would race on them in the worker threads.
407
+ *
408
+ * @param frame - Software source frame
356
409
  *
357
- * @returns The cleared download frame
410
+ * @param options - Crop, resize, and format options
411
+ *
412
+ * @returns Tightly packed pixel data
413
+ *
414
+ * @throws {FFmpegError} If scaling fails
358
415
  *
359
416
  * @internal
360
417
  */
361
- prepareDownloadTarget() {
362
- if (!this.downloadFrame) {
363
- this.downloadFrame = new Frame();
364
- this.downloadFrame.alloc();
365
- }
366
- const dst = this.downloadFrame;
367
- dst.unref();
368
- dst.format = AV_PIX_FMT_NONE;
369
- return dst;
418
+ async processNative(frame, options) {
419
+ return await this.nativeLock.run(() => this.native.process(frame.getNative(), options));
420
+ }
421
+ /**
422
+ * The native scaler used by the sync path (lazily created).
423
+ *
424
+ * Sync calls block the event loop but can land while an async call's worker is
425
+ * scaling in the background; a separate native instance keeps them off the async
426
+ * scaler's pooled contexts, which are not safe to share across threads.
427
+ *
428
+ * @returns The sync-path native scaler
429
+ *
430
+ * @internal
431
+ */
432
+ getSyncNative() {
433
+ this.syncNative ??= new bindings.Scaler(this.flags);
434
+ return this.syncNative;
370
435
  }
371
436
  /**
372
437
  * Scale/crop/convert a frame through a cached filter graph and return the
@@ -392,27 +457,68 @@ export class Scaler {
392
457
  * @internal
393
458
  */
394
459
  async toFrame(frame, crop, resize, format) {
395
- const { entry, key } = this.resolveGraph(frame, crop, resize, format);
396
- await entry.filter.process(frame);
397
- let out = await entry.filter.receive();
398
- if (!(out instanceof Frame)) {
399
- // Some hardware scalers (notably QSV's vpp_qsv) buffer internally and don't
400
- // emit a frame synchronously after a single input - the buffersink returns
401
- // EAGAIN. Flush to force the pending frame out, then evict the now-EOF'd
402
- // graph so the next call rebuilds it. Zero-latency scalers (scale_vaapi,
403
- // scale_vt) deliver on the first receive and never hit this path.
404
- await entry.filter.flush();
405
- out = await entry.filter.receive();
406
- this.evictGraph(key);
407
- }
408
- if (!(out instanceof Frame)) {
409
- throw new Error('Scaler: filter produced no frame');
410
- }
411
- return out;
460
+ // Retry loop: an entry can be retired while we queue on its lock (QSV flush
461
+ // eviction, mid-stream failure, or LRU pressure from concurrent calls). The
462
+ // next attempt then resolves a freshly built graph.
463
+ for (;;) {
464
+ if (this.disposed) {
465
+ throw new Error('Scaler has been disposed');
466
+ }
467
+ const acq = this.resolveGraph(frame, crop, resize, format);
468
+ const { entry, key } = acq;
469
+ // Hold the entry across the awaits below: eviction of an in-use graph is
470
+ // deferred to releaseGraph(), never disposing it mid-scale.
471
+ entry.refs++;
472
+ try {
473
+ // Serialize crop command + process + receive per graph: concurrent scales
474
+ // on the same output config would otherwise interleave on one FilterAPI
475
+ // and hand frames to the wrong caller.
476
+ const out = await entry.lock.run(async () => {
477
+ if (entry.evicted) {
478
+ return null; // Retired while we queued - retry with a fresh graph
479
+ }
480
+ this.prepareGraph(acq, frame);
481
+ try {
482
+ await entry.filter.process(frame);
483
+ let received = await entry.filter.receive();
484
+ if (!(received instanceof Frame)) {
485
+ // Some hardware scalers (notably QSV's vpp_qsv) buffer internally and don't
486
+ // emit a frame synchronously after a single input - the buffersink returns
487
+ // EAGAIN. Flush to force the pending frame out, then evict the now-EOF'd
488
+ // graph so the next call rebuilds it. Zero-latency scalers (scale_vaapi,
489
+ // scale_vt) deliver on the first receive and never hit this path.
490
+ await entry.filter.flush();
491
+ received = await entry.filter.receive();
492
+ this.evictGraph(key, entry);
493
+ }
494
+ if (!(received instanceof Frame)) {
495
+ throw new Error('Scaler: filter produced no frame');
496
+ }
497
+ return received;
498
+ }
499
+ catch (error) {
500
+ // The graph is in an unknown mid-stream state; retire it so the next
501
+ // call rebuilds instead of inheriting the wreckage.
502
+ this.evictGraph(key, entry);
503
+ throw error;
504
+ }
505
+ });
506
+ if (out) {
507
+ return out;
508
+ }
509
+ }
510
+ finally {
511
+ this.releaseGraph(entry);
512
+ }
513
+ }
412
514
  }
413
515
  /**
414
516
  * Synchronous version of toFrame.
415
517
  *
518
+ * Sync scales cannot interleave with each other (they block the event loop),
519
+ * but they can land between the awaits of an in-flight async scale; a busy
520
+ * cached graph is bypassed with a throwaway graph in that case.
521
+ *
416
522
  * @param frame - Source frame (software, or hardware with a hardware context)
417
523
  *
418
524
  * @param crop - Crop region, or undefined for the full frame
@@ -426,25 +532,67 @@ export class Scaler {
426
532
  * @internal
427
533
  */
428
534
  toFrameSync(frame, crop, resize, format) {
429
- const { entry, key } = this.resolveGraph(frame, crop, resize, format);
430
- entry.filter.processSync(frame);
431
- let out = entry.filter.receiveSync();
432
- if (!(out instanceof Frame)) {
433
- // See toFrame: QSV's vpp_qsv buffers a frame; flush to drain it and evict
434
- // the now-EOF'd graph so the next call rebuilds.
435
- entry.filter.flushSync();
436
- out = entry.filter.receiveSync();
437
- this.evictGraph(key);
438
- }
439
- if (!(out instanceof Frame)) {
440
- throw new Error('Scaler: filter produced no frame');
441
- }
442
- return out;
535
+ const acq = this.resolveGraph(frame, crop, resize, format);
536
+ const { entry, key } = acq;
537
+ if (entry.refs > 0) {
538
+ return this.scaleOneShotSync(frame, acq);
539
+ }
540
+ this.prepareGraph(acq, frame);
541
+ try {
542
+ entry.filter.processSync(frame);
543
+ let out = entry.filter.receiveSync();
544
+ if (!(out instanceof Frame)) {
545
+ // See toFrame: QSV's vpp_qsv buffers a frame; flush to drain it and evict
546
+ // the now-EOF'd graph so the next call rebuilds.
547
+ entry.filter.flushSync();
548
+ out = entry.filter.receiveSync();
549
+ this.evictGraph(key, entry);
550
+ }
551
+ if (!(out instanceof Frame)) {
552
+ throw new Error('Scaler: filter produced no frame');
553
+ }
554
+ return out;
555
+ }
556
+ catch (error) {
557
+ this.evictGraph(key, entry);
558
+ throw error;
559
+ }
443
560
  }
444
561
  /**
445
- * Resolve (and, on demand, build) the cached filter graph for a frame, applying
446
- * the crop reconfiguration. Shared by the sync and async scaling paths; all of
447
- * this work (parse, config, sendCommand) is synchronous.
562
+ * Scale through a throwaway graph with the crop baked in, bypassing the cache.
563
+ *
564
+ * @param frame - Source frame
565
+ *
566
+ * @param acq - Resolved operation configuration
567
+ *
568
+ * @returns The scaled output frame (caller frees)
569
+ *
570
+ * @throws {Error} If the graph produces no frame
571
+ *
572
+ * @internal
573
+ */
574
+ scaleOneShotSync(frame, acq) {
575
+ const filter = FilterAPI.create(this.buildGraph(acq.isHw, acq.outW, acq.outH, acq.format, acq.crop, false), acq.isHw ? { hardware: this.hardware } : {});
576
+ try {
577
+ filter.processSync(frame);
578
+ let out = filter.receiveSync();
579
+ if (!(out instanceof Frame)) {
580
+ filter.flushSync();
581
+ out = filter.receiveSync();
582
+ }
583
+ if (!(out instanceof Frame)) {
584
+ throw new Error('Scaler: filter produced no frame');
585
+ }
586
+ return out;
587
+ }
588
+ finally {
589
+ filter[Symbol.dispose]();
590
+ }
591
+ }
592
+ /**
593
+ * Resolve (and, on demand, build) the cached filter graph for a frame. Shared
594
+ * by the sync and async scaling paths; all of this work is synchronous, so a
595
+ * resolved entry cannot be evicted before the caller marks it in use.
448
596
  *
449
597
  * @param frame - Source frame (software, or hardware with a hardware context)
450
598
  *
@@ -454,7 +602,7 @@ export class Scaler {
454
602
  *
455
603
  * @param format - Output pixel format
456
604
  *
457
- * @returns The cached graph entry (ready to process the frame) and its cache key
605
+ * @returns The cached graph entry and the resolved operation configuration
458
606
  *
459
607
  * @throws {Error} If the crop is out of bounds
460
608
  *
@@ -488,7 +636,7 @@ export class Scaler {
488
636
  // on the first processed frame, before any command can be sent, so the
489
637
  // initial crop must already be in place. Later crops use sendCommand.
490
638
  const filter = FilterAPI.create(this.buildGraph(isHw, outW, outH, format, cropRegion, commandable), isHw ? { hardware: this.hardware } : {});
491
- entry = { filter, lastCrop: commandable ? { x: cropX, y: cropY, w: cropW, h: cropH } : null };
639
+ entry = { filter, lastCrop: commandable ? { x: cropX, y: cropY, w: cropW, h: cropH } : null, lastSrc: null, lock: new Mutex(), refs: 0, evicted: false };
492
640
  this.cacheSet(key, entry);
493
641
  }
494
642
  else {
@@ -496,38 +644,95 @@ export class Scaler {
496
644
  this.graphs.delete(key);
497
645
  this.graphs.set(key, entry);
498
646
  }
499
- if (commandable) {
500
- // Reconfigure the crop only when it actually changed - identical crops reuse
501
- // the graph untouched (no config_input/config_output re-run).
502
- const c = entry.lastCrop;
503
- if (c?.x !== cropX || c?.y !== cropY || c?.w !== cropW || c?.h !== cropH) {
504
- // w/h are validated against the input dims (above); x/y are clamped per
505
- // frame by the crop filter, so this order never produces a transient error.
506
- entry.filter.sendCommand('crop@sc', 'w', String(cropW));
507
- entry.filter.sendCommand('crop@sc', 'h', String(cropH));
508
- entry.filter.sendCommand('crop@sc', 'x', String(cropX));
509
- entry.filter.sendCommand('crop@sc', 'y', String(cropY));
510
- entry.lastCrop = { x: cropX, y: cropY, w: cropW, h: cropH };
511
- }
512
- }
513
- return { entry, key };
647
+ return { entry, key, isHw, commandable, crop: cropRegion, outW, outH, format };
514
648
  }
515
649
  /**
516
- * Dispose a cached graph and remove it from the cache.
650
+ * Bring a cached graph in line with the current call. Must run under the entry
651
+ * lock (async path) or with no async call in flight (sync path).
517
652
  *
518
- * Used after a graph has been flushed (and is therefore at EOF and can no
519
- * longer accept frames) so the next operation rebuilds it.
653
+ * Rebuilds the graph when the source parameters changed: FilterAPI would
654
+ * otherwise reinitialize itself from the original graph description, silently
655
+ * resurrecting the crop baked in at build time instead of the current one. A
656
+ * graph that has not seen a frame yet is likewise rebuilt when the crop
657
+ * differs, because commands cannot be sent before initialization. Otherwise a
658
+ * changed crop is re-aimed with runtime commands - identical crops reuse the
659
+ * graph untouched (no config_input/config_output re-run).
520
660
  *
521
- * @param key - Cache key of the graph to evict
661
+ * @param acq - Resolved graph and operation configuration
662
+ *
663
+ * @param frame - Frame about to be processed
522
664
  *
523
665
  * @internal
524
666
  */
525
- evictGraph(key) {
526
- const entry = this.graphs.get(key);
527
- if (entry) {
667
+ prepareGraph(acq, frame) {
668
+ const { entry, isHw, commandable, crop, outW, outH, format } = acq;
669
+ const src = entry.lastSrc;
670
+ const srcChanged = src !== null && (src.width !== frame.width || src.height !== frame.height || src.format !== frame.format);
671
+ const c = entry.lastCrop;
672
+ const cropChanged = commandable && (c?.x !== crop.x || c?.y !== crop.y || c?.w !== crop.width || c?.h !== crop.height);
673
+ if (srcChanged || (cropChanged && src === null)) {
528
674
  entry.filter[Symbol.dispose]();
675
+ entry.filter = FilterAPI.create(this.buildGraph(isHw, outW, outH, format, crop, commandable), isHw ? { hardware: this.hardware } : {});
676
+ entry.lastCrop = commandable ? { x: crop.x, y: crop.y, w: crop.width, h: crop.height } : null;
677
+ }
678
+ else if (cropChanged) {
679
+ // w/h are validated against the input dims (in resolveGraph); x/y are clamped
680
+ // per frame by the crop filter, so this order never produces a transient error.
681
+ entry.filter.sendCommand('crop@sc', 'w', String(crop.width));
682
+ entry.filter.sendCommand('crop@sc', 'h', String(crop.height));
683
+ entry.filter.sendCommand('crop@sc', 'x', String(crop.x));
684
+ entry.filter.sendCommand('crop@sc', 'y', String(crop.y));
685
+ entry.lastCrop = { x: crop.x, y: crop.y, w: crop.width, h: crop.height };
686
+ }
687
+ entry.lastSrc = { width: frame.width, height: frame.height, format: frame.format };
688
+ }
689
+ /**
690
+ * Remove a graph from the cache and retire it.
691
+ *
692
+ * Identity-checked so a newer graph cached under the same key is left alone.
693
+ * Used after a flush (the graph is at EOF and can no longer accept frames) and
694
+ * after mid-stream failures, so the next operation rebuilds it.
695
+ *
696
+ * @param key - Cache key of the graph
697
+ *
698
+ * @param entry - The exact entry to evict
699
+ *
700
+ * @internal
701
+ */
702
+ evictGraph(key, entry) {
703
+ if (this.graphs.get(key) === entry) {
529
704
  this.graphs.delete(key);
530
705
  }
706
+ this.retireEntry(entry);
707
+ }
708
+ /**
709
+ * Take a graph out of service, disposing it now or on last release.
710
+ *
711
+ * An entry with in-flight scales is only marked; the final {@link releaseGraph}
712
+ * disposes the filter once nothing uses it anymore.
713
+ *
714
+ * @param entry - Entry removed from the cache
715
+ *
716
+ * @internal
717
+ */
718
+ retireEntry(entry) {
719
+ entry.evicted = true;
720
+ if (entry.refs === 0) {
721
+ entry.filter[Symbol.dispose]();
722
+ }
723
+ }
724
+ /**
725
+ * Drop an in-flight reference, disposing the graph if it was retired meanwhile.
726
+ *
727
+ * @param entry - Entry acquired by {@link toFrame}
728
+ *
729
+ * @internal
730
+ */
731
+ releaseGraph(entry) {
732
+ entry.refs--;
733
+ if (entry.refs === 0 && entry.evicted) {
734
+ entry.filter[Symbol.dispose]();
735
+ }
531
736
  }
532
737
  /**
533
738
  * Tag a frame for MJPEG encoding: full color range and a per-frame quality.
@@ -575,7 +780,9 @@ export class Scaler {
575
780
  * For software frames this is `crop,scale,format`. For hardware frames the crop
576
781
  * filter sets crop metadata that the hardware scaler (scale_vt/scale_cuda/
577
782
  * scale_vaapi) applies on the GPU; the result is kept in NV12, downloaded, and
578
- * converted on the CPU. When `commandable` the crop is a labeled instance
783
+ * converted on the CPU. Scalers with a same-size/same-format passthrough mode
784
+ * (scale_vaapi/scale_cuda/vpp_qsv) get `passthrough=0`, since skipping the scaler
785
+ * would drop the crop metadata unapplied. When `commandable` the crop is a labeled instance
579
786
  * (`crop@sc`) whose initial region is `crop` but which is re-aimed per frame via
580
787
  * `sendCommand`. Otherwise (OpenCL) the crop is fixed in the graph.
581
788
  *
@@ -604,7 +811,13 @@ export class Scaler {
604
811
  else {
605
812
  chain = chain.crop(crop.width, crop.height, crop.x, crop.y);
606
813
  }
607
- chain = chain.scale(outW, outH);
814
+ // The crop filter only sets crop metadata on hardware frames; the hw scaler
815
+ // materializes it. scale_vaapi/scale_cuda/vpp_qsv skip themselves entirely when
816
+ // input and output size/format match (decided at config time, before any crop
817
+ // command), which would silently drop the crop - so their passthrough is disabled.
818
+ const passthroughCapable = [AV_HWDEVICE_TYPE_VAAPI, AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_QSV];
819
+ const noPassthrough = isHw && passthroughCapable.includes(this.hardware.deviceType);
820
+ chain = chain.scale(outW, outH, noPassthrough ? { passthrough: 0 } : undefined);
608
821
  if (isHw) {
609
822
  // Keep the GPU result in NV12, download the small frame, then convert on CPU.
610
823
  chain = chain.scaleFormat(AV_PIX_FMT_NV12).hwdownload().format(AV_PIX_FMT_NV12);
@@ -633,8 +846,13 @@ export class Scaler {
633
846
  if (oldest === undefined) {
634
847
  break;
635
848
  }
636
- this.graphs.get(oldest)?.filter[Symbol.dispose]();
849
+ const evictee = this.graphs.get(oldest);
637
850
  this.graphs.delete(oldest);
851
+ if (evictee) {
852
+ // In-use graphs are retired, not disposed - the last in-flight scale
853
+ // disposes them on release.
854
+ this.retireEntry(evictee);
855
+ }
638
856
  }
639
857
  }
640
858
  /**