@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
@@ -23,6 +23,19 @@ var FrameSlot = class {
23
23
  */
24
24
  reserved = null;
25
25
  pendingPull = false;
26
+ /** Frames dropped by the `minIntervalMs` throttle (never entered the slot). */
27
+ throttledCount = 0;
28
+ /** Frames dropped by latest-wins (an unpulled slot frame superseded before delivery). */
29
+ supersededCount = 0;
30
+ /**
31
+ * Total frames this slot has DROPPED before delivery — the sum of the
32
+ * `minIntervalMs` throttle drops and the latest-wins (drop-older)
33
+ * supersessions. Read by the decode-worker child for its `framesSkipped`
34
+ * metric; purely additive, never affects slot behaviour.
35
+ */
36
+ get droppedCount() {
37
+ return this.throttledCount + this.supersededCount;
38
+ }
26
39
  /**
27
40
  * Publish a newly decoded frame into the latest-wins slot, freeing
28
41
  * whatever the slot held before (the drop-older half of "latest-wins").
@@ -32,6 +45,7 @@ var FrameSlot = class {
32
45
  */
33
46
  publish(frame, timestamp, minIntervalMs) {
34
47
  if (minIntervalMs > 0 && this.slot && timestamp - this.slot.timestamp < minIntervalMs) {
48
+ this.throttledCount++;
35
49
  frame.free();
36
50
  return null;
37
51
  }
@@ -44,6 +58,7 @@ var FrameSlot = class {
44
58
  width: frame.width,
45
59
  height: frame.height
46
60
  };
61
+ if (previous) this.supersededCount++;
47
62
  previous?.frame.free();
48
63
  if (this.pendingPull) {
49
64
  this.pendingPull = false;
@@ -90,6 +105,27 @@ var FrameSlot = class {
90
105
  //#region src/session-decode/decode-worker-child.ts
91
106
  /** Re-dial backoff on a transient dial error/EOF (mirrors the decoder's PULL_REDIAL_MS). */
92
107
  var REDIAL_MS = 3e3;
108
+ /** How often the worker emits its `framesDecoded/framesSkipped/deliveredFps` line. */
109
+ var METRICS_INTERVAL_MS = 1e4;
110
+ /**
111
+ * The libav GPU scale filter for a hwaccel backend, or `null` when none is
112
+ * known here — those backends fall back to the software crop+scale path.
113
+ * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
114
+ */
115
+ function gpuScaleFilterForBackend(backend) {
116
+ switch (backend) {
117
+ case "vaapi": return "scale_vaapi";
118
+ case "qsv": return "scale_qsv";
119
+ case "cuda":
120
+ case "nvdec": return "scale_cuda";
121
+ case "videotoolbox": return "scale_vt";
122
+ default: return null;
123
+ }
124
+ }
125
+ /** libav `format` filter target name for the requested detection pixel format. */
126
+ function pixelFilterName(format) {
127
+ return format === "gray" ? "gray" : "rgb24";
128
+ }
93
129
  var _nav = null;
94
130
  var _consts = null;
95
131
  async function getNodeAv() {
@@ -182,6 +218,45 @@ function resolveResizeTarget(cropWidth, cropHeight, resize) {
182
218
  height: Math.max(1, Math.round(resize.height))
183
219
  };
184
220
  }
221
+ /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
222
+ function isFullFrameRegion(region, srcW, srcH) {
223
+ return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
224
+ }
225
+ /**
226
+ * Build the libav filtergraph description for the GPU crop+scale path.
227
+ *
228
+ * Full-frame — the ONLY shape the session-decode pump ever asks for (it passes
229
+ * `resize`+`format`, never `crop`, see `session-decode-pump.ts`): scale on the
230
+ * GPU, then `hwdownload` pulls only the small detection-sized surface into
231
+ * system memory + a cheap pixel-convert. This is byte-for-byte the chain the
232
+ * ffmpeg decoder already ships (`addon-decoder-ffmpeg/src/ffmpeg-args.ts`
233
+ * `buildVideoFilter`): `scale_<be>=w=W:h=H,hwdownload,format=nv12,format=<pixel>`.
234
+ *
235
+ * Sub-region crop — contract-supported but unused on the hot path: the generic
236
+ * `crop` filter offsets plane POINTERS, which corrupts opaque VAAPI/QSV
237
+ * surfaces, so there is no safe GPU crop for those. A real crop is therefore
238
+ * done on the CPU AFTER `hwdownload` (`hwdownload,format=nv12,crop=…,scale=…`).
239
+ * It is correct and never crashes; it just forgoes the GPU win for that rare
240
+ * call — exactly the safety/behaviour trade-off the task calls for.
241
+ */
242
+ function buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel) {
243
+ if (fullFrame) return `${scaleFilter}=w=${target.width}:h=${target.height},hwdownload,format=nv12,format=${pixel}`;
244
+ return `hwdownload,format=nv12,crop=${region.width}:${region.height}:${region.left}:${region.top},scale=${target.width}:${target.height},format=${pixel}`;
245
+ }
246
+ /**
247
+ * Copy a single-plane packed frame (rgb24 / gray8 out of the filtergraph) into
248
+ * a tightly-packed `Uint8Array` of `width*channels*height`, stripping libav's
249
+ * row padding (`linesize` ≥ `width*channels`). Keeps the `toBuffer` output
250
+ * contract byte-identical to the software `scaleRoiToBuffer`.
251
+ */
252
+ function packSinglePlane(plane, linesize, width, height, channels) {
253
+ const rowBytes = width * channels;
254
+ const stride = linesize > 0 ? linesize : rowBytes;
255
+ const out = Buffer.allocUnsafe(rowBytes * height);
256
+ if (stride === rowBytes) plane.copy(out, 0, 0, rowBytes * height);
257
+ else for (let row = 0; row < height; row++) plane.copy(out, row * rowBytes, row * stride, row * stride + rowBytes);
258
+ return out;
259
+ }
185
260
  /**
186
261
  * The forked child's whole runtime: decode-loop lifecycle, the latest-wins
187
262
  * frame slot, and the ROI scaler for `toBuffer`. One instance per process.
@@ -201,11 +276,57 @@ var DecodeWorkerChild = class {
201
276
  */
202
277
  scaler = null;
203
278
  scalerKey = "";
279
+ /** libav GPU scale filter for the resolved hwaccel backend, or null when GPU filtering isn't available. */
280
+ gpuScaleFilter = null;
281
+ /**
282
+ * GPU crop+scale path decision — picked ONCE per session.
283
+ * - `software`: the decoder downloads every frame to YUV420P system memory and
284
+ * `toBuffer` uses the CPU {@link scaler}. This is the original, leak-safe
285
+ * behaviour; the default until a HW device + `scale_<be>` filter is found.
286
+ * - `undecided`: a HW device + GPU scale filter are available; the decoder
287
+ * emits GPU surfaces and the FIRST HW frame is probed through the GPU
288
+ * filtergraph — success ⇒ `hardware`, any failure ⇒ `software` (re-dial).
289
+ * - `hardware`: the decoder emits GPU surfaces and `toBuffer` runs the GPU
290
+ * crop+scale, downloading only the tiny detection-sized result.
291
+ */
292
+ hwFilterDecision = "software";
293
+ /** Skip the re-dial backoff when the re-dial is specifically to switch to software. */
294
+ redialAsSoftware = false;
295
+ /**
296
+ * Cached GPU crop+scale filtergraph + the geometry/format key it was built
297
+ * for — the HW analogue of {@link scaler}/{@link scalerKey}. Rebuilt (old
298
+ * CLOSED) only when the key changes. Its internal `scale_<be>` OUTPUT hw
299
+ * frames pool is the leak-prone VAAPI surface pool, so it is closed on EVERY
300
+ * re-dial ({@link closeInput}) and on teardown — this discipline is what stops
301
+ * the prior "keeping decoded frames on the GPU leaked the VAAPI surface pool
302
+ * on every re-dial" incident from recurring.
303
+ */
304
+ hwFilter = null;
305
+ hwFilterKey = "";
306
+ /** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
307
+ framesDecoded = 0;
308
+ /** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
309
+ framesSkipped = 0;
310
+ /** Frames actually handed to the parent as a `frame` reply. */
311
+ framesDelivered = 0;
312
+ lastMetricsAt = 0;
313
+ lastDeliveredSnapshot = 0;
314
+ metricsTimer = null;
204
315
  demuxer = null;
205
316
  decoder = null;
206
317
  abortController = null;
207
318
  started = false;
208
319
  stopped = false;
320
+ /**
321
+ * The numeric device id this worker decodes for — learned from the `start`
322
+ * message's {@link VideoFrameSource}. The child's stderr is INHERITED by the
323
+ * pipeline-runner (silent:false fork), so these lines land in the runner's
324
+ * raw stderr WITHOUT passing through a scoped logger; prefixing every line
325
+ * with `[dev:<id>]` (see {@link emitStderr}) is what lets the per-device log
326
+ * UI attribute the forked worker's output to its camera. `null` only for the
327
+ * brief pre-`start` window (e.g. a malformed first message).
328
+ */
329
+ deviceId = null;
209
330
  sessionFormat = "rgb";
210
331
  minIntervalMs = 0;
211
332
  /**
@@ -253,9 +374,14 @@ var DecodeWorkerChild = class {
253
374
  clearTimeout(this.redialTimer);
254
375
  this.redialTimer = null;
255
376
  }
377
+ if (this.metricsTimer) {
378
+ clearInterval(this.metricsTimer);
379
+ this.metricsTimer = null;
380
+ }
381
+ if (this.started) this.logMetrics(true);
256
382
  this.abortController?.abort();
257
- this.closeInput();
258
383
  this.frames.free();
384
+ this.closeInput();
259
385
  this.scaler?.[Symbol.dispose]?.();
260
386
  this.scaler = null;
261
387
  this.scalerKey = "";
@@ -266,14 +392,29 @@ var DecodeWorkerChild = class {
266
392
  this.send({ kind: "ended" });
267
393
  }
268
394
  }
395
+ /** Emit the compact throughput line (window `deliveredFps`); cheap, integer-only. */
396
+ logMetrics(final) {
397
+ const now = Date.now();
398
+ const windowMs = Math.max(1, now - this.lastMetricsAt);
399
+ const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
400
+ const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
401
+ const skipped = this.framesSkipped + this.frames.droppedCount;
402
+ this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
403
+ this.lastMetricsAt = now;
404
+ this.lastDeliveredSnapshot = this.framesDelivered;
405
+ }
269
406
  handleStart(source, opts) {
270
407
  if (this.started) {
271
- process.stderr.write("decode-worker-child: duplicate start ignored\n");
408
+ this.emitStderr("decode-worker-child: duplicate start ignored\n");
272
409
  return;
273
410
  }
274
411
  this.started = true;
412
+ this.deviceId = source.deviceId;
275
413
  this.sessionFormat = opts.format ?? "rgb";
276
414
  this.minIntervalMs = opts.fps && opts.fps > 0 ? 1e3 / opts.fps : 0;
415
+ this.lastMetricsAt = Date.now();
416
+ this.metricsTimer = setInterval(() => this.logMetrics(false), METRICS_INTERVAL_MS);
417
+ this.metricsTimer.unref?.();
277
418
  this.runDecodeLoop(source.restreamUrl).catch((err) => {
278
419
  this.sendError(`decode-worker-child: decode loop crashed — ${errMessage(err)}`);
279
420
  });
@@ -285,7 +426,7 @@ var DecodeWorkerChild = class {
285
426
  }
286
427
  const reply = this.frames.pull();
287
428
  if (reply) {
288
- this.send(this.toFrameReply(reply));
429
+ this.deliverFrame(reply);
289
430
  return;
290
431
  }
291
432
  this.pendingPull = true;
@@ -298,7 +439,7 @@ var DecodeWorkerChild = class {
298
439
  return;
299
440
  }
300
441
  try {
301
- const bytes = this.scaleRoiToBuffer(frame, opts);
442
+ const bytes = frame.isHwFrame() ? this.hwScaleToBuffer(frame, opts) : this.scaleRoiToBuffer(frame, opts);
302
443
  this.send({
303
444
  kind: "buffer",
304
445
  frameId,
@@ -325,26 +466,34 @@ var DecodeWorkerChild = class {
325
466
  await this.dialAndDecode(nav, C, url);
326
467
  } catch (err) {
327
468
  if (this.stopped) break;
328
- process.stderr.write(`decode-worker-child: dial ended — re-dial scheduled: ${errMessage(err)}\n`);
469
+ this.emitStderr(`decode-worker-child: dial ended — re-dial scheduled: ${errMessage(err)}\n`);
329
470
  } finally {
330
471
  this.closeInput();
331
472
  }
332
473
  if (this.stopped) break;
333
- await this.sleep(REDIAL_MS);
474
+ const skipBackoff = this.redialAsSoftware;
475
+ this.redialAsSoftware = false;
476
+ if (!skipBackoff) await this.sleep(REDIAL_MS);
334
477
  }
335
478
  }
336
479
  /**
337
- * One dial: open the demuxer, pick the video stream, build a HW/SW decoder
338
- * that downloads + normalises frames to YUV420P system memory (so the ROI
339
- * scaler always reads plain software planes regardless of hwaccel), and
340
- * pump `demuxer.packets → decoder.frames → consumeDecodedFrame` until the
480
+ * One dial: open the demuxer, pick the video stream, build a HW/SW decoder,
481
+ * and pump `demuxer.packets decoder.frames consumeDecodedFrame` until the
341
482
  * stream ends, errors, or teardown aborts it.
342
483
  *
343
- * Copied from `nodeav-decoder-session.ts` `pullDialAndDecode` (lines
344
- * ~916-996): same demuxer options, same `rescale.pixelFormat: YUV420P`
345
- * download-always contract (keeping decoded frames on the GPU leaked the
346
- * VAAPI surface pool on every re-dial see that file's comment), same
347
- * `exitOnError: false` bad-frame tolerance.
484
+ * Two decoder shapes, selected by {@link hwFilterDecision} (picked once per
485
+ * session):
486
+ * - HW path (`undecided`/`hardware`): NO `rescale` the decoder keeps frames
487
+ * as GPU surfaces so `toBuffer` can crop+scale them on the GPU and download
488
+ * only the small detection-sized result.
489
+ * - Software path (`software`): `rescale.pixelFormat: YUV420P` forces a
490
+ * full-res GPU→system download so the CPU ROI scaler reads plain planes.
491
+ * This is the ORIGINAL contract — keeping decoded frames on the GPU leaked
492
+ * the VAAPI surface pool on every re-dial (see `nodeav-decoder-session.ts`);
493
+ * the HW path avoids that leak by closing its filtergraph + freeing every
494
+ * surface on every re-dial/teardown (see {@link closeInput}/{@link teardown}).
495
+ *
496
+ * Same demuxer options + `exitOnError: false` bad-frame tolerance throughout.
348
497
  */
349
498
  async dialAndDecode(nav, C, url) {
350
499
  this.abortController = new AbortController();
@@ -359,9 +508,10 @@ var DecodeWorkerChild = class {
359
508
  this.demuxer = demuxer;
360
509
  const videoStream = demuxer.video();
361
510
  if (!videoStream) throw new Error("decode-worker-child: pull input has no video stream");
511
+ const wantHwFrames = this.hwContext !== null && this.hwFilterDecision !== "software";
362
512
  const decoder = await nav.Decoder.create(videoStream, {
363
513
  ...this.hwContext ? { hardware: this.hwContext } : {},
364
- rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P },
514
+ ...wantHwFrames ? {} : { rescale: { pixelFormat: C.AV_PIX_FMT_YUV420P } },
365
515
  exitOnError: false
366
516
  });
367
517
  if (this.stopped) {
@@ -373,24 +523,103 @@ var DecodeWorkerChild = class {
373
523
  if (this.stopped) break;
374
524
  if (!frame) continue;
375
525
  this.consumeDecodedFrame(frame);
526
+ if (this.redialAsSoftware) break;
376
527
  }
377
528
  }
378
529
  /**
379
- * Publish a decoded frame into the latest-wins slot (mirrors Scrypted's
380
- * `libav.py:32,50-53` single-slot queue) via `FrameSlot`, which handles
381
- * drop-older, the `minIntervalMs` throttle, and resolving a pending `pull`.
530
+ * Route a decoded frame according to the session's {@link hwFilterDecision}.
531
+ * On the software path a frame is published straight to the latest-wins slot;
532
+ * on the HW path the FIRST GPU surface is probed through the GPU filtergraph
533
+ * to commit (or fall back) the path, then GPU surfaces are published for
534
+ * `toBuffer` to crop+scale on the GPU. Publishing itself (drop-older, the
535
+ * `minIntervalMs` throttle, resolving a pending `pull`) is `FrameSlot`'s job.
382
536
  */
383
537
  consumeDecodedFrame(frame) {
384
- if (frame.isHwFrame()) {
538
+ this.framesDecoded++;
539
+ if (this.hwFilterDecision === "software") {
540
+ if (frame.isHwFrame()) {
541
+ this.framesSkipped++;
542
+ frame.free();
543
+ return;
544
+ }
545
+ this.publishFrame(frame);
546
+ return;
547
+ }
548
+ if (!frame.isHwFrame()) {
549
+ this.framesSkipped++;
385
550
  frame.free();
551
+ this.degradeToSoftware();
386
552
  return;
387
553
  }
554
+ if (this.hwFilterDecision === "undecided") {
555
+ if (!this.probeHwFilter(frame)) {
556
+ this.framesSkipped++;
557
+ frame.free();
558
+ this.degradeToSoftware();
559
+ return;
560
+ }
561
+ this.hwFilterDecision = "hardware";
562
+ this.emitStderr(`decode-worker-child: GPU crop+scale path active (${this.gpuScaleFilter})\n`);
563
+ }
564
+ this.publishFrame(frame);
565
+ }
566
+ /** Publish to the latest-wins slot; deliver immediately if a `pull` is pending. */
567
+ publishFrame(frame) {
388
568
  const reply = this.frames.publish(frame, Date.now(), this.minIntervalMs);
389
569
  if (reply) {
390
570
  this.pendingPull = false;
391
- this.send(this.toFrameReply(reply));
571
+ this.deliverFrame(reply);
392
572
  }
393
573
  }
574
+ /** Send a `frame` reply to the parent and count it toward `deliveredFps`. */
575
+ deliverFrame(reply) {
576
+ this.framesDelivered++;
577
+ this.send(this.toFrameReply(reply));
578
+ }
579
+ /**
580
+ * One-shot GPU-filter capability probe on the FIRST decoded HW frame: build a
581
+ * no-op `scale_<be>=iw:ih,hwdownload,format=nv12` graph and push the frame
582
+ * through it. A frame out ⇒ the driver supports GPU crop+scale for this
583
+ * session; any throw / empty output ⇒ fall back to software. The probe graph
584
+ * and its output frames are freed here regardless of outcome, so no surface
585
+ * leaks out of the probe. The input `frame` keeps its own ref (buffersrc only
586
+ * ref'd it) and is published by the caller on success.
587
+ */
588
+ probeHwFilter(frame) {
589
+ const nav = this.nav;
590
+ const scaleFilter = this.gpuScaleFilter;
591
+ if (!nav || !scaleFilter || !this.hwContext) return false;
592
+ let filter = null;
593
+ try {
594
+ filter = nav.FilterAPI.create(`${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12`, { hardware: this.hwContext });
595
+ const outputs = filter.processAllSync(frame);
596
+ const ok = outputs.length > 0;
597
+ for (const out of outputs) out.free();
598
+ return ok;
599
+ } catch (err) {
600
+ this.emitStderr(`decode-worker-child: GPU filter probe failed — using software decode: ${errMessage(err)}\n`);
601
+ return false;
602
+ } finally {
603
+ filter?.close();
604
+ }
605
+ }
606
+ /**
607
+ * Abandon the GPU filter path for the rest of the session: close the
608
+ * filtergraph (freeing its GPU surface pool), mark the decision `software`,
609
+ * and abort the current HW dial so the decode loop re-dials with a YUV420P
610
+ * rescale. Called from the probe path AND from a runtime `toBuffer` GPU-filter
611
+ * failure, so a surprising driver/format error never leaves the session
612
+ * silently producing no usable frames. Idempotent.
613
+ */
614
+ degradeToSoftware() {
615
+ if (this.hwFilterDecision === "software") return;
616
+ this.hwFilterDecision = "software";
617
+ this.redialAsSoftware = true;
618
+ this.hwFilter?.close();
619
+ this.hwFilter = null;
620
+ this.hwFilterKey = "";
621
+ this.abortController?.abort();
622
+ }
394
623
  toFrameReply(reply) {
395
624
  return {
396
625
  kind: "frame",
@@ -473,10 +702,71 @@ var DecodeWorkerChild = class {
473
702
  return scaler;
474
703
  }
475
704
  /**
705
+ * GPU crop+scale `toBuffer`: run the retained GPU surface through the cached
706
+ * GPU filtergraph (`scale_<be>→hwdownload→format` for the full-frame case),
707
+ * pulling ONLY the small detection-sized result into system memory, then pack
708
+ * it into the tightly-packed output buffer. On any GPU-filter failure,
709
+ * {@link degradeToSoftware} flips the whole session to the CPU path (via a
710
+ * re-dial) and this call rethrows so the pump drops just this one frame.
711
+ *
712
+ * Surface lifecycle: the input GPU surface is owned by {@link frames} (freed
713
+ * by the slot lifecycle) — `processAllSync` only ref's it. Every OUTPUT frame
714
+ * the filtergraph returns is freed here in the `finally`.
715
+ */
716
+ hwScaleToBuffer(frame, opts) {
717
+ try {
718
+ const region = resolveCropRegion(frame.width, frame.height, opts?.crop);
719
+ const target = resolveResizeTarget(region.width, region.height, opts?.resize);
720
+ const format = opts?.format ?? this.sessionFormat;
721
+ const channels = format === "gray" ? 1 : 3;
722
+ const outputs = this.ensureHwFilter(region, target, frame.width, frame.height, format).processAllSync(frame);
723
+ const first = outputs[0];
724
+ if (!first) throw new Error("GPU filtergraph produced no frame");
725
+ try {
726
+ const plane = first.data?.[0];
727
+ if (!plane) throw new Error("GPU filtergraph frame missing packed plane data");
728
+ return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, channels);
729
+ } finally {
730
+ for (const out of outputs) out.free();
731
+ }
732
+ } catch (err) {
733
+ this.degradeToSoftware();
734
+ throw err instanceof Error ? err : new Error(errMessage(err));
735
+ }
736
+ }
737
+ /**
738
+ * Build-once / reuse the GPU crop+scale {@link NavFilterApi} for a given crop
739
+ * region + resize target + pixel format — the HW analogue of
740
+ * {@link ensureScaler}. Rebuilt (old CLOSED, freeing its GPU surface pool)
741
+ * only when the key changes; detection's ROI + resize target are stable, so
742
+ * this is a per-worker singleton in the steady state.
743
+ */
744
+ ensureHwFilter(region, target, srcW, srcH, format) {
745
+ const nav = this.nav;
746
+ const scaleFilter = this.gpuScaleFilter;
747
+ if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: GPU filter requested without a HW context");
748
+ const fullFrame = isFullFrameRegion(region, srcW, srcH);
749
+ const pixel = pixelFilterName(format);
750
+ const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}:${pixel}`;
751
+ const cached = this.hwFilter;
752
+ if (cached && key === this.hwFilterKey) return cached;
753
+ this.hwFilter?.close();
754
+ this.hwFilter = null;
755
+ this.hwFilterKey = "";
756
+ const description = buildGpuFilterDescription(scaleFilter, region, target, fullFrame, pixel);
757
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
758
+ this.hwFilter = filter;
759
+ this.hwFilterKey = key;
760
+ return filter;
761
+ }
762
+ /**
476
763
  * Resolve the HW context ONCE (reused across every re-dial, freed in
477
764
  * `teardown`). Tries the platform's ordered candidate list; the first
478
765
  * `HardwareContext.create` that succeeds wins; every failure falls through
479
- * to software decode (`hardware` omitted from `Decoder.create`).
766
+ * to software decode (`hardware` omitted from `Decoder.create`). When a GPU
767
+ * device is found, the session becomes a GPU crop+scale CANDIDATE
768
+ * (`hwFilterDecision = 'undecided'`) only if a `scale_<be>` filter exists for
769
+ * that backend — otherwise it stays on the software path.
480
770
  */
481
771
  async ensureHwContext(nav, C) {
482
772
  for (const backend of candidateBackends()) {
@@ -484,13 +774,15 @@ var DecodeWorkerChild = class {
484
774
  if (deviceType === null) continue;
485
775
  const hw = nav.HardwareContext.create(deviceType);
486
776
  if (!hw) {
487
- process.stderr.write(`decode-worker-child: hwaccel candidate '${backend}' failed — trying next\n`);
777
+ this.emitStderr(`decode-worker-child: hwaccel candidate '${backend}' failed — trying next\n`);
488
778
  continue;
489
779
  }
490
780
  this.hwContext = hw;
781
+ this.gpuScaleFilter = gpuScaleFilterForBackend(backend);
782
+ this.hwFilterDecision = this.gpuScaleFilter !== null ? "undecided" : "software";
491
783
  return;
492
784
  }
493
- process.stderr.write("decode-worker-child: no hwaccel backend available — using software decode\n");
785
+ this.emitStderr("decode-worker-child: no hwaccel backend available — using software decode\n");
494
786
  }
495
787
  /** Interruptible re-dial backoff — `teardown` clears the pending timer. */
496
788
  sleep(ms) {
@@ -501,8 +793,18 @@ var DecodeWorkerChild = class {
501
793
  }, ms);
502
794
  });
503
795
  }
504
- /** Dispose the CURRENT dial's demuxer + decoder (the HW context is reused across dials). */
796
+ /**
797
+ * Dispose the CURRENT dial's demuxer + decoder (the HW *device* context is
798
+ * reused across dials). The GPU filtergraph is closed FIRST: it references the
799
+ * decoder's per-dial `hw_frames_ctx` AND owns the `scale_<be>` OUTPUT surface
800
+ * pool, so freeing it before the decoder is what stops re-dials from
801
+ * accumulating VAAPI surfaces (the exact prior leak). It is rebuilt lazily on
802
+ * the next dial's first `toBuffer`.
803
+ */
505
804
  closeInput() {
805
+ this.hwFilter?.close();
806
+ this.hwFilter = null;
807
+ this.hwFilterKey = "";
506
808
  this.decoder?.[Symbol.dispose]?.();
507
809
  this.decoder = null;
508
810
  this.demuxer?.[Symbol.dispose]?.();
@@ -511,8 +813,19 @@ var DecodeWorkerChild = class {
511
813
  send(reply) {
512
814
  process.send?.(reply);
513
815
  }
816
+ /**
817
+ * Write one diagnostic line to the (inherited) stderr, prefixed with the
818
+ * device tag so the per-device log UI can attribute the forked worker's
819
+ * output. Public so the module-level process handlers — which only hold the
820
+ * `worker` instance — emit device-tagged lines too. Callers pass the line
821
+ * INCLUDING its trailing newline (unchanged from the prior direct writes).
822
+ */
823
+ emitStderr(line) {
824
+ const tag = this.deviceId === null ? "[dev:?]" : `[dev:${this.deviceId}]`;
825
+ process.stderr.write(`${tag} ${line}`);
826
+ }
514
827
  sendError(message, frameId) {
515
- process.stderr.write(`${message}\n`);
828
+ this.emitStderr(`${message}\n`);
516
829
  this.send(frameId === void 0 ? {
517
830
  kind: "error",
518
831
  message
@@ -532,12 +845,12 @@ process.on("disconnect", () => {
532
845
  process.exit(0);
533
846
  });
534
847
  process.on("uncaughtException", (err) => {
535
- process.stderr.write(`decode-worker-child: uncaught exception — ${errMessage(err)}\n`);
848
+ worker.emitStderr(`decode-worker-child: uncaught exception — ${errMessage(err)}\n`);
536
849
  worker.teardown();
537
850
  process.exit(1);
538
851
  });
539
852
  process.on("unhandledRejection", (reason) => {
540
- process.stderr.write(`decode-worker-child: unhandled rejection — ${errMessage(reason)}\n`);
853
+ worker.emitStderr(`decode-worker-child: unhandled rejection — ${errMessage(reason)}\n`);
541
854
  });
542
855
  //#endregion
543
856
  export { DecodeWorkerChild };
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.1.18",
6
+ version: "1.1.19",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_stream_broker_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.31",
21
+ version: "1.1.32",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_stream_broker_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.1.26",
36
+ version: "1.1.27",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.31",
39
+ version: "1.1.32",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.1.18",
48
+ version: "1.1.19",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.1.26",
84
+ version: "1.1.27",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,