@camstack/addon-pipeline 1.1.53 → 1.1.54

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 (33) 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 +146 -7
  4. package/dist/detection-pipeline/index.mjs +146 -7
  5. package/dist/{dist-DWb62H5U.js → dist-BbaoC680.js} +110 -7
  6. package/dist/{dist-BalxNwOt.mjs → dist-CEcTeu1h.mjs} +110 -7
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +114 -5
  10. package/dist/pipeline-runner/index.mjs +114 -5
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{remote-source-plane-BCJW5CvF.mjs → remote-source-plane-CNCqC_XG.mjs} +1 -1
  14. package/dist/{remote-source-plane-CZpzIVro.js → remote-source-plane-vN45KFSm.js} +1 -1
  15. package/dist/session-decode/decode-worker-child.js +319 -35
  16. package/dist/session-decode/decode-worker-child.mjs +319 -35
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DIN3CcRP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-HS0tc6Sa.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B3gTdHEh.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C9fwKMfg.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_v5r5ya.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-CGUEq--X.mjs → hostInit-DgohfEDA.mjs} +3 -3
  22. package/dist/stream-broker/index.js +2 -2
  23. package/dist/stream-broker/index.mjs +2 -2
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/{worker-protocol-pk7qdYXt.mjs → worker-protocol-DGIt_waM.mjs} +6 -0
  26. package/dist/{worker-protocol-BCfO8gUF.js → worker-protocol-DkL6GDxe.js} +6 -0
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CqOe8eYa.js → MaskShapeCanvas-DI4BY7W2-CA2Hd6xx.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CZyLeUnd.js → MotionZonesSettings-NcxxQN8r-DUN1VeC7.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Cn0hGZnu.js → PrivacyMaskSettings-APgPLF7p-BqZIAU5s.js} +1 -1
  30. package/embed-dist/assets/{index-DRl4XYjA.js → index-CMkYypxB.js} +10 -10
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-o4tu_xuc.mjs +0 -26
@@ -1,11 +1,85 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../chunk-D6vf50IK.js");
3
- const require_worker_protocol = require("../worker-protocol-BCfO8gUF.js");
3
+ const require_worker_protocol = require("../worker-protocol-DkL6GDxe.js");
4
4
  let node_url = require("node:url");
5
5
  let node_fs = require("node:fs");
6
6
  node_fs = require_chunk.__toESM(node_fs);
7
7
  let node_path = require("node:path");
8
8
  node_path = require_chunk.__toESM(node_path);
