@camstack/addon-pipeline 1.1.40 → 1.1.42

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 (29) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +1 -1
  4. package/dist/detection-pipeline/index.mjs +1 -1
  5. package/dist/{dist-v6cKLmU3.mjs → dist-GFd8M6KO.mjs} +9 -1
  6. package/dist/{dist-BJTPkJFw.js → dist-NvwN60Fq.js} +9 -1
  7. package/dist/{frame-handle-plane-BP8YV4sF.js → frame-handle-plane-B3Fxww8H.js} +1 -1
  8. package/dist/{frame-handle-plane-DKAXTtfn.mjs → frame-handle-plane-E_AsR77T.mjs} +1 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +47 -9
  12. package/dist/pipeline-runner/index.mjs +47 -9
  13. package/dist/recorder/index.js +1 -1
  14. package/dist/recorder/index.mjs +1 -1
  15. package/dist/session-decode/decode-worker-child.js +364 -40
  16. package/dist/session-decode/decode-worker-child.mjs +364 -40
  17. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-6I7-nufj.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs} +3 -3
  18. package/dist/stream-broker/{hostInit-CUkFGqiI.mjs → hostInit-DmjqskOR.mjs} +3 -3
  19. package/dist/stream-broker/index.js +2 -2
  20. package/dist/stream-broker/index.mjs +2 -2
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Cr_fCzY5.js → MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-COcvoHDH.js → MotionZonesSettings-NcxxQN8r-D43fIRgB.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Bv-bbyJl.js → PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js} +1 -1
  25. package/embed-dist/assets/index-CSFtK41z.css +2 -0
  26. package/embed-dist/assets/{index-jMj5PyJk.js → index-CX-rILhw.js} +4 -4
  27. package/embed-dist/index.html +2 -2
  28. package/package.json +1 -1
  29. package/embed-dist/assets/index-Ctbq9AXh.css +0 -2
