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
@@ -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.
@@ -73,15 +78,21 @@ export class Scaler {
73
78
  *
74
79
  * @param frame - Source frame (software or hardware; hardware frames are processed on the GPU and downloaded)
75
80
  *
76
- * @param options - Crop, resize, and format options
81
+ * @param options - Crop, resize, format, and output buffer options
77
82
  *
78
- * @returns Tightly packed pixel data
83
+ * @returns Tightly packed pixel data (a view of `options.output` when given)
79
84
  *
80
85
  * @throws {FFmpegError} If scaling fails
81
86
  *
87
+ * @throws {TypeError} If `options.output` is not a Buffer or is smaller than the result
88
+ *
82
89
  * @example
83
90
  * ```typescript
84
91
  * const gray = await scaler.toBuffer(frame, { resize: { width: 320, height: 180 }, format: 'gray' });
92
+ *
93
+ * // Reuse one buffer across frames
94
+ * const reused = Buffer.allocUnsafe(320 * 180);
95
+ * await scaler.toBuffer(frame, { resize: { width: 320, height: 180 }, format: 'gray', output: reused });
85
96
  * ```
86
97
  *
87
98
  * @see {@link toBufferSync} For synchronous version
@@ -90,6 +101,10 @@ export class Scaler {
90
101
  if (this.disposed) {
91
102
  throw new Error('Scaler has been disposed');
92
103
  }
104
+ // The native side accepts any Uint8Array, but the result would then not be a Buffer
105
+ if (options.output !== undefined && !Buffer.isBuffer(options.output)) {
106
+ throw new TypeError('output must be a Buffer');
107
+ }
93
108
  if (frame.isHwFrame()) {
94
109
  // With a hardware context, crop/scale/convert on the GPU and download only
95
110
  // the small result. Without one, fall back to downloading the full frame
@@ -97,15 +112,21 @@ export class Scaler {
97
112
  if (this.hardware) {
98
113
  const out = await this.toFrame(frame, options.crop, options.resize, options.format ?? 'nv12');
99
114
  try {
100
- return out.toBuffer();
115
+ return out.toBuffer(options.output);
101
116
  }
102
117
  finally {
103
118
  out.free();
104
119
  }
105
120
  }
106
- return await this.native.process((await this.downloadToSoftware(frame)).getNative(), options);
121
+ const sw = await this.downloadToSoftware(frame);
122
+ try {
123
+ return await this.processNative(sw, options);
124
+ }
125
+ finally {
126
+ sw.free();
127
+ }
107
128
  }
108
- return await this.native.process(frame.getNative(), options);
129
+ return await this.processNative(frame, options);
109
130
  }
110
131
  /**
111
132
  * Scale, crop, and/or convert a frame and return the raw pixel data.
@@ -113,12 +134,14 @@ export class Scaler {
113
134
  *
114
135
  * @param frame - Source frame (software or hardware; hardware frames are processed on the GPU and downloaded)
115
136
  *
116
- * @param options - Crop, resize, and format options
137
+ * @param options - Crop, resize, format, and output buffer options
117
138
  *
118
- * @returns Tightly packed pixel data
139
+ * @returns Tightly packed pixel data (a view of `options.output` when given)
119
140
  *
120
141
  * @throws {FFmpegError} If scaling fails
121
142
  *
143
+ * @throws {TypeError} If `options.output` is not a Buffer or is smaller than the result
144
+ *
122
145
  * @example
123
146
  * ```typescript
124
147
  * const gray = scaler.toBufferSync(frame, { resize: { width: 320, height: 180 }, format: 'gray' });
@@ -130,19 +153,28 @@ export class Scaler {
130
153
  if (this.disposed) {
131
154
  throw new Error('Scaler has been disposed');
132
155
  }
156
+ if (options.output !== undefined && !Buffer.isBuffer(options.output)) {
157
+ throw new TypeError('output must be a Buffer');
158
+ }
133
159
  if (frame.isHwFrame()) {
134
160
  if (this.hardware) {
135
161
  const out = this.toFrameSync(frame, options.crop, options.resize, options.format ?? 'nv12');
136
162
  try {
137
- return out.toBuffer();
163
+ return out.toBuffer(options.output);
138
164
  }
139
165
  finally {
140
166
  out.free();
141
167
  }
142
168
  }
143
- return this.native.processSync(this.downloadToSoftwareSync(frame).getNative(), options);
169
+ const sw = this.downloadToSoftwareSync(frame);
170
+ try {
171
+ return this.getSyncNative().processSync(sw.getNative(), options);
172
+ }
173
+ finally {
174
+ sw.free();
175
+ }
144
176
  }
145
- return this.native.processSync(frame.getNative(), options);
177
+ return this.getSyncNative().processSync(frame.getNative(), options);
146
178
  }
147
179
  /**
148
180
  * Scale/crop a frame and encode it to a JPEG image.
@@ -172,16 +204,21 @@ export class Scaler {
172
204
  throw new Error('Scaler has been disposed');
173
205
  }
174
206
  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');
207
+ const downloaded = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : null;
179
208
  try {
180
- this.prepareJpegFrame(out, quality);
181
- return await this.getJpegPool().encode(out);
209
+ // mjpeg wants planar YUV; tag it full-range so colors are correct without the
210
+ // deprecated yuvj* formats.
211
+ const out = await this.toFrame(downloaded ?? frame, options.crop, options.resize, 'yuv420p');
212
+ try {
213
+ this.prepareJpegFrame(out, quality);
214
+ return await this.getJpegPool().encode(out);
215
+ }
216
+ finally {
217
+ out.free();
218
+ }
182
219
  }
183
220
  finally {
184
- out.free();
221
+ downloaded?.free();
185
222
  }
186
223
  }
187
224
  /**
@@ -208,14 +245,19 @@ export class Scaler {
208
245
  throw new Error('Scaler has been disposed');
209
246
  }
210
247
  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');
248
+ const downloaded = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : null;
213
249
  try {
214
- this.prepareJpegFrame(out, quality);
215
- return this.getJpegPool().encodeSync(out);
250
+ const out = this.toFrameSync(downloaded ?? frame, options.crop, options.resize, 'yuv420p');
251
+ try {
252
+ this.prepareJpegFrame(out, quality);
253
+ return this.getJpegPool().encodeSync(out);
254
+ }
255
+ finally {
256
+ out.free();
257
+ }
216
258
  }
217
259
  finally {
218
- out.free();
260
+ downloaded?.free();
219
261
  }
220
262
  }
221
263
  /**
@@ -245,13 +287,18 @@ export class Scaler {
245
287
  throw new Error('Scaler has been disposed');
246
288
  }
247
289
  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);
290
+ const downloaded = frame.isHwFrame() && !this.hardware ? await this.downloadToSoftware(frame) : null;
250
291
  try {
251
- return await this.getPngPool().encode(out);
292
+ const out = await this.toFrame(downloaded ?? frame, options.crop, options.resize, format);
293
+ try {
294
+ return await this.getPngPool().encode(out);
295
+ }
296
+ finally {
297
+ out.free();
298
+ }
252
299
  }
253
300
  finally {
254
- out.free();
301
+ downloaded?.free();
255
302
  }
256
303
  }
257
304
  /**
@@ -278,13 +325,18 @@ export class Scaler {
278
325
  throw new Error('Scaler has been disposed');
279
326
  }
280
327
  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);
328
+ const downloaded = frame.isHwFrame() && !this.hardware ? this.downloadToSoftwareSync(frame) : null;
283
329
  try {
284
- return this.getPngPool().encodeSync(out);
330
+ const out = this.toFrameSync(downloaded ?? frame, options.crop, options.resize, format);
331
+ try {
332
+ return this.getPngPool().encodeSync(out);
333
+ }
334
+ finally {
335
+ out.free();
336
+ }
285
337
  }
286
338
  finally {
287
- out.free();
339
+ downloaded?.free();
288
340
  }
289
341
  }
290
342
  /**
@@ -301,34 +353,41 @@ export class Scaler {
301
353
  }
302
354
  this.disposed = true;
303
355
  this.native.close();
356
+ this.syncNative?.close();
357
+ this.syncNative = undefined;
358
+ // In-use graphs are retired, not disposed: the last in-flight scale disposes
359
+ // them on release, so close() during concurrent calls never crashes them.
304
360
  for (const entry of this.graphs.values()) {
305
- entry.filter[Symbol.dispose]();
361
+ this.retireEntry(entry);
306
362
  }
307
363
  this.graphs.clear();
308
364
  this.jpegPool?.close();
309
365
  this.jpegPool = undefined;
310
366
  this.pngPool?.close();
311
367
  this.pngPool = undefined;
312
- this.downloadFrame?.free();
313
- this.downloadFrame = undefined;
314
368
  }
315
369
  /**
316
370
  * Download a hardware frame to system memory (no hardware context available).
317
371
  *
318
- * Reuses a single target frame across calls. The frame's own hwframe context
319
- * selects a supported software format.
372
+ * Allocates a fresh target per call - concurrent downloads must not share a
373
+ * staging frame. The frame's own hwframe context selects a supported software
374
+ * format. The caller frees the returned frame.
320
375
  *
321
376
  * @param frame - Hardware source frame
322
377
  *
323
- * @returns Software frame
378
+ * @returns Software frame (caller frees)
324
379
  *
325
380
  * @throws {FFmpegError} If the transfer fails
326
381
  *
327
382
  * @internal
328
383
  */
329
384
  async downloadToSoftware(frame) {
330
- const dst = this.prepareDownloadTarget();
385
+ const dst = new Frame();
386
+ dst.alloc();
331
387
  const ret = await frame.hwframeTransferData(dst, 0);
388
+ if (ret < 0) {
389
+ dst.free();
390
+ }
332
391
  FFmpegError.throwIfError(ret, 'Failed to download hardware frame');
333
392
  dst.copyProps(frame);
334
393
  return dst;
@@ -338,35 +397,56 @@ export class Scaler {
338
397
  *
339
398
  * @param frame - Hardware source frame
340
399
  *
341
- * @returns Software frame
400
+ * @returns Software frame (caller frees)
342
401
  *
343
402
  * @throws {FFmpegError} If the transfer fails
344
403
  *
345
404
  * @internal
346
405
  */
347
406
  downloadToSoftwareSync(frame) {
348
- const dst = this.prepareDownloadTarget();
407
+ const dst = new Frame();
408
+ dst.alloc();
349
409
  const ret = frame.hwframeTransferDataSync(dst, 0);
410
+ if (ret < 0) {
411
+ dst.free();
412
+ }
350
413
  FFmpegError.throwIfError(ret, 'Failed to download hardware frame');
351
414
  dst.copyProps(frame);
352
415
  return dst;
353
416
  }
354
417
  /**
355
- * Lazily allocate and reset the reused download target frame.
418
+ * Run the pooled native software scaler, serialized per instance.
356
419
  *
357
- * @returns The cleared download frame
420
+ * The native scaler reuses staging frames and sws contexts per configuration;
421
+ * overlapping async calls would race on them in the worker threads.
422
+ *
423
+ * @param frame - Software source frame
424
+ *
425
+ * @param options - Crop, resize, and format options
426
+ *
427
+ * @returns Tightly packed pixel data
428
+ *
429
+ * @throws {FFmpegError} If scaling fails
358
430
  *
359
431
  * @internal
360
432
  */
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;
433
+ async processNative(frame, options) {
434
+ return await this.nativeLock.run(() => this.native.process(frame.getNative(), options));
435
+ }
436
+ /**
437
+ * The native scaler used by the sync path (lazily created).
438
+ *
439
+ * Sync calls block the event loop but can land while an async call's worker is
440
+ * scaling in the background; a separate native instance keeps them off the async
441
+ * scaler's pooled contexts, which are not safe to share across threads.
442
+ *
443
+ * @returns The sync-path native scaler
444
+ *
445
+ * @internal
446
+ */
447
+ getSyncNative() {
448
+ this.syncNative ??= new bindings.Scaler(this.flags);
449
+ return this.syncNative;
370
450
  }
371
451
  /**
372
452
  * Scale/crop/convert a frame through a cached filter graph and return the
@@ -392,27 +472,68 @@ export class Scaler {
392
472
  * @internal
393
473
  */
394
474
  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;
475
+ // Retry loop: an entry can be retired while we queue on its lock (QSV flush
476
+ // eviction, mid-stream failure, or LRU pressure from concurrent calls). The
477
+ // next attempt then resolves a freshly built graph.
478
+ for (;;) {
479
+ if (this.disposed) {
480
+ throw new Error('Scaler has been disposed');
481
+ }
482
+ const acq = this.resolveGraph(frame, crop, resize, format);
483
+ const { entry, key } = acq;
484
+ // Hold the entry across the awaits below: eviction of an in-use graph is
485
+ // deferred to releaseGraph(), never disposing it mid-scale.
486
+ entry.refs++;
487
+ try {
488
+ // Serialize crop command + process + receive per graph: concurrent scales
489
+ // on the same output config would otherwise interleave on one FilterAPI
490
+ // and hand frames to the wrong caller.
491
+ const out = await entry.lock.run(async () => {
492
+ if (entry.evicted) {
493
+ return null; // Retired while we queued - retry with a fresh graph
494
+ }
495
+ this.prepareGraph(acq, frame);
496
+ try {
497
+ await entry.filter.process(frame);
498
+ let received = await entry.filter.receive();
499
+ if (!(received instanceof Frame)) {
500
+ // Some hardware scalers (notably QSV's vpp_qsv) buffer internally and don't
501
+ // emit a frame synchronously after a single input - the buffersink returns
502
+ // EAGAIN. Flush to force the pending frame out, then evict the now-EOF'd
503
+ // graph so the next call rebuilds it. Zero-latency scalers (scale_vaapi,
504
+ // scale_vt) deliver on the first receive and never hit this path.
505
+ await entry.filter.flush();
506
+ received = await entry.filter.receive();
507
+ this.evictGraph(key, entry);
508
+ }
509
+ if (!(received instanceof Frame)) {
510
+ throw new Error('Scaler: filter produced no frame');
511
+ }
512
+ return received;
513
+ }
514
+ catch (error) {
515
+ // The graph is in an unknown mid-stream state; retire it so the next
516
+ // call rebuilds instead of inheriting the wreckage.
517
+ this.evictGraph(key, entry);
518
+ throw error;
519
+ }
520
+ });
521
+ if (out) {
522
+ return out;
523
+ }
524
+ }
525
+ finally {
526
+ this.releaseGraph(entry);
527
+ }
528
+ }
412
529
  }
413
530
  /**
414
531
  * Synchronous version of toFrame.
415
532
  *
533
+ * Sync scales cannot interleave with each other (they block the event loop),
534
+ * but they can land between the awaits of an in-flight async scale; a busy
535
+ * cached graph is bypassed with a throwaway graph in that case.
536
+ *
416
537
  * @param frame - Source frame (software, or hardware with a hardware context)
417
538
  *
418
539
  * @param crop - Crop region, or undefined for the full frame
@@ -426,25 +547,67 @@ export class Scaler {
426
547
  * @internal
427
548
  */
428
549
  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;
550
+ const acq = this.resolveGraph(frame, crop, resize, format);
551
+ const { entry, key } = acq;
552
+ if (entry.refs > 0) {
553
+ return this.scaleOneShotSync(frame, acq);
554
+ }
555
+ this.prepareGraph(acq, frame);
556
+ try {
557
+ entry.filter.processSync(frame);
558
+ let out = entry.filter.receiveSync();
559
+ if (!(out instanceof Frame)) {
560
+ // See toFrame: QSV's vpp_qsv buffers a frame; flush to drain it and evict
561
+ // the now-EOF'd graph so the next call rebuilds.
562
+ entry.filter.flushSync();
563
+ out = entry.filter.receiveSync();
564
+ this.evictGraph(key, entry);
565
+ }
566
+ if (!(out instanceof Frame)) {
567
+ throw new Error('Scaler: filter produced no frame');
568
+ }
569
+ return out;
570
+ }
571
+ catch (error) {
572
+ this.evictGraph(key, entry);
573
+ throw error;
574
+ }
443
575
  }
444
576
  /**
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.
577
+ * Scale through a throwaway graph with the crop baked in, bypassing the cache.
578
+ *
579
+ * @param frame - Source frame
580
+ *
581
+ * @param acq - Resolved operation configuration
582
+ *
583
+ * @returns The scaled output frame (caller frees)
584
+ *
585
+ * @throws {Error} If the graph produces no frame
586
+ *
587
+ * @internal
588
+ */
589
+ scaleOneShotSync(frame, acq) {
590
+ const filter = FilterAPI.create(this.buildGraph(acq.isHw, acq.outW, acq.outH, acq.format, acq.crop, false), acq.isHw ? { hardware: this.hardware } : {});
591
+ try {
592
+ filter.processSync(frame);
593
+ let out = filter.receiveSync();
594
+ if (!(out instanceof Frame)) {
595
+ filter.flushSync();
596
+ out = filter.receiveSync();
597
+ }
598
+ if (!(out instanceof Frame)) {
599
+ throw new Error('Scaler: filter produced no frame');
600
+ }
601
+ return out;
602
+ }
603
+ finally {
604
+ filter[Symbol.dispose]();
605
+ }
606
+ }
607
+ /**
608
+ * Resolve (and, on demand, build) the cached filter graph for a frame. Shared
609
+ * by the sync and async scaling paths; all of this work is synchronous, so a
610
+ * resolved entry cannot be evicted before the caller marks it in use.
448
611
  *
449
612
  * @param frame - Source frame (software, or hardware with a hardware context)
450
613
  *
@@ -454,7 +617,7 @@ export class Scaler {
454
617
  *
455
618
  * @param format - Output pixel format
456
619
  *
457
- * @returns The cached graph entry (ready to process the frame) and its cache key
620
+ * @returns The cached graph entry and the resolved operation configuration
458
621
  *
459
622
  * @throws {Error} If the crop is out of bounds
460
623
  *
@@ -488,7 +651,7 @@ export class Scaler {
488
651
  // on the first processed frame, before any command can be sent, so the
489
652
  // initial crop must already be in place. Later crops use sendCommand.
490
653
  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 };
654
+ entry = { filter, lastCrop: commandable ? { x: cropX, y: cropY, w: cropW, h: cropH } : null, lastSrc: null, lock: new Mutex(), refs: 0, evicted: false };
492
655
  this.cacheSet(key, entry);
493
656
  }
494
657
  else {
@@ -496,38 +659,95 @@ export class Scaler {
496
659
  this.graphs.delete(key);
497
660
  this.graphs.set(key, entry);
498
661
  }
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 };
662
+ return { entry, key, isHw, commandable, crop: cropRegion, outW, outH, format };
514
663
  }
515
664
  /**
516
- * Dispose a cached graph and remove it from the cache.
665
+ * Bring a cached graph in line with the current call. Must run under the entry
666
+ * lock (async path) or with no async call in flight (sync path).
517
667
  *
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.
668
+ * Rebuilds the graph when the source parameters changed: FilterAPI would
669
+ * otherwise reinitialize itself from the original graph description, silently
670
+ * resurrecting the crop baked in at build time instead of the current one. A
671
+ * graph that has not seen a frame yet is likewise rebuilt when the crop
672
+ * differs, because commands cannot be sent before initialization. Otherwise a
673
+ * changed crop is re-aimed with runtime commands - identical crops reuse the
674
+ * graph untouched (no config_input/config_output re-run).
520
675
  *
521
- * @param key - Cache key of the graph to evict
676
+ * @param acq - Resolved graph and operation configuration
677
+ *
678
+ * @param frame - Frame about to be processed
522
679
  *
523
680
  * @internal
524
681
  */
525
- evictGraph(key) {
526
- const entry = this.graphs.get(key);
527
- if (entry) {
682
+ prepareGraph(acq, frame) {
683
+ const { entry, isHw, commandable, crop, outW, outH, format } = acq;
684
+ const src = entry.lastSrc;
685
+ const srcChanged = src !== null && (src.width !== frame.width || src.height !== frame.height || src.format !== frame.format);
686
+ const c = entry.lastCrop;
687
+ const cropChanged = commandable && (c?.x !== crop.x || c?.y !== crop.y || c?.w !== crop.width || c?.h !== crop.height);
688
+ if (srcChanged || (cropChanged && src === null)) {
528
689
  entry.filter[Symbol.dispose]();
690
+ entry.filter = FilterAPI.create(this.buildGraph(isHw, outW, outH, format, crop, commandable), isHw ? { hardware: this.hardware } : {});
691
+ entry.lastCrop = commandable ? { x: crop.x, y: crop.y, w: crop.width, h: crop.height } : null;
692
+ }
693
+ else if (cropChanged) {
694
+ // w/h are validated against the input dims (in resolveGraph); x/y are clamped
695
+ // per frame by the crop filter, so this order never produces a transient error.
696
+ entry.filter.sendCommand('crop@sc', 'w', String(crop.width));
697
+ entry.filter.sendCommand('crop@sc', 'h', String(crop.height));
698
+ entry.filter.sendCommand('crop@sc', 'x', String(crop.x));
699
+ entry.filter.sendCommand('crop@sc', 'y', String(crop.y));
700
+ entry.lastCrop = { x: crop.x, y: crop.y, w: crop.width, h: crop.height };
701
+ }
702
+ entry.lastSrc = { width: frame.width, height: frame.height, format: frame.format };
703
+ }
704
+ /**
705
+ * Remove a graph from the cache and retire it.
706
+ *
707
+ * Identity-checked so a newer graph cached under the same key is left alone.
708
+ * Used after a flush (the graph is at EOF and can no longer accept frames) and
709
+ * after mid-stream failures, so the next operation rebuilds it.
710
+ *
711
+ * @param key - Cache key of the graph
712
+ *
713
+ * @param entry - The exact entry to evict
714
+ *
715
+ * @internal
716
+ */
717
+ evictGraph(key, entry) {
718
+ if (this.graphs.get(key) === entry) {
529
719
  this.graphs.delete(key);
530
720
  }
721
+ this.retireEntry(entry);
722
+ }
723
+ /**
724
+ * Take a graph out of service, disposing it now or on last release.
725
+ *
726
+ * An entry with in-flight scales is only marked; the final {@link releaseGraph}
727
+ * disposes the filter once nothing uses it anymore.
728
+ *
729
+ * @param entry - Entry removed from the cache
730
+ *
731
+ * @internal
732
+ */
733
+ retireEntry(entry) {
734
+ entry.evicted = true;
735
+ if (entry.refs === 0) {
736
+ entry.filter[Symbol.dispose]();
737
+ }
738
+ }
739
+ /**
740
+ * Drop an in-flight reference, disposing the graph if it was retired meanwhile.
741
+ *
742
+ * @param entry - Entry acquired by {@link toFrame}
743
+ *
744
+ * @internal
745
+ */
746
+ releaseGraph(entry) {
747
+ entry.refs--;
748
+ if (entry.refs === 0 && entry.evicted) {
749
+ entry.filter[Symbol.dispose]();
750
+ }
531
751
  }
532
752
  /**
533
753
  * Tag a frame for MJPEG encoding: full color range and a per-frame quality.
@@ -575,7 +795,9 @@ export class Scaler {
575
795
  * For software frames this is `crop,scale,format`. For hardware frames the crop
576
796
  * filter sets crop metadata that the hardware scaler (scale_vt/scale_cuda/
577
797
  * 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
798
+ * converted on the CPU. Scalers with a same-size/same-format passthrough mode
799
+ * (scale_vaapi/scale_cuda/vpp_qsv) get `passthrough=0`, since skipping the scaler
800
+ * would drop the crop metadata unapplied. When `commandable` the crop is a labeled instance
579
801
  * (`crop@sc`) whose initial region is `crop` but which is re-aimed per frame via
580
802
  * `sendCommand`. Otherwise (OpenCL) the crop is fixed in the graph.
581
803
  *
@@ -604,7 +826,13 @@ export class Scaler {
604
826
  else {
605
827
  chain = chain.crop(crop.width, crop.height, crop.x, crop.y);
606
828
  }
607
- chain = chain.scale(outW, outH);
829
+ // The crop filter only sets crop metadata on hardware frames; the hw scaler
830
+ // materializes it. scale_vaapi/scale_cuda/vpp_qsv skip themselves entirely when
831
+ // input and output size/format match (decided at config time, before any crop
832
+ // command), which would silently drop the crop - so their passthrough is disabled.
833
+ const passthroughCapable = [AV_HWDEVICE_TYPE_VAAPI, AV_HWDEVICE_TYPE_CUDA, AV_HWDEVICE_TYPE_QSV];
834
+ const noPassthrough = isHw && passthroughCapable.includes(this.hardware.deviceType);
835
+ chain = chain.scale(outW, outH, noPassthrough ? { passthrough: 0 } : undefined);
608
836
  if (isHw) {
609
837
  // Keep the GPU result in NV12, download the small frame, then convert on CPU.
610
838
  chain = chain.scaleFormat(AV_PIX_FMT_NV12).hwdownload().format(AV_PIX_FMT_NV12);
@@ -633,8 +861,13 @@ export class Scaler {
633
861
  if (oldest === undefined) {
634
862
  break;
635
863
  }
636
- this.graphs.get(oldest)?.filter[Symbol.dispose]();
864
+ const evictee = this.graphs.get(oldest);
637
865
  this.graphs.delete(oldest);
866
+ if (evictee) {
867
+ // In-use graphs are retired, not disposed - the last in-flight scale
868
+ // disposes them on release.
869
+ this.retireEntry(evictee);
870
+ }
638
871
  }
639
872
  }
640
873
  /**