9
+ //#region src/session-decode/crop-geometry.ts
10
+ /** Clamp to [min,max] then floor to the nearest even number (YUV420P chroma is 2x2 subsampled). */
11
+ function clampEven(value, min, max) {
12
+ const bounded = Math.min(Math.max(value, min), max);
13
+ return bounded - bounded % 2;
14
+ }
15
+ /** Resolve `FrameImageOptions.crop` against the decoded frame's dimensions, defaulting to the full frame. */
16
+ function resolveCropRegion(frameWidth, frameHeight, crop) {
17
+ const left = clampEven(crop?.left ?? 0, 0, Math.max(0, frameWidth - 2));
18
+ const top = clampEven(crop?.top ?? 0, 0, Math.max(0, frameHeight - 2));
19
+ return {
20
+ left,
21
+ top,
22
+ width: clampEven(crop?.width ?? frameWidth - left, 2, frameWidth - left),
23
+ height: clampEven(crop?.height ?? frameHeight - top, 2, frameHeight - top)
24
+ };
25
+ }
26
+ /** Resolve `FrameImageOptions.resize`, defaulting to the (already cropped) source dimensions. */
27
+ function resolveResizeTarget(cropWidth, cropHeight, resize) {
28
+ if (!resize) return {
29
+ width: cropWidth,
30
+ height: cropHeight
31
+ };
32
+ return {
33
+ width: Math.max(1, Math.round(resize.width)),
34
+ height: Math.max(1, Math.round(resize.height))
35
+ };
36
+ }
37
+ /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
38
+ function isFullFrameRegion(region, srcW, srcH) {
39
+ return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
40
+ }
41
+ /**
42
+ * Resolve a NORMALIZED [0,1] bbox against a native frame's real pixel dims into
43
+ * an even-aligned pixel crop region. Clamps the box inside the frame so a
44
+ * padded / slightly-out-of-frame bbox never reads past the plane; a degenerate
45
+ * (zero-area) box is widened to the 2px chroma minimum by
46
+ * {@link resolveCropRegion}'s own clamps.
47
+ */
48
+ function normalizedBboxToCrop(frameWidth, frameHeight, bbox) {
49
+ return {
50
+ left: Math.round(bbox.x * frameWidth),
51
+ top: Math.round(bbox.y * frameHeight),
52
+ width: Math.max(2, Math.round(bbox.w * frameWidth)),
53
+ height: Math.max(2, Math.round(bbox.h * frameHeight))
54
+ };
55
+ }
56
+ /**
57
+ * Aspect-preserving cap of a native crop region to `maxWidth`. Returns
58
+ * `undefined` (no resize → true native crop) when `maxWidth` is absent or the
59
+ * region is already within it.
60
+ */
61
+ function resolveNativeCropTarget(region, maxWidth) {
62
+ if (maxWidth === void 0 || maxWidth <= 0 || region.width <= maxWidth) return void 0;
63
+ const width = Math.round(maxWidth);
64
+ return {
65
+ width,
66
+ height: Math.max(1, Math.round(width * region.height / region.width))
67
+ };
68
+ }
69
+ /**
70
+ * Full native-crop geometry: normalized bbox → even-aligned source region +
71
+ * (optionally width-capped) output target. The single mapping the decode worker
72
+ * uses to turn a detection-res bbox into a native-res crop request.
73
+ */
74
+ function resolveNativeCropGeometry(frameWidth, frameHeight, bbox, maxWidth) {
75
+ const region = resolveCropRegion(frameWidth, frameHeight, normalizedBboxToCrop(frameWidth, frameHeight, bbox));
76
+ const resize = resolveNativeCropTarget(region, maxWidth);
77
+ return {
78
+ region,
79
+ target: resolveResizeTarget(region.width, region.height, resize)
80
+ };
81
+ }
82
+ //#endregion
9
83
  //#region src/session-decode/frame-slot.ts