@@ -24,6 +24,19 @@ var FrameSlot = class {
24
24
  */
25
25
  reserved = null;
26
26
  pendingPull = false;
27
+ /** Frames dropped by the `minIntervalMs` throttle (never entered the slot). */
28
+ throttledCount = 0;
29
+ /** Frames dropped by latest-wins (an unpulled slot frame superseded before delivery). */
30
+ supersededCount = 0;
31
+ /**
32
+ * Total frames this slot has DROPPED before delivery — the sum of the
33
+ * `minIntervalMs` throttle drops and the latest-wins (drop-older)
34
+ * supersessions. Read by the decode-worker child for its `framesSkipped`
35
+ * metric; purely additive, never affects slot behaviour.
36
+ */
37
+ get droppedCount() {
38
+ return this.throttledCount + this.supersededCount;
39
+ }
27
40
  /**
28
41
  * Publish a newly decoded frame into the latest-wins slot, freeing
29
42
  * whatever the slot held before (the drop-older half of "latest-wins").
@@ -33,6 +46,7 @@ var FrameSlot = class {
33
46
  */
34
47
  publish(frame, timestamp, minIntervalMs) {
35
48
  if (minIntervalMs > 0 && this.slot && timestamp - this.slot.timestamp < minIntervalMs) {
49
+ this.throttledCount++;
36
50
  frame.free();
37
51
  return null;
38
52
  }
@@ -45,6 +59,7 @@ var FrameSlot = class {
45
59
  width: frame.width,
46
60
  height: frame.height
47
61
  };
62
+ if (previous) this.supersededCount++;
48
63
  previous?.frame.free();
49
64
  if (this.pendingPull) {
50
65
  this.pendingPull = false;
@@ -91,6 +106,27 @@ var FrameSlot = class {
91
106
  //#region src/session-decode/decode-worker-child.ts
92
107
  /** Re-dial backoff on a transient dial error/EOF (mirrors the decoder's PULL_REDIAL_MS). */
93
108
  var REDIAL_MS = 3e3;
109
+ /** How often the worker emits its `framesDecoded/framesSkipped/deliveredFps` line. */
110
+ var METRICS_INTERVAL_MS = 1e4;
111
+ /**
112
+ * The libav GPU scale filter for a hwaccel backend, or `null` when none is
113
+ * known here — those backends fall back to the software crop+scale path.
114
+ * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
115
+ */
116
+ function gpuScaleFilterForBackend(backend) {
117
+ switch (backend) {
118
+ case "vaapi": return "scale_vaapi";
119
+ case "qsv": return "scale_qsv";
120
+ case "cuda":
121
+ case "nvdec": return "scale_cuda";
122
+ case "videotoolbox": return "scale_vt";
123
+ default: return null;
124
+ }
125
+ }
126
+ /** libav `format` filter target name for the requested detection pixel format. */
127
+ function pixelFilterName(format) {
128
+ return format === "gray" ? "gray" : "rgb24";
129
+ }
94
130
  var _nav = null;
95
131
  var _consts = null;
96
132
  async function getNodeAv() {
@@ -183,6 +219,45 @@ function resolveResizeTarget(cropWidth, cropHeight, resize) {
183
219
  height: Math.max(1, Math.round(resize.height))
184
220
  };
185
221
  }
222
+ /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
223
+ function isFullFrameRegion(region, srcW, srcH) {
224
+ return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
225
+ }
226
+ /**
227
+ * Build the libav filtergraph description for the GPU crop+scale path.
228
+ *
229
+ * Full-frame — the ONLY shape the session-decode pump ever asks for (it passes
230
+ * `resize`+`format`, never `crop`, see `session-decode-pump.ts`): scale on the
231
+ * GPU, then `hwdownload` pulls only the small detection-sized surface into
232
+ * system memory + a cheap pixel-convert. This is byte-for-byte the chain the
233
+ * ffmpeg decoder already ships (`addon-decoder-ffmpeg/src/ffmpeg-args.ts`
234
+ * `buildVideoFilter`): `scale_<be>=w=W:h=H,hwdownload,format=nv12,format=<pixel>`.
235
+ *
236
+ * Sub-region crop — contract-supported but unused on the hot path: the generic
237
+ * `crop` filter offsets plane POINTERS, which corrupts opaque VAAPI/QSV
238
+ * surfaces, so there is no safe GPU crop for those. A real crop is therefore
239
+ * done on the CPU AFTER `hwdownload` (`hwdownload,format=nv12,crop=…,scale=…`).
240
+ * It is correct and never crashes; it just forgoes the GPU win for that rare
241
+ * call — exactly the safety/behaviour trade-off the task calls for.
242
+ */
243
+ function buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel) {
244
+ if (fullFrame) return `${scaleFilter}=w=${target.width}:h=${target.height},hwdownload,format=nv12,format=${pixel}`;
245
+ return `hwdownload,format=nv12,crop=${region.width}:${region.height}:${region.left}:${region.top},scale=${target.width}:${target.height},format=${pixel}`;
246
+ }
247
+ /**
248
+ * Copy a single-plane packed frame (rgb24 / gray8 out of the filtergraph) into
249
+ * a tightly-packed `Uint8Array` of `width*channels*height`, stripping libav's
250
+ * row padding (`linesize` ≥ `width*channels`). Keeps the `toBuffer` output
251
+ * contract byte-identical to the software `scaleRoiToBuffer`.
252
+ */
253
+ function packSinglePlane(plane, linesize, width, height, channels) {
254
+ const rowBytes = width * channels;
255
+ const stride = linesize > 0 ? linesize : rowBytes;
256
+ const out = Buffer.allocUnsafe(rowBytes * height);
257
+ if (stride === rowBytes) plane.copy(out, 0, 0, rowBytes * height);
258
+ else for (let row = 0; row < height; row++) plane.copy(out, row * rowBytes, row * stride, row * stride + rowBytes);
259
+ return out;
260
+ }
186
261
  /**
187
262
  * The forked child's whole runtime: decode-loop lifecycle, the latest-wins
188
263
  * frame slot, and the ROI scaler for `toBuffer`. One instance per process.
@@ -191,6 +266,53 @@ var DecodeWorkerChild = class {
191
266
  nav = null;
192
267
  consts = null;
193
268
  hwContext = null;
269
+ /**
270
+ * Reused software scaler + the geometry/format key it was built for.
271
+ * Building a fresh `SoftwareScaleContext` on EVERY `toBuffer` (24fps)
272
+ * churned native libav memory unboundedly (a persistent worker grew to
273
+ * ~10GB/h). Detection uses a stable ROI + resize target, so the scaler is
274
+ * built once and reused, rebuilt only when the key changes — mirroring the
275
+ * shared decoder session (`nodeav-decoder-session.ts`, which reuses one
276
+ * `this.scaler` for the same reason). Disposed in `teardown`.
277
+ */
278
+ scaler = null;
279
+ scalerKey = "";
280
+ /** libav GPU scale filter for the resolved hwaccel backend, or null when GPU filtering isn't available. */
281
+ gpuScaleFilter = null;
282
+ /**
283
+ * GPU crop+scale path decision — picked ONCE per session.
284
+ * - `software`: the decoder downloads every frame to YUV420P system memory and
285
+ * `toBuffer` uses the CPU {@link scaler}. This is the original, leak-safe
286
+ * behaviour; the default until a HW device + `scale_<be>` filter is found.
287
+ * - `undecided`: a HW device + GPU scale filter are available; the decoder
288
+ * emits GPU surfaces and the FIRST HW frame is probed through the GPU
289
+ * filtergraph — success ⇒ `hardware`, any failure ⇒ `software` (re-dial).
290
+ * - `hardware`: the decoder emits GPU surfaces and `toBuffer` runs the GPU
291
+ * crop+scale, downloading only the tiny detection-sized result.
292
+ */
293
+ hwFilterDecision = "software";
294
+ /** Skip the re-dial backoff when the re-dial is specifically to switch to software. */
295
+ redialAsSoftware = false;
296
+ /**
297
+ * Cached GPU crop+scale filtergraph + the geometry/format key it was built
298
+ * for — the HW analogue of {@link scaler}/{@link scalerKey}. Rebuilt (old
299
+ * CLOSED) only when the key changes. Its internal `scale_<be>` OUTPUT hw
300
+ * frames pool is the leak-prone VAAPI surface pool, so it is closed on EVERY
301
+ * re-dial ({@link closeInput}) and on teardown — this discipline is what stops
302
+ * the prior "keeping decoded frames on the GPU leaked the VAAPI surface pool
303
+ * on every re-dial" incident from recurring.
304
+ */
305
+ hwFilter = null;
306
+ hwFilterKey = "";
307
+ /** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
308
+ framesDecoded = 0;
309
+ /** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
310
+ framesSkipped = 0;
311
+ /** Frames actually handed to the parent as a `frame` reply. */
312
+ framesDelivered = 0;
313
+ lastMetricsAt = 0;
314
+ lastDeliveredSnapshot = 0;
315
+ metricsTimer = null;
194
316
  demuxer = null;
195
317
  decoder = null;
196
318
  abortController = null;
@@ -243,9 +365,17 @@ var DecodeWorkerChild = class {
243
365
  clearTimeout(this.redialTimer);
244
366
  this.redialTimer = null;
245
367
  }
368
+ if (this.metricsTimer) {
369
+ clearInterval(this.metricsTimer);
370
+ this.metricsTimer = null;
371
+ }
372
+ if (this.started) this.logMetrics(true);
246
373
  this.abortController?.abort();
247
- this.closeInput();
248
374
  this.frames.free();
375
+ this.closeInput();
376
+ this.scaler?.[Symbol.dispose]?.();
377
+ this.scaler = null;
378
+ this.scalerKey = "";
249
379
  this.hwContext?.[Symbol.dispose]?.();
250
380
  this.hwContext = null;
251
381
  if (this.pendingPull) {
@@ -253,6 +383,17 @@ var DecodeWorkerChild = class {
253
383
  this.send({ kind: "ended" });
254
384
  }
255
385
  }
386
+ /** Emit the compact throughput line (window `deliveredFps`); cheap, integer-only. */
387
+ logMetrics(final) {
388
+ const now = Date.now();
389
+ const windowMs = Math.max(1, now - this.lastMetricsAt);
390
+ const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
391
+ const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
392
+ const skipped = this.framesSkipped + this.frames.droppedCount;
393
+ process.stderr.write(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
394
+ this.lastMetricsAt = now;
395
+ this.lastDeliveredSnapshot = this.framesDelivered;
396
+ }
256
397
  handleStart(source, opts) {
257
398
  if (this.started) {
258
399
  process.stderr.write("decode-worker-child: duplicate start ignored\n");
@@ -261,6 +402,9 @@ var DecodeWorkerChild = class {
261
402
  this.started = true;
262
403
  this.sessionFormat = opts.format ?? "rgb";
263
404
  this.minIntervalMs = opts.fps && opts.fps > 0 ? 1e3 / opts.fps : 0;
405
+ this.lastMetricsAt = Date.now();
406
+ this.metricsTimer = setInterval(() => this.logMetrics(false), METRICS_INTERVAL_MS);
407
+ this.metricsTimer.unref?.();
264
408
  this.runDecodeLoop(source.restreamUrl).catch((err) => {
265
409
  this.sendError(`decode-worker-child: decode loop crashed — ${errMessage(err)}`);
266
410
  });
@@ -272,7 +416,7 @@ var DecodeWorkerChild = class {
272
416
  }
273
417
  const reply = this.frames.pull();
274
418
  if (reply) {
275
- this.send(this.toFrameReply(reply));
419
+ this.deliverFrame(reply);
276
420
  return;
277
421
  }
278
422
  this.pendingPull = true;
@@ -285,7 +429,7 @@ var DecodeWorkerChild = class {
285
429
  return;
286
430
  }
287
431
  try {
288
- const bytes = this.scaleRoiToBuffer(frame, opts);
432
+ const bytes = frame.isHwFrame() ? this.hwScaleToBuffer(frame, opts) : this.scaleRoiToBuffer(frame, opts);
289
433
  this.send({
290
434
  kind: "buffer",
291
435
  frameId,
@@ -317,21 +461,29 @@ var DecodeWorkerChild = class {
317
461
  this.closeInput();
318
462
  }
319
463
  if (this.stopped) break;
320
- await this.sleep(REDIAL_MS);
464
+ const skipBackoff = this.redialAsSoftware;
465
+ this.redialAsSoftware = false;
466
+ if (!skipBackoff) await this.sleep(REDIAL_MS);
321
467
  }
322
468
  }
323
469
  /**
324
- * One dial: open the demuxer, pick the video stream, build a HW/SW decoder
325
- * that downloads + normalises frames to YUV420P system memory (so the ROI
326
- * scaler always reads plain software planes regardless of hwaccel), and
327
- * pump `demuxer.packets → decoder.frames → consumeDecodedFrame` until the
470
+ * One dial: open the demuxer, pick the video stream, build a HW/SW decoder,
471
+ * and pump `demuxer.packets decoder.frames consumeDecodedFrame` until the
328
472
  * stream ends, errors, or teardown aborts it.
329
473
  *
330
- * Copied from `nodeav-decoder-session.ts` `pullDialAndDecode` (lines
331
- * ~916-996): same demuxer options, same `rescale.pixelFormat: YUV420P`
332
- * download-always contract (keeping decoded frames on the GPU leaked the
333
- * VAAPI surface pool on every re-dial see that file's comment), same
334
- * `exitOnError: false` bad-frame tolerance.
474
+ * Two decoder shapes, selected by {@link hwFilterDecision} (picked once per
475
+ * session):
476
+ * - HW path (`undecided`/`hardware`): NO `rescale` the decoder keeps frames
477
+ * as GPU surfaces so `toBuffer` can crop+scale them on the GPU and download
478
+ * only the small detection-sized result.
479
+ * - Software path (`software`): `rescale.pixelFormat: YUV420P` forces a
480
+ * full-res GPU→system download so the CPU ROI scaler reads plain planes.
481
+ * This is the ORIGINAL contract — keeping decoded frames on the GPU leaked
482
+ * the VAAPI surface pool on every re-dial (see `nodeav-decoder-session.ts`);
483
+ * the HW path avoids that leak by closing its filtergraph + freeing every
484
+ * surface on every re-dial/teardown (see {@link closeInput}/{@link teardown}).
485
+ *
486
+ * Same demuxer options + `exitOnError: false` bad-frame tolerance throughout.
335
487
  */
336
488
  async dialAndDecode(nav, C, url) {
337
489
  this.abortController = new AbortController();
@@ -346,9 +498,10 @@ var DecodeWorkerChild = class {
346
498
  this.demuxer = demuxer;
347
499
  const videoStream = demuxer.video();
348
500
  if (!videoStream) throw new Error("decode-worker-child: pull input has no video stream");
501
+ const wantHwFrames = this.hwContext !== null && this.hwFilterDecision !== "software";
349
502
  const decoder = await nav.Decoder.create(videoStream, {
350
503
  ...this.hwContext ? { hardware: this.hwContext } : {},
351
- rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
504
+ ...wantHwFrames ? {} : { rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P } },
352
505
  exitOnError: false
353
506
  });
354
507
  if (this.stopped) {
@@ -360,24 +513,103 @@ var DecodeWorkerChild = class {
360
513
  if (this.stopped) break;
361
514
  if (!frame) continue;
362
515
  this.consumeDecodedFrame(frame);
516
+ if (this.redialAsSoftware) break;
363
517
  }
364
518
  }
365
519
  /**
366
- * Publish a decoded frame into the latest-wins slot (mirrors Scrypted's
367
- * `libav.py:32,50-53` single-slot queue) via `FrameSlot`, which handles
368
- * drop-older, the `minIntervalMs` throttle, and resolving a pending `pull`.
520
+ * Route a decoded frame according to the session's {@link hwFilterDecision}.
521
+ * On the software path a frame is published straight to the latest-wins slot;
522
+ * on the HW path the FIRST GPU surface is probed through the GPU filtergraph
523
+ * to commit (or fall back) the path, then GPU surfaces are published for
524
+ * `toBuffer` to crop+scale on the GPU. Publishing itself (drop-older, the
525
+ * `minIntervalMs` throttle, resolving a pending `pull`) is `FrameSlot`'s job.
369
526
  */
370
527
  consumeDecodedFrame(frame) {
371
- if (frame.isHwFrame()) {
528
+ this.framesDecoded++;
529
+ if (this.hwFilterDecision === "software") {
530
+ if (frame.isHwFrame()) {
531
+ this.framesSkipped++;
532
+ frame.free();
533
+ return;
534
+ }
535
+ this.publishFrame(frame);
536
+ return;
537
+ }
538
+ if (!frame.isHwFrame()) {
539
+ this.framesSkipped++;
372
540
  frame.free();
541
+ this.degradeToSoftware();
373
542
  return;
374
543
  }
544
+ if (this.hwFilterDecision === "undecided") {
545
+ if (!this.probeHwFilter(frame)) {
546
+ this.framesSkipped++;
547
+ frame.free();
548
+ this.degradeToSoftware();
549
+ return;
550
+ }
551
+ this.hwFilterDecision = "hardware";
552
+ process.stderr.write(`decode-worker-child: GPU crop+scale path active (${this.gpuScaleFilter})\n`);
553
+ }
554
+ this.publishFrame(frame);
555
+ }
556
+ /** Publish to the latest-wins slot; deliver immediately if a `pull` is pending. */
557
+ publishFrame(frame) {
375
558
  const reply = this.frames.publish(frame, Date.now(), this.minIntervalMs);
376
559
  if (reply) {
377
560
  this.pendingPull = false;
378
- this.send(this.toFrameReply(reply));
561
+ this.deliverFrame(reply);
562
+ }
563
+ }
564
+ /** Send a `frame` reply to the parent and count it toward `deliveredFps`. */
565
+ deliverFrame(reply) {
566
+ this.framesDelivered++;
567
+ this.send(this.toFrameReply(reply));
568
+ }
569
+ /**
570
+ * One-shot GPU-filter capability probe on the FIRST decoded HW frame: build a
571
+ * no-op `scale_<be>=iw:ih,hwdownload,format=nv12` graph and push the frame
572
+ * through it. A frame out ⇒ the driver supports GPU crop+scale for this
573
+ * session; any throw / empty output ⇒ fall back to software. The probe graph
574
+ * and its output frames are freed here regardless of outcome, so no surface
575
+ * leaks out of the probe. The input `frame` keeps its own ref (buffersrc only
576
+ * ref'd it) and is published by the caller on success.
577
+ */
578
+ probeHwFilter(frame) {
579
+ const nav = this.nav;
580
+ const scaleFilter = this.gpuScaleFilter;
581
+ if (!nav || !scaleFilter || !this.hwContext) return false;
582
+ let filter = null;
583
+ try {
584
+ filter = nav.FilterAPI.create(`${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12`, { hardware: this.hwContext });
585
+ const outputs = filter.processAllSync(frame);
586
+ const ok = outputs.length > 0;
587
+ for (const out of outputs) out.free();
588
+ return ok;
589
+ } catch (err) {
590
+ process.stderr.write(`decode-worker-child: GPU filter probe failed — using software decode: ${errMessage(err)}\n`);
591
+ return false;
592
+ } finally {
593
+ filter?.close();
379
594
  }
380
595
  }
596
+ /**
597
+ * Abandon the GPU filter path for the rest of the session: close the
598
+ * filtergraph (freeing its GPU surface pool), mark the decision `software`,
599
+ * and abort the current HW dial so the decode loop re-dials with a YUV420P
600
+ * rescale. Called from the probe path AND from a runtime `toBuffer` GPU-filter
601
+ * failure, so a surprising driver/format error never leaves the session
602
+ * silently producing no usable frames. Idempotent.
603
+ */
604
+ degradeToSoftware() {
605
+ if (this.hwFilterDecision === "software") return;
606
+ this.hwFilterDecision = "software";
607
+ this.redialAsSoftware = true;
608
+ this.hwFilter?.close();
609
+ this.hwFilter = null;
610
+ this.hwFilterKey = "";
611
+ this.abortController?.abort();
612
+ }
381
613
  toFrameReply(reply) {
382
614
  return {
383
615
  kind: "frame",
@@ -417,34 +649,114 @@ var DecodeWorkerChild = class {
417
649
  const ySlice = yPlane.subarray(region.top * yStride + region.left);
418
650
  const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
419
651
  const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
652
+ const scaler = this.ensureScaler(nav, C, region.width, region.height, target.width, target.height, dstFmt);
653
+ const dstStride = target.width * channels;
654
+ const dstBuffer = Buffer.allocUnsafe(dstStride * target.height);
655
+ const scaledHeight = scaler.scaleSync([
656
+ ySlice,
657
+ uSlice,
658
+ vSlice
659
+ ], [
660
+ yStride,
661
+ cStride,
662
+ cStride
663
+ ], 0, region.height, [dstBuffer], [dstStride]);
664
+ if (scaledHeight < 0) throw new Error(`sws_scale failed: ${scaledHeight}`);
665
+ if (scaledHeight !== target.height) throw new Error(`scaler produced ${scaledHeight} of ${target.height} rows`);
666
+ return dstBuffer;
667
+ }
668
+ /**
669
+ * Build-once / reuse the {@link NavScaler} for a given src+dst geometry and
670
+ * dst format. Rebuilt (old disposed) only when the key changes — detection's
671
+ * ROI + resize target are stable, so this is a per-worker singleton in the
672
+ * steady state. Replaces the previous per-frame `new SoftwareScaleContext()`
673
+ * that leaked native libav memory at frame rate. Synchronous + single-thread
674
+ * (Node), so no epoch/race guard is needed (unlike the async shared session).
675
+ */
676
+ ensureScaler(nav, C, srcW, srcH, dstW, dstH, dstFmt) {
677
+ const key = `${srcW}x${srcH}->${dstW}x${dstH}:${dstFmt}`;
678
+ const cached = this.scaler;
679
+ if (cached && key === this.scalerKey) return cached;
680
+ this.scaler?.[Symbol.dispose]?.();
681
+ this.scaler = null;
682
+ this.scalerKey = "";
420
683
  const scaler = new nav.SoftwareScaleContext();
421
- try {
422
- scaler.getContext(region.width, region.height, C.AV_PIX_FMT_YUV420P, target.width, target.height, dstFmt, C.SWS_FAST_BILINEAR);
423
- const initRet = scaler.initContext();
424
- if (initRet < 0) throw new Error(`sws_init_context failed: ${initRet}`);
425
- const dstStride = target.width * channels;
426
- const dstBuffer = Buffer.allocUnsafe(dstStride * target.height);
427
- const scaledHeight = scaler.scaleSync([
428
- ySlice,
429
- uSlice,
430
- vSlice
431
- ], [
432
- yStride,
433
- cStride,
434
- cStride
435
- ], 0, region.height, [dstBuffer], [dstStride]);
436
- if (scaledHeight < 0) throw new Error(`sws_scale failed: ${scaledHeight}`);
437
- if (scaledHeight !== target.height) throw new Error(`scaler produced ${scaledHeight} of ${target.height} rows`);
438
- return dstBuffer;
439
- } finally {
684
+ scaler.getContext(srcW, srcH, C.AV_PIX_FMT_YUV420P, dstW, dstH, dstFmt, C.SWS_FAST_BILINEAR);
685
+ const initRet = scaler.initContext();
686
+ if (initRet < 0) {
440
687
  scaler[Symbol.dispose]?.();
688
+ throw new Error(`sws_init_context failed: ${initRet}`);
441
689
  }
690
+ this.scaler = scaler;
691
+ this.scalerKey = key;
692
+ return scaler;
693
+ }
694
+ /**
695
+ * GPU crop+scale `toBuffer`: run the retained GPU surface through the cached
696
+ * GPU filtergraph (`scale_<be>→hwdownload→format` for the full-frame case),
697
+ * pulling ONLY the small detection-sized result into system memory, then pack
698
+ * it into the tightly-packed output buffer. On any GPU-filter failure,
699
+ * {@link degradeToSoftware} flips the whole session to the CPU path (via a
700
+ * re-dial) and this call rethrows so the pump drops just this one frame.
701
+ *
702
+ * Surface lifecycle: the input GPU surface is owned by {@link frames} (freed
703
+ * by the slot lifecycle) — `processAllSync` only ref's it. Every OUTPUT frame
704
+ * the filtergraph returns is freed here in the `finally`.
705
+ */
706
+ hwScaleToBuffer(frame, opts) {
707
+ try {
708
+ const region = resolveCropRegion(frame.width, frame.height, opts?.crop);
709
+ const target = resolveResizeTarget(region.width, region.height, opts?.resize);
710
+ const format = opts?.format ?? this.sessionFormat;
711
+ const channels = format === "gray" ? 1 : 3;
712
+ const outputs = this.ensureHwFilter(region, target, frame.width, frame.height, format).processAllSync(frame);
713
+ const first = outputs[0];
714
+ if (!first) throw new Error("GPU filtergraph produced no frame");
715
+ try {
716
+ const plane = first.data?.[0];
717
+ if (!plane) throw new Error("GPU filtergraph frame missing packed plane data");
718
+ return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, channels);
719
+ } finally {
720
+ for (const out of outputs) out.free();
721
+ }
722
+ } catch (err) {
723
+ this.degradeToSoftware();
724
+ throw err instanceof Error ? err : new Error(errMessage(err));
725
+ }
726
+ }
727
+ /**
728
+ * Build-once / reuse the GPU crop+scale {@link NavFilterApi} for a given crop
729
+ * region + resize target + pixel format — the HW analogue of
730
+ * {@link ensureScaler}. Rebuilt (old CLOSED, freeing its GPU surface pool)
731
+ * only when the key changes; detection's ROI + resize target are stable, so
732
+ * this is a per-worker singleton in the steady state.
733
+ */
734
+ ensureHwFilter(region, target, srcW, srcH, format) {
735
+ const nav = this.nav;
736
+ const scaleFilter = this.gpuScaleFilter;
737
+ if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: GPU filter requested without a HW context");
738
+ const fullFrame = isFullFrameRegion(region, srcW, srcH);
739
+ const pixel = pixelFilterName(format);
740
+ const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}`;
741
+ const cached = this.hwFilter;
742
+ if (cached && key === this.hwFilterKey) return cached;
743
+ this.hwFilter?.close();
744
+ this.hwFilter = null;
745
+ this.hwFilterKey = "";
746
+ const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel);
747
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
748
+ this.hwFilter = filter;
749
+ this.hwFilterKey = key;
750
+ return filter;
442
751
  }
443
752
  /**
444
753
  * Resolve the HW context ONCE (reused across every re-dial, freed in
445
754
  * `teardown`). Tries the platform's ordered candidate list; the first
446
755
  * `HardwareContext.create` that succeeds wins; every failure falls through
447
- * to software decode (`hardware` omitted from `Decoder.create`).
756
+ * to software decode (`hardware` omitted from `Decoder.create`). When a GPU
757
+ * device is found, the session becomes a GPU crop+scale CANDIDATE
758
+ * (`hwFilterDecision = 'undecided'`) only if a `scale_<be>` filter exists for
759
+ * that backend — otherwise it stays on the software path.
448
760
  */
449
761
  async ensureHwContext(nav, C) {
450
762
  for (const backend of candidateBackends()) {
@@ -456,6 +768,8 @@ var DecodeWorkerChild = class {
456
768
  continue;
457
769
  }
458
770
  this.hwContext = hw;
771
+ this.gpuScaleFilter = gpuScaleFilterForBackend(backend);
772
+ this.hwFilterDecision = this.gpuScaleFilter !== null ? "undecided" : "software";
459
773
  return;
460
774
  }
461
775
  process.stderr.write("decode-worker-child: no hwaccel backend available — using software decode\n");
@@ -469,8 +783,18 @@ var DecodeWorkerChild = class {
469
783
  }, ms);
470
784
  });
471
785
  }
472
- /** Dispose the CURRENT dial's demuxer + decoder (the HW context is reused across dials). */
786
+ /**
787
+ * Dispose the CURRENT dial's demuxer + decoder (the HW *device* context is
788
+ * reused across dials). The GPU filtergraph is closed FIRST: it references the
789
+ * decoder's per-dial `hw_frames_ctx` AND owns the `scale_<be>` OUTPUT surface
790
+ * pool, so freeing it before the decoder is what stops re-dials from
791
+ * accumulating VAAPI surfaces (the exact prior leak). It is rebuilt lazily on
792
+ * the next dial's first `toBuffer`.
793
+ */
473
794
  closeInput() {
795
+ this.hwFilter?.close();
796
+ this.hwFilter = null;
797
+ this.hwFilterKey = "";
474
798
  this.decoder?.[Symbol.dispose]?.();
475
799
  this.decoder = null;
476
800
  this.demuxer?.[Symbol.dispose]?.();