@camstack/addon-pipeline 1.1.41 → 1.1.43

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 (27) 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 +10 -7
  4. package/dist/detection-pipeline/index.mjs +10 -7
  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 +19 -12
  14. package/dist/recorder/index.mjs +19 -12
  15. package/dist/session-decode/decode-worker-child.js +341 -28
  16. package/dist/session-decode/decode-worker-child.mjs +341 -28
  17. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2l0kmD4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs} +3 -3
  18. package/dist/stream-broker/{hostInit-DHaCRMjM.mjs → hostInit-DmjqskOR.mjs} +3 -3
  19. package/dist/stream-broker/index.js +96 -63
  20. package/dist/stream-broker/index.mjs +96 -63
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-B6fza7ic.js → MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-Cy-4iTog.js → MotionZonesSettings-NcxxQN8r-D43fIRgB.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CYX33lJb.js → PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js} +1 -1
  25. package/embed-dist/assets/{index-BPkayx9u.js → index-CX-rILhw.js} +4 -4
  26. package/embed-dist/index.html +1 -1
  27. package/package.json +1 -1
@@ -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.
@@ -202,11 +277,57 @@ var DecodeWorkerChild = class {
202
277
  */
203
278
  scaler = null;
204
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;
205
316
  demuxer = null;
206
317
  decoder = null;
207
318
  abortController = null;
208
319
  started = false;
209
320
  stopped = false;
321
+ /**
322
+ * The numeric device id this worker decodes for — learned from the `start`
323
+ * message's {@link VideoFrameSource}. The child's stderr is INHERITED by the
324
+ * pipeline-runner (silent:false fork), so these lines land in the runner's
325
+ * raw stderr WITHOUT passing through a scoped logger; prefixing every line
326
+ * with `[dev:<id>]` (see {@link emitStderr}) is what lets the per-device log
327
+ * UI attribute the forked worker's output to its camera. `null` only for the
328
+ * brief pre-`start` window (e.g. a malformed first message).
329
+ */
330
+ deviceId = null;
210
331
  sessionFormat = "rgb";
211
332
  minIntervalMs = 0;
212
333
  /**
@@ -254,9 +375,14 @@ var DecodeWorkerChild = class {
254
375
  clearTimeout(this.redialTimer);
255
376
  this.redialTimer = null;
256
377
  }
378
+ if (this.metricsTimer) {
379
+ clearInterval(this.metricsTimer);
380
+ this.metricsTimer = null;
381
+ }
382
+ if (this.started) this.logMetrics(true);
257
383
  this.abortController?.abort();
258
- this.closeInput();
259
384
  this.frames.free();
385
+ this.closeInput();
260
386
  this.scaler?.[Symbol.dispose]?.();
261
387
  this.scaler = null;
262
388
  this.scalerKey = "";
@@ -267,14 +393,29 @@ var DecodeWorkerChild = class {
267
393
  this.send({ kind: "ended" });
268
394
  }
269
395
  }
396
+ /** Emit the compact throughput line (window `deliveredFps`); cheap, integer-only. */
397
+ logMetrics(final) {
398
+ const now = Date.now();
399
+ const windowMs = Math.max(1, now - this.lastMetricsAt);
400
+ const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
401
+ const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
402
+ const skipped = this.framesSkipped + this.frames.droppedCount;
403
+ this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
404
+ this.lastMetricsAt = now;
405
+ this.lastDeliveredSnapshot = this.framesDelivered;
406
+ }
270
407
  handleStart(source, opts) {
271
408
  if (this.started) {
272
- process.stderr.write("decode-worker-child: duplicate start ignored\n");
409
+ this.emitStderr("decode-worker-child: duplicate start ignored\n");
273
410
  return;
274
411
  }
275
412
  this.started = true;
413
+ this.deviceId = source.deviceId;
276
414
  this.sessionFormat = opts.format ?? "rgb";
277
415
  this.minIntervalMs = opts.fps && opts.fps > 0 ? 1e3 / opts.fps : 0;
416
+ this.lastMetricsAt = Date.now();
417
+ this.metricsTimer = setInterval(() => this.logMetrics(false), METRICS_INTERVAL_MS);
418
+ this.metricsTimer.unref?.();
278
419
  this.runDecodeLoop(source.restreamUrl).catch((err) => {
279
420
  this.sendError(`decode-worker-child: decode loop crashed — ${errMessage(err)}`);
280
421
  });
@@ -286,7 +427,7 @@ var DecodeWorkerChild = class {
286
427
  }
287
428
  const reply = this.frames.pull();
288
429
  if (reply) {
289
- this.send(this.toFrameReply(reply));
430
+ this.deliverFrame(reply);
290
431
  return;
291
432
  }
292
433
  this.pendingPull = true;
@@ -299,7 +440,7 @@ var DecodeWorkerChild = class {
299
440
  return;
300
441
  }
301
442
  try {
302
- const bytes = this.scaleRoiToBuffer(frame, opts);
443
+ const bytes = frame.isHwFrame() ? this.hwScaleToBuffer(frame, opts) : this.scaleRoiToBuffer(frame, opts);
303
444
  this.send({
304
445
  kind: "buffer",
305
446
  frameId,
@@ -326,26 +467,34 @@ var DecodeWorkerChild = class {
326
467
  await this.dialAndDecode(nav, C, url);
327
468
  } catch (err) {
328
469
  if (this.stopped) break;
329
- process.stderr.write(`decode-worker-child: dial ended — re-dial scheduled: ${errMessage(err)}\n`);
470
+ this.emitStderr(`decode-worker-child: dial ended — re-dial scheduled: ${errMessage(err)}\n`);
330
471
  } finally {
331
472
  this.closeInput();
332
473
  }
333
474
  if (this.stopped) break;
334
- await this.sleep(REDIAL_MS);
475
+ const skipBackoff = this.redialAsSoftware;
476
+ this.redialAsSoftware = false;
477
+ if (!skipBackoff) await this.sleep(REDIAL_MS);
335
478
  }
336
479
  }
337
480
  /**
338
- * One dial: open the demuxer, pick the video stream, build a HW/SW decoder
339
- * that downloads + normalises frames to YUV420P system memory (so the ROI
340
- * scaler always reads plain software planes regardless of hwaccel), and
341
- * pump `demuxer.packets → decoder.frames → consumeDecodedFrame` until the
481
+ * One dial: open the demuxer, pick the video stream, build a HW/SW decoder,
482
+ * and pump `demuxer.packets decoder.frames consumeDecodedFrame` until the
342
483
  * stream ends, errors, or teardown aborts it.
343
484
  *
344
- * Copied from `nodeav-decoder-session.ts` `pullDialAndDecode` (lines
345
- * ~916-996): same demuxer options, same `rescale.pixelFormat: YUV420P`
346
- * download-always contract (keeping decoded frames on the GPU leaked the
347
- * VAAPI surface pool on every re-dial see that file's comment), same
348
- * `exitOnError: false` bad-frame tolerance.
485
+ * Two decoder shapes, selected by {@link hwFilterDecision} (picked once per
486
+ * session):
487
+ * - HW path (`undecided`/`hardware`): NO `rescale` the decoder keeps frames
488
+ * as GPU surfaces so `toBuffer` can crop+scale them on the GPU and download
489
+ * only the small detection-sized result.
490
+ * - Software path (`software`): `rescale.pixelFormat: YUV420P` forces a
491
+ * full-res GPU→system download so the CPU ROI scaler reads plain planes.
492
+ * This is the ORIGINAL contract — keeping decoded frames on the GPU leaked
493
+ * the VAAPI surface pool on every re-dial (see `nodeav-decoder-session.ts`);
494
+ * the HW path avoids that leak by closing its filtergraph + freeing every
495
+ * surface on every re-dial/teardown (see {@link closeInput}/{@link teardown}).
496
+ *
497
+ * Same demuxer options + `exitOnError: false` bad-frame tolerance throughout.
349
498
  */
350
499
  async dialAndDecode(nav, C, url) {
351
500
  this.abortController = new AbortController();
@@ -360,9 +509,10 @@ var DecodeWorkerChild = class {
360
509
  this.demuxer = demuxer;
361
510
  const videoStream = demuxer.video();
362
511
  if (!videoStream) throw new Error("decode-worker-child: pull input has no video stream");
512
+ const wantHwFrames = this.hwContext !== null && this.hwFilterDecision !== "software";
363
513
  const decoder = await nav.Decoder.create(videoStream, {
364
514
  ...this.hwContext ? { hardware: this.hwContext } : {},
365
- rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
515
+ ...wantHwFrames ? {} : { rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P } },
366
516
  exitOnError: false
367
517
  });
368
518
  if (this.stopped) {
@@ -374,24 +524,103 @@ var DecodeWorkerChild = class {
374
524
  if (this.stopped) break;
375
525
  if (!frame) continue;
376
526
  this.consumeDecodedFrame(frame);
527
+ if (this.redialAsSoftware) break;
377
528
  }
378
529
  }
379
530
  /**
380
- * Publish a decoded frame into the latest-wins slot (mirrors Scrypted's
381
- * `libav.py:32,50-53` single-slot queue) via `FrameSlot`, which handles
382
- * drop-older, the `minIntervalMs` throttle, and resolving a pending `pull`.
531
+ * Route a decoded frame according to the session's {@link hwFilterDecision}.
532
+ * On the software path a frame is published straight to the latest-wins slot;
533
+ * on the HW path the FIRST GPU surface is probed through the GPU filtergraph
534
+ * to commit (or fall back) the path, then GPU surfaces are published for
535
+ * `toBuffer` to crop+scale on the GPU. Publishing itself (drop-older, the
536
+ * `minIntervalMs` throttle, resolving a pending `pull`) is `FrameSlot`'s job.
383
537
  */
384
538
  consumeDecodedFrame(frame) {
385
- if (frame.isHwFrame()) {
539
+ this.framesDecoded++;
540
+ if (this.hwFilterDecision === "software") {
541
+ if (frame.isHwFrame()) {
542
+ this.framesSkipped++;
543
+ frame.free();
544
+ return;
545
+ }
546
+ this.publishFrame(frame);
547
+ return;
548
+ }
549
+ if (!frame.isHwFrame()) {
550
+ this.framesSkipped++;
386
551
  frame.free();
552
+ this.degradeToSoftware();
387
553
  return;
388
554
  }
555
+ if (this.hwFilterDecision === "undecided") {
556
+ if (!this.probeHwFilter(frame)) {
557
+ this.framesSkipped++;
558
+ frame.free();
559
+ this.degradeToSoftware();
560
+ return;
561
+ }
562
+ this.hwFilterDecision = "hardware";
563
+ this.emitStderr(`decode-worker-child: GPU crop+scale path active (${this.gpuScaleFilter})\n`);
564
+ }
565
+ this.publishFrame(frame);
566
+ }
567
+ /** Publish to the latest-wins slot; deliver immediately if a `pull` is pending. */
568
+ publishFrame(frame) {
389
569
  const reply = this.frames.publish(frame, Date.now(), this.minIntervalMs);
390
570
  if (reply) {
391
571
  this.pendingPull = false;
392
- this.send(this.toFrameReply(reply));
572
+ this.deliverFrame(reply);
393
573
  }
394
574
  }
575
+ /** Send a `frame` reply to the parent and count it toward `deliveredFps`. */
576
+ deliverFrame(reply) {
577
+ this.framesDelivered++;
578
+ this.send(this.toFrameReply(reply));
579
+ }
580
+ /**
581
+ * One-shot GPU-filter capability probe on the FIRST decoded HW frame: build a
582
+ * no-op `scale_<be>=iw:ih,hwdownload,format=nv12` graph and push the frame
583
+ * through it. A frame out ⇒ the driver supports GPU crop+scale for this
584
+ * session; any throw / empty output ⇒ fall back to software. The probe graph
585
+ * and its output frames are freed here regardless of outcome, so no surface
586
+ * leaks out of the probe. The input `frame` keeps its own ref (buffersrc only
587
+ * ref'd it) and is published by the caller on success.
588
+ */
589
+ probeHwFilter(frame) {
590
+ const nav = this.nav;
591
+ const scaleFilter = this.gpuScaleFilter;
592
+ if (!nav || !scaleFilter || !this.hwContext) return false;
593
+ let filter = null;
594
+ try {
595
+ filter = nav.FilterAPI.create(`${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12`, { hardware: this.hwContext });
596
+ const outputs = filter.processAllSync(frame);
597
+ const ok = outputs.length > 0;
598
+ for (const out of outputs) out.free();
599
+ return ok;
600
+ } catch (err) {
601
+ this.emitStderr(`decode-worker-child: GPU filter probe failed — using software decode: ${errMessage(err)}\n`);
602
+ return false;
603
+ } finally {
604
+ filter?.close();
605
+ }
606
+ }
607
+ /**
608
+ * Abandon the GPU filter path for the rest of the session: close the
609
+ * filtergraph (freeing its GPU surface pool), mark the decision `software`,
610
+ * and abort the current HW dial so the decode loop re-dials with a YUV420P
611
+ * rescale. Called from the probe path AND from a runtime `toBuffer` GPU-filter
612
+ * failure, so a surprising driver/format error never leaves the session
613
+ * silently producing no usable frames. Idempotent.
614
+ */
615
+ degradeToSoftware() {
616
+ if (this.hwFilterDecision === "software") return;
617
+ this.hwFilterDecision = "software";
618
+ this.redialAsSoftware = true;
619
+ this.hwFilter?.close();
620
+ this.hwFilter = null;
621
+ this.hwFilterKey = "";
622
+ this.abortController?.abort();
623
+ }
395
624
  toFrameReply(reply) {
396
625
  return {
397
626
  kind: "frame",
@@ -474,10 +703,71 @@ var DecodeWorkerChild = class {
474
703
  return scaler;
475
704
  }
476
705
  /**
706
+ * GPU crop+scale `toBuffer`: run the retained GPU surface through the cached
707
+ * GPU filtergraph (`scale_<be>→hwdownload→format` for the full-frame case),
708
+ * pulling ONLY the small detection-sized result into system memory, then pack
709
+ * it into the tightly-packed output buffer. On any GPU-filter failure,
710
+ * {@link degradeToSoftware} flips the whole session to the CPU path (via a
711
+ * re-dial) and this call rethrows so the pump drops just this one frame.
712
+ *
713
+ * Surface lifecycle: the input GPU surface is owned by {@link frames} (freed
714
+ * by the slot lifecycle) — `processAllSync` only ref's it. Every OUTPUT frame
715
+ * the filtergraph returns is freed here in the `finally`.
716
+ */
717
+ hwScaleToBuffer(frame, opts) {
718
+ try {
719
+ const region = resolveCropRegion(frame.width, frame.height, opts?.crop);
720
+ const target = resolveResizeTarget(region.width, region.height, opts?.resize);
721
+ const format = opts?.format ?? this.sessionFormat;
722
+ const channels = format === "gray" ? 1 : 3;
723
+ const outputs = this.ensureHwFilter(region, target, frame.width, frame.height, format).processAllSync(frame);
724
+ const first = outputs[0];
725
+ if (!first) throw new Error("GPU filtergraph produced no frame");
726
+ try {
727
+ const plane = first.data?.[0];
728
+ if (!plane) throw new Error("GPU filtergraph frame missing packed plane data");
729
+ return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, channels);
730
+ } finally {
731
+ for (const out of outputs) out.free();
732
+ }
733
+ } catch (err) {
734
+ this.degradeToSoftware();
735
+ throw err instanceof Error ? err : new Error(errMessage(err));
736
+ }
737
+ }
738
+ /**
739
+ * Build-once / reuse the GPU crop+scale {@link NavFilterApi} for a given crop
740
+ * region + resize target + pixel format — the HW analogue of
741
+ * {@link ensureScaler}. Rebuilt (old CLOSED, freeing its GPU surface pool)
742
+ * only when the key changes; detection's ROI + resize target are stable, so
743
+ * this is a per-worker singleton in the steady state.
744
+ */
745
+ ensureHwFilter(region, target, srcW, srcH, format) {
746
+ const nav = this.nav;
747
+ const scaleFilter = this.gpuScaleFilter;
748
+ if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: GPU filter requested without a HW context");
749
+ const fullFrame = isFullFrameRegion(region, srcW, srcH);
750
+ const pixel = pixelFilterName(format);
751
+ const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}`;
752
+ const cached = this.hwFilter;
753
+ if (cached && key === this.hwFilterKey) return cached;
754
+ this.hwFilter?.close();
755
+ this.hwFilter = null;
756
+ this.hwFilterKey = "";
757
+ const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel);
758
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
759
+ this.hwFilter = filter;
760
+ this.hwFilterKey = key;
761
+ return filter;
762
+ }
763
+ /**
477
764
  * Resolve the HW context ONCE (reused across every re-dial, freed in
478
765
  * `teardown`). Tries the platform's ordered candidate list; the first
479
766
  * `HardwareContext.create` that succeeds wins; every failure falls through
480
- * to software decode (`hardware` omitted from `Decoder.create`).
767
+ * to software decode (`hardware` omitted from `Decoder.create`). When a GPU
768
+ * device is found, the session becomes a GPU crop+scale CANDIDATE
769
+ * (`hwFilterDecision = 'undecided'`) only if a `scale_<be>` filter exists for
770
+ * that backend — otherwise it stays on the software path.
481
771
  */
482
772
  async ensureHwContext(nav, C) {
483
773
  for (const backend of candidateBackends()) {
@@ -485,13 +775,15 @@ var DecodeWorkerChild = class {
485
775
  if (deviceType === null) continue;
486
776
  const hw = nav.HardwareContext.create(deviceType);
487
777
  if (!hw) {
488
- process.stderr.write(`decode-worker-child: hwaccel candidate '${backend}' failed — trying next\n`);
778
+ this.emitStderr(`decode-worker-child: hwaccel candidate '${backend}' failed — trying next\n`);
489
779
  continue;
490
780
  }
491
781
  this.hwContext = hw;
782
+ this.gpuScaleFilter = gpuScaleFilterForBackend(backend);
783
+ this.hwFilterDecision = this.gpuScaleFilter !== null ? "undecided" : "software";
492
784
  return;
493
785
  }
494
- process.stderr.write("decode-worker-child: no hwaccel backend available — using software decode\n");
786
+ this.emitStderr("decode-worker-child: no hwaccel backend available — using software decode\n");
495
787
  }
496
788
  /** Interruptible re-dial backoff — `teardown` clears the pending timer. */
497
789
  sleep(ms) {
@@ -502,8 +794,18 @@ var DecodeWorkerChild = class {
502
794
  }, ms);
503
795
  });
504
796
  }
505
- /** Dispose the CURRENT dial's demuxer + decoder (the HW context is reused across dials). */
797
+ /**
798
+ * Dispose the CURRENT dial's demuxer + decoder (the HW *device* context is
799
+ * reused across dials). The GPU filtergraph is closed FIRST: it references the
800
+ * decoder's per-dial `hw_frames_ctx` AND owns the `scale_<be>` OUTPUT surface
801
+ * pool, so freeing it before the decoder is what stops re-dials from
802
+ * accumulating VAAPI surfaces (the exact prior leak). It is rebuilt lazily on
803
+ * the next dial's first `toBuffer`.
804
+ */
506
805
  closeInput() {
806
+ this.hwFilter?.close();
807
+ this.hwFilter = null;
808
+ this.hwFilterKey = "";
507
809
  this.decoder?.[Symbol.dispose]?.();
508
810
  this.decoder = null;
509
811
  this.demuxer?.[Symbol.dispose]?.();
@@ -512,8 +814,19 @@ var DecodeWorkerChild = class {
512
814
  send(reply) {
513
815
  process.send?.(reply);
514
816
  }
817
+ /**
818
+ * Write one diagnostic line to the (inherited) stderr, prefixed with the
819
+ * device tag so the per-device log UI can attribute the forked worker's
820
+ * output. Public so the module-level process handlers — which only hold the
821
+ * `worker` instance — emit device-tagged lines too. Callers pass the line
822
+ * INCLUDING its trailing newline (unchanged from the prior direct writes).
823
+ */
824
+ emitStderr(line) {
825
+ const tag = this.deviceId === null ? "[dev:?]" : `[dev:${this.deviceId}]`;
826
+ process.stderr.write(`${tag} ${line}`);
827
+ }
515
828
  sendError(message, frameId) {
516
- process.stderr.write(`${message}\n`);
829
+ this.emitStderr(`${message}\n`);
517
830
  this.send(frameId === void 0 ? {
518
831
  kind: "error",
519
832
  message
@@ -533,12 +846,12 @@ process.on("disconnect", () => {
533
846
  process.exit(0);
534
847
  });
535
848
  process.on("uncaughtException", (err) => {
536
- process.stderr.write(`decode-worker-child: uncaught exception — ${errMessage(err)}\n`);
849
+ worker.emitStderr(`decode-worker-child: uncaught exception — ${errMessage(err)}\n`);
537
850
  worker.teardown();
538
851
  process.exit(1);
539
852
  });
540
853
  process.on("unhandledRejection", (reason) => {
541
- process.stderr.write(`decode-worker-child: unhandled rejection — ${errMessage(reason)}\n`);
854
+ worker.emitStderr(`decode-worker-child: unhandled rejection — ${errMessage(reason)}\n`);
542
855
  });
543
856
  //#endregion
544
857
  exports.DecodeWorkerChild = DecodeWorkerChild;