10
84
  function toReply(held) {
11
85
  return {
@@ -20,6 +94,10 @@ function toReply(held) {
20
94
  * pending-pull flag. One instance per decode-worker child session.
21
95
  */
22
96
  var FrameSlot = class {
97
+ retainSuperseded;
98
+ constructor(options) {
99
+ this.retainSuperseded = options?.retainSuperseded;
100
+ }
23
101
  nextFrameId = 1;
24
102
  slot = null;
25
103
  /**
@@ -104,11 +182,61 @@ var FrameSlot = class {
104
182
  const previousReserved = this.reserved;
105
183
  this.reserved = held;
106
184
  this.slot = null;
107
- previousReserved?.frame.free();
185
+ if (previousReserved) {
186
+ if (!(this.retainSuperseded?.(previousReserved.frameId, previousReserved.frame) ?? false)) previousReserved.frame.free();
187
+ }
108
188
  return toReply(held);
109
189
  }
110
190
  };
111
191
  //#endregion
192
+ //#region src/session-decode/native-frame-ring.ts
193
+ /**
194
+ * A hard-capped, insertion-ordered map from worker frameId → retained frame.
195
+ * `capacity <= 0` disables retention entirely (every `retain` frees immediately
196
+ * and returns false) — the safe "keep the ring off" knob.
197
+ */
198
+ var NativeFrameRing = class {
199
+ capacity;
200
+ /** Insertion-ordered (Map preserves insertion order) frameId → frame. */
201
+ frames = /* @__PURE__ */ new Map();
202
+ constructor(capacity) {
203
+ this.capacity = capacity;
204
+ }
205
+ /** Number of frames currently retained (for tests / metrics). */
206
+ get size() {
207
+ return this.frames.size;
208
+ }
209
+ /**
210
+ * Take ownership of `frame` under `frameId`. Frees the oldest retained frame
211
+ * when the cap is exceeded (FIFO by frameId insertion). Returns `true` when
212
+ * the ring took ownership (caller MUST NOT free), `false` when retention is
213
+ * disabled (`capacity <= 0`) and the caller still owns/should free the frame.
214
+ */
215
+ retain(frameId, frame) {
216
+ if (this.capacity <= 0) return false;
217
+ const existing = this.frames.get(frameId);
218
+ if (existing) existing.free();
219
+ this.frames.set(frameId, frame);
220
+ while (this.frames.size > this.capacity) {
221
+ const oldestKey = this.frames.keys().next().value;
222
+ if (oldestKey === void 0) break;
223
+ const oldest = this.frames.get(oldestKey);
224
+ this.frames.delete(oldestKey);
225
+ oldest?.free();
226
+ }
227
+ return true;
228
+ }
229
+ /** The retained frame for `frameId`, or `null` if never retained / already evicted. */
230
+ get(frameId) {
231
+ return this.frames.get(frameId) ?? null;
232
+ }
233
+ /** Free and drop every retained frame. Idempotent (re-dial + teardown call it). */
234
+ clear() {
235
+ for (const frame of this.frames.values()) frame.free();
236
+ this.frames.clear();
237
+ }
238
+ };
239
+ //#endregion
112
240
  //#region src/session-decode/decode-worker-child.ts
113
241
  /**
114
242
  * Decode-worker CHILD entry point — Epic C P1, Task 2.
@@ -139,6 +267,20 @@ var REDIAL_MS = 3e3;
139
267
  /** How often the worker emits its `framesDecoded/framesSkipped/deliveredFps` line. */
140
268
  var METRICS_INTERVAL_MS = 1e4;
141
269
  /**
270
+ * Hard cap on the native-frame retention ring (native-res crop feature). Kept
271
+ * DELIBERATELY TINY — its only job is to cover the detection→crop latency
272
+ * (typically 1-4 frames), NOT the full shm ring depth. On the HW path every
273
+ * retained slot pins a GPU/VAAPI surface out of the decoder pool (the historic
274
+ * leak site), so the cap MUST stay small; a miss is free (caller falls back to
275
+ * today's detection-frame crop). Override with `CAMSTACK_SESSION_NATIVE_CROP_RING`
276
+ * (0 disables retention entirely); clamped to [0,4], default 2.
277
+ */
278
+ var NATIVE_RING_CAP = (() => {
279
+ const raw = Number(process.env["CAMSTACK_SESSION_NATIVE_CROP_RING"]);
280
+ if (!Number.isFinite(raw)) return 2;
281
+ return Math.min(4, Math.max(0, Math.floor(raw)));
282
+ })();
283
+ /**
142
284
  * The libav GPU scale filter for a hwaccel backend, or `null` when none is
143
285
  * known here — those backends fall back to the software crop+scale path.
144
286
  * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
@@ -276,37 +418,6 @@ function backendToHwDeviceConst(backend, consts) {
276
418
  default: return null;
277
419
  }
278
420
  }
279
- /** Clamp to [min,max] then floor to the nearest even number (YUV420P chroma is 2x2 subsampled). */
280
- function clampEven(value, min, max) {
281
- const bounded = Math.min(Math.max(value, min), max);
282
- return bounded - bounded % 2;
283
- }
284
- /** Resolve `FrameImageOptions.crop` against the decoded frame's dimensions, defaulting to the full frame. */
285
- function resolveCropRegion(frameWidth, frameHeight, crop) {
286
- const left = clampEven(crop?.left ?? 0, 0, Math.max(0, frameWidth - 2));
287
- const top = clampEven(crop?.top ?? 0, 0, Math.max(0, frameHeight - 2));
288
- return {
289
- left,
290
- top,
291
- width: clampEven(crop?.width ?? frameWidth - left, 2, frameWidth - left),
292
- height: clampEven(crop?.height ?? frameHeight - top, 2, frameHeight - top)
293
- };
294
- }
295
- /** Resolve `FrameImageOptions.resize`, defaulting to the (already cropped) source dimensions. */
296
- function resolveResizeTarget(cropWidth, cropHeight, resize) {
297
- if (!resize) return {
298
- width: cropWidth,
299
- height: cropHeight
300
- };
301
- return {
302
- width: Math.max(1, Math.round(resize.width)),
303
- height: Math.max(1, Math.round(resize.height))
304
- };
305
- }
306
- /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
307
- function isFullFrameRegion(region, srcW, srcH) {
308
- return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
309
- }
310
421
  /**
311
422
  * Build the libav filtergraph description for the GPU crop+scale path.
312
423
  *
@@ -388,6 +499,30 @@ var DecodeWorkerChild = class {
388
499
  */
389
500
  hwFilter = null;
390
501
  hwFilterKey = "";
502
+ /**
503
+ * DEDICATED native-crop GPU filtergraph + software scaler, kept SEPARATE from
504
+ * the detection {@link hwFilter}/{@link scaler} so a native-crop request never
505
+ * evicts (rebuilds) the hot-path detection filter cache — the crop geometry
506
+ * differs from the stable detection geometry, so sharing one cache would
507
+ * thrash it. Same dispose discipline: both are closed on every re-dial
508
+ * ({@link closeInput}) and on teardown. Rebuilt lazily per crop geometry.
509
+ */
510
+ nativeCropHwFilter = null;
511
+ nativeCropHwFilterKey = "";
512
+ nativeCropScaler = null;
513
+ nativeCropScalerKey = "";
514
+ /**
515
+ * Bounded native-frame retention ring — the survival window for a frame's
516
+ * NATIVE surface between detection (on the shipped small frame) and the crop
517
+ * request that follows. Hard-capped at {@link NATIVE_RING_CAP}; single
518
+ * ownership via the {@link FrameSlot} `retainSuperseded` hook (a frame enters
519
+ * the ring exactly when the slot would otherwise free it). Cleared on every
520
+ * re-dial + teardown (see {@link closeInput}).
521
+ */
522
+ nativeRing = new NativeFrameRing(NATIVE_RING_CAP);
523
+ /** Throttled native-crop hit/miss counters, emitted with the throughput line. */
524
+ nativeCropHits = 0;
525
+ nativeCropMisses = 0;
391
526
  /** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
392
527
  framesDecoded = 0;
393
528
  /** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
@@ -420,7 +555,7 @@ var DecodeWorkerChild = class {
420
555
  * the reserve-on-pull invariant (`e3c78a9b`) is unit-tested without
421
556
  * node-av. See that file's doc comment for the full semantics.
422
557
  */
423
- frames = new FrameSlot();
558
+ frames = new FrameSlot({ retainSuperseded: (frameId, frame) => this.nativeRing.retain(frameId, frame) });
424
559
  /**
425
560
  * Mirrors whether `this.frames` currently has a pull waiting, so
426
561
  * `teardown` knows to resolve it with `{kind:'ended'}` — `FrameSlot`
@@ -446,6 +581,9 @@ var DecodeWorkerChild = class {
446
581
  case "toBuffer":
447
582
  this.handleToBuffer(message.frameId, message.opts);
448
583
  return;
584
+ case "nativeCrop":
585
+ this.handleNativeCrop(message.requestId, message.frameId, message.bbox, message.maxWidth);
586
+ return;
449
587
  case "stop":
450
588
  this.handleStop();
451
589
  return;
@@ -470,6 +608,9 @@ var DecodeWorkerChild = class {
470
608
  this.scaler?.[Symbol.dispose]?.();
471
609
  this.scaler = null;
472
610
  this.scalerKey = "";
611
+ this.nativeCropScaler?.[Symbol.dispose]?.();
612
+ this.nativeCropScaler = null;
613
+ this.nativeCropScalerKey = "";
473
614
  this.hwContext?.[Symbol.dispose]?.();
474
615
  this.hwContext = null;
475
616
  if (this.pendingPull) {
@@ -484,7 +625,7 @@ var DecodeWorkerChild = class {
484
625
  const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
485
626
  const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
486
627
  const skipped = this.framesSkipped + this.frames.droppedCount;
487
- this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
628
+ this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}, nativeCropHits:${this.nativeCropHits}, nativeCropMisses:${this.nativeCropMisses}}${final ? " (final)" : ""}\n`);
488
629
  this.lastMetricsAt = now;
489
630
  this.lastDeliveredSnapshot = this.framesDelivered;
490
631
  }
@@ -534,6 +675,145 @@ var DecodeWorkerChild = class {
534
675
  this.sendError(`decode-worker-child: toBuffer failed — ${errMessage(err)}`, frameId);
535
676
  }
536
677
  }
678
+ /**
679
+ * Best-effort NATIVE-resolution crop of a still-retained frame. Resolves the
680
+ * frame from the reserved slot (the latest, most common case) or the
681
+ * retention ring (an older frame detection has since moved past). A miss
682
+ * (frame aged out) or ANY crop error replies `nativeCropMiss` so the caller
683
+ * falls back to today's detection-frame crop — it NEVER degrades the
684
+ * detection session (unlike {@link hwScaleToBuffer}, which flips the whole
685
+ * session to software on a GPU-filter error).
686
+ */
687
+ handleNativeCrop(requestId, frameId, bbox, maxWidth) {
688
+ const frame = this.nativeRing.get(frameId) ?? this.frames.toBuffer(frameId);
689
+ if (!frame) {
690
+ this.nativeCropMisses++;
691
+ this.send({
692
+ kind: "nativeCropMiss",
693
+ requestId
694
+ });
695
+ return;
696
+ }
697
+ try {
698
+ const resolved = this.resolveNativeCrop(frame, bbox, maxWidth);
699
+ const bytes = frame.isHwFrame() ? this.nativeHwCropToBuffer(frame, resolved) : this.nativeScaleCropToBuffer(frame, resolved);
700
+ this.nativeCropHits++;
701
+ this.send({
702
+ kind: "nativeCropResult",
703
+ requestId,
704
+ bytes,
705
+ width: resolved.target.width,
706
+ height: resolved.target.height
707
+ });
708
+ } catch (err) {
709
+ this.nativeCropMisses++;
710
+ this.emitStderr(`decode-worker-child: native crop failed (requestId ${requestId}) — ${errMessage(err)}\n`);
711
+ this.send({
712
+ kind: "nativeCropMiss",
713
+ requestId
714
+ });
715
+ }
716
+ }
717
+ /** Map a normalized bbox to an even-aligned pixel crop + (optionally capped) target. */
718
+ resolveNativeCrop(frame, bbox, maxWidth) {
719
+ return resolveNativeCropGeometry(frame.width, frame.height, bbox, maxWidth);
720
+ }
721
+ /**
722
+ * Software native crop — the leak-free path: offset the retained YUV420P
723
+ * frame's plane pointers to the crop origin (zero-copy) and scale ONLY the
724
+ * ROI into a tightly-packed rgb buffer via a DEDICATED scaler (never the
725
+ * detection {@link scaler}). Mirrors {@link scaleRoiToBuffer}, rgb-fixed.
726
+ */
727
+ nativeScaleCropToBuffer(frame, resolved) {
728
+ const nav = this.nav;
729
+ const C = this.consts;
730
+ if (!nav || !C) throw new Error("node-av not initialized");
731
+ const { region, target } = resolved;
732
+ const planes = frame.data;
733
+ const strides = frame.linesize;
734
+ const yPlane = planes?.[0];
735
+ const uPlane = planes?.[1];
736
+ const vPlane = planes?.[2];
737
+ if (!yPlane || !uPlane || !vPlane) throw new Error("decode-worker-child: retained frame missing planar YUV420P data");
738
+ const yStride = strides[0] ?? 0;
739
+ const cStride = strides[1] ?? 0;
740
+ const ySlice = yPlane.subarray(region.top * yStride + region.left);
741
+ const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
742
+ const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
743
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height);
744
+ const dstStride = target.width * 3;
745
+ const dstBuffer = Buffer.allocUnsafe(dstStride * target.height);
746
+ const scaledHeight = scaler.scaleSync([
747
+ ySlice,
748
+ uSlice,
749
+ vSlice
750
+ ], [
751
+ yStride,
752
+ cStride,
753
+ cStride
754
+ ], 0, region.height, [dstBuffer], [dstStride]);
755
+ if (scaledHeight < 0) throw new Error(`native crop sws_scale failed: ${scaledHeight}`);
756
+ if (scaledHeight !== target.height) throw new Error(`native crop scaler produced ${scaledHeight} of ${target.height} rows`);
757
+ return dstBuffer;
758
+ }
759
+ /**
760
+ * Hardware native crop — run the retained GPU surface through a DEDICATED
761
+ * crop filtergraph (`hwdownload,format=nv12,crop=…,scale=…,format=rgb24`) and
762
+ * pack the small result. Mirrors {@link hwScaleToBuffer} but uses its OWN
763
+ * cached filter and, on error, throws WITHOUT degrading the session.
764
+ */
765
+ nativeHwCropToBuffer(frame, resolved) {
766
+ const { region, target } = resolved;
767
+ const outputs = this.ensureNativeCropHwFilter(region, target).processAllSync(frame);
768
+ const first = outputs[0];
769
+ if (!first) {
770
+ for (const out of outputs) out.free();
771
+ throw new Error("native crop GPU filtergraph produced no frame");
772
+ }
773
+ try {
774
+ const plane = first.data?.[0];
775
+ if (!plane) throw new Error("native crop GPU frame missing packed plane data");
776
+ return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, 3);
777
+ } finally {
778
+ for (const out of outputs) out.free();
779
+ }
780
+ }
781
+ /** Build-once / reuse the dedicated native-crop software scaler (rgb out). */
782
+ ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH) {
783
+ const key = `${srcW}x${srcH}->${dstW}x${dstH}`;
784
+ const cached = this.nativeCropScaler;
785
+ if (cached && key === this.nativeCropScalerKey) return cached;
786
+ this.nativeCropScaler?.[Symbol.dispose]?.();
787
+ this.nativeCropScaler = null;
788
+ this.nativeCropScalerKey = "";
789
+ const scaler = new nav.SoftwareScaleContext();
790
+ scaler.getContext(srcW, srcH, C.AV_PIX_FMT_YUV420P, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
791
+ const initRet = scaler.initContext();
792
+ if (initRet < 0) {
793
+ scaler[Symbol.dispose]?.();
794
+ throw new Error(`native crop sws_init_context failed: ${initRet}`);
795
+ }
796
+ this.nativeCropScaler = scaler;
797
+ this.nativeCropScalerKey = key;
798
+ return scaler;
799
+ }
800
+ /** Build-once / reuse the dedicated native-crop GPU filtergraph (rgb out). */
801
+ ensureNativeCropHwFilter(region, target) {
802
+ const nav = this.nav;
803
+ const scaleFilter = this.gpuScaleFilter;
804
+ if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: native GPU crop requested without a HW context");
805
+ const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}`;
806
+ const cached = this.nativeCropHwFilter;
807
+ if (cached && key === this.nativeCropHwFilterKey) return cached;
808
+ this.nativeCropHwFilter?.close();
809
+ this.nativeCropHwFilter = null;
810
+ this.nativeCropHwFilterKey = "";
811
+ const description = buildGpuFilterDescription(scaleFilter, region, target, false, "rgb24");
812
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
813
+ this.nativeCropHwFilter = filter;
814
+ this.nativeCropHwFilterKey = key;
815
+ return filter;
816
+ }
537
817
  handleStop() {
538
818
  this.teardown();
539
819
  process.exit(0);
@@ -887,6 +1167,10 @@ var DecodeWorkerChild = class {
887
1167
  * the next dial's first `toBuffer`.
888
1168
  */
889
1169
  closeInput() {
1170
+ this.nativeRing.clear();
1171
+ this.nativeCropHwFilter?.close();
1172
+ this.nativeCropHwFilter = null;
1173
+ this.nativeCropHwFilterKey = "";
890
1174
  this.hwFilter?.close();
891
1175
  this.hwFilter = null;
892
1176
  this.hwFilterKey = "";