@camstack/addon-pipeline 1.1.53 → 1.1.55

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 (35) 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 +289 -1469
  4. package/dist/detection-pipeline/index.mjs +269 -1449
  5. package/dist/{dist-DWb62H5U.js → dist-DI57FC8K.js} +167 -8
  6. package/dist/{dist-BalxNwOt.mjs → dist-RWGGPwVx.mjs} +167 -8
  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 +772 -21
  10. package/dist/pipeline-runner/index.mjs +772 -22
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{remote-source-plane-CZpzIVro.js → remote-source-plane-CHgvzzA6.js} +1 -1
  14. package/dist/{remote-source-plane-BCJW5CvF.mjs → remote-source-plane-DU0aRSPv.mjs} +1 -1
  15. package/dist/session-decode/decode-worker-child.js +538 -35
  16. package/dist/session-decode/decode-worker-child.mjs +538 -35
  17. package/dist/step-definitions-CNBFKjZe.js +1514 -0
  18. package/dist/step-definitions-CP9kVSml.mjs +1479 -0
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DIN3CcRP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-mDCPzmT3.mjs} +3 -3
  21. 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
  22. 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-Dn_pxzP-.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-CGUEq--X.mjs → hostInit-GM_CI22k.mjs} +3 -3
  24. package/dist/stream-broker/index.js +2 -2
  25. package/dist/stream-broker/index.mjs +2 -2
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-pk7qdYXt.mjs → worker-protocol-CyVJTZEO.mjs} +7 -0
  28. package/dist/{worker-protocol-BCfO8gUF.js → worker-protocol-PP4jKHHJ.js} +7 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CqOe8eYa.js → MaskShapeCanvas-DI4BY7W2-BChW0ntM.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CZyLeUnd.js → MotionZonesSettings-NcxxQN8r-DDzqEbSe.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Cn0hGZnu.js → PrivacyMaskSettings-APgPLF7p-B879GXaf.js} +1 -1
  32. package/embed-dist/assets/{index-DRl4XYjA.js → index-CNjQ5rAE.js} +10 -10
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. 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,7 +1,81 @@
1
- import { n as isWorkerRequest } from "../worker-protocol-pk7qdYXt.mjs";
1
+ import { n as isWorkerRequest } from "../worker-protocol-CyVJTZEO.mjs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
5
+ //#region src/session-decode/crop-geometry.ts
6
+ /** Clamp to [min,max] then floor to the nearest even number (YUV420P chroma is 2x2 subsampled). */
7
+ function clampEven(value, min, max) {
8
+ const bounded = Math.min(Math.max(value, min), max);
9
+ return bounded - bounded % 2;
10
+ }
11
+ /** Resolve `FrameImageOptions.crop` against the decoded frame's dimensions, defaulting to the full frame. */
12
+ function resolveCropRegion(frameWidth, frameHeight, crop) {
13
+ const left = clampEven(crop?.left ?? 0, 0, Math.max(0, frameWidth - 2));
14
+ const top = clampEven(crop?.top ?? 0, 0, Math.max(0, frameHeight - 2));
15
+ return {
16
+ left,
17
+ top,
18
+ width: clampEven(crop?.width ?? frameWidth - left, 2, frameWidth - left),
19
+ height: clampEven(crop?.height ?? frameHeight - top, 2, frameHeight - top)
20
+ };
21
+ }
22
+ /** Resolve `FrameImageOptions.resize`, defaulting to the (already cropped) source dimensions. */
23
+ function resolveResizeTarget(cropWidth, cropHeight, resize) {
24
+ if (!resize) return {
25
+ width: cropWidth,
26
+ height: cropHeight
27
+ };
28
+ return {
29
+ width: Math.max(1, Math.round(resize.width)),
30
+ height: Math.max(1, Math.round(resize.height))
31
+ };
32
+ }
33
+ /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
34
+ function isFullFrameRegion(region, srcW, srcH) {
35
+ return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
36
+ }
37
+ /**
38
+ * Resolve a NORMALIZED [0,1] bbox against a native frame's real pixel dims into
39
+ * an even-aligned pixel crop region. Clamps the box inside the frame so a
40
+ * padded / slightly-out-of-frame bbox never reads past the plane; a degenerate
41
+ * (zero-area) box is widened to the 2px chroma minimum by
42
+ * {@link resolveCropRegion}'s own clamps.
43
+ */
44
+ function normalizedBboxToCrop(frameWidth, frameHeight, bbox) {
45
+ return {
46
+ left: Math.round(bbox.x * frameWidth),
47
+ top: Math.round(bbox.y * frameHeight),
48
+ width: Math.max(2, Math.round(bbox.w * frameWidth)),
49
+ height: Math.max(2, Math.round(bbox.h * frameHeight))
50
+ };
51
+ }
52
+ /**
53
+ * Aspect-preserving cap of a native crop region to `maxWidth`. Returns
54
+ * `undefined` (no resize → true native crop) when `maxWidth` is absent or the
55
+ * region is already within it.
56
+ */
57
+ function resolveNativeCropTarget(region, maxWidth) {
58
+ if (maxWidth === void 0 || maxWidth <= 0 || region.width <= maxWidth) return void 0;
59
+ const width = Math.round(maxWidth);
60
+ return {
61
+ width,
62
+ height: Math.max(1, Math.round(width * region.height / region.width))
63
+ };
64
+ }
65
+ /**
66
+ * Full native-crop geometry: normalized bbox → even-aligned source region +
67
+ * (optionally width-capped) output target. The single mapping the decode worker
68
+ * uses to turn a detection-res bbox into a native-res crop request.
69
+ */
70
+ function resolveNativeCropGeometry(frameWidth, frameHeight, bbox, maxWidth) {
71
+ const region = resolveCropRegion(frameWidth, frameHeight, normalizedBboxToCrop(frameWidth, frameHeight, bbox));
72
+ const resize = resolveNativeCropTarget(region, maxWidth);
73
+ return {
74
+ region,
75
+ target: resolveResizeTarget(region.width, region.height, resize)
76
+ };
77
+ }
78
+ //#endregion
5
79
  //#region src/session-decode/frame-slot.ts
6
80
  function toReply(held) {
7
81
  return {
@@ -16,6 +90,10 @@ function toReply(held) {
16
90
  * pending-pull flag. One instance per decode-worker child session.
17
91
  */
18
92
  var FrameSlot = class {
93
+ retainSuperseded;
94
+ constructor(options) {
95
+ this.retainSuperseded = options?.retainSuperseded;
96
+ }
19
97
  nextFrameId = 1;
20
98
  slot = null;
21
99
  /**
@@ -100,11 +178,154 @@ var FrameSlot = class {
100
178
  const previousReserved = this.reserved;
101
179
  this.reserved = held;
102
180
  this.slot = null;
103
- previousReserved?.frame.free();
181
+ if (previousReserved) {
182
+ if (!(this.retainSuperseded?.(previousReserved.frameId, previousReserved.frame) ?? false)) previousReserved.frame.free();
183
+ }
104
184
  return toReply(held);
105
185
  }
106
186
  };
107
187
  //#endregion
188
+ //#region src/session-decode/native-frame-ring.ts
189
+ /**
190
+ * A hard-capped, insertion-ordered map from worker frameId → retained frame.
191
+ * `capacity <= 0` disables retention entirely (every `retain` frees immediately
192
+ * and returns false) — the safe "keep the ring off" knob.
193
+ */
194
+ var NativeFrameRing = class {
195
+ capacity;
196
+ /** Insertion-ordered (Map preserves insertion order) frameId → frame. */
197
+ frames = /* @__PURE__ */ new Map();
198
+ constructor(capacity) {
199
+ this.capacity = capacity;
200
+ }
201
+ /** Number of frames currently retained (for tests / metrics). */
202
+ get size() {
203
+ return this.frames.size;
204
+ }
205
+ /**
206
+ * Take ownership of `frame` under `frameId`. Frees the oldest retained frame
207
+ * when the cap is exceeded (FIFO by frameId insertion). Returns `true` when
208
+ * the ring took ownership (caller MUST NOT free), `false` when retention is
209
+ * disabled (`capacity <= 0`) and the caller still owns/should free the frame.
210
+ */
211
+ retain(frameId, frame) {
212
+ if (this.capacity <= 0) return false;
213
+ const existing = this.frames.get(frameId);
214
+ if (existing) existing.free();
215
+ this.frames.set(frameId, frame);
216
+ while (this.frames.size > this.capacity) {
217
+ const oldestKey = this.frames.keys().next().value;
218
+ if (oldestKey === void 0) break;
219
+ const oldest = this.frames.get(oldestKey);
220
+ this.frames.delete(oldestKey);
221
+ oldest?.free();
222
+ }
223
+ return true;
224
+ }
225
+ /** The retained frame for `frameId`, or `null` if never retained / already evicted. */
226
+ get(frameId) {
227
+ return this.frames.get(frameId) ?? null;
228
+ }
229
+ /** Free and drop every retained frame. Idempotent (re-dial + teardown call it). */
230
+ clear() {
231
+ for (const frame of this.frames.values()) frame.free();
232
+ this.frames.clear();
233
+ }
234
+ };
235
+ //#endregion
236
+ //#region src/session-decode/native-lease-store.ts
237
+ /**
238
+ * A hard-capped-by-bytes, TTL-and-release map from worker frameId → retained
239
+ * RAM frame. Insertion-ordered (`Map`), so budget eviction is FIFO by age.
240
+ */
241
+ var NativeLeaseStore = class {
242
+ entries = /* @__PURE__ */ new Map();
243
+ budgetBytes;
244
+ ttlMs;
245
+ now;
246
+ bytes = 0;
247
+ constructor(options) {
248
+ this.budgetBytes = options.budgetBytes;
249
+ this.ttlMs = options.ttlMs;
250
+ this.now = options.now ?? Date.now;
251
+ }
252
+ /** `false` when the store is disabled (`budgetBytes <= 0`). */
253
+ get enabled() {
254
+ return this.budgetBytes > 0;
255
+ }
256
+ /** Number of retained leases (tests / metrics). */
257
+ get size() {
258
+ return this.entries.size;
259
+ }
260
+ /** Total resident bytes across all retained leases (tests / metrics). */
261
+ get totalBytes() {
262
+ return this.bytes;
263
+ }
264
+ /**
265
+ * Take ownership of `frame` under `frameId`. Returns `true` when the store
266
+ * took ownership (caller MUST NOT free), `false` when the store is disabled
267
+ * (`budgetBytes <= 0`) and the frame was freed here — the caller then keeps
268
+ * today's GPU-surface-ring path. Sweeps expired leases first, then evicts the
269
+ * oldest while the total exceeds the budget.
270
+ */
271
+ put(frameId, frame) {
272
+ if (!this.enabled) {
273
+ frame.free();
274
+ return false;
275
+ }
276
+ this.drop(frameId);
277
+ this.sweepExpired();
278
+ this.entries.set(frameId, {
279
+ frame,
280
+ insertedAt: this.now()
281
+ });
282
+ this.bytes += frame.byteLength;
283
+ while (this.bytes > this.budgetBytes && this.entries.size > 0) {
284
+ const oldest = this.entries.keys().next().value;
285
+ if (oldest === void 0) break;
286
+ this.drop(oldest);
287
+ }
288
+ return true;
289
+ }
290
+ /**
291
+ * The retained frame for `frameId`, or `null` if never leased, already
292
+ * released/evicted, OR aged past its TTL (an expired lease is freed + dropped
293
+ * here so a caller that reads it just after expiry does not resurrect it).
294
+ */
295
+ get(frameId) {
296
+ const entry = this.entries.get(frameId);
297
+ if (!entry) return null;
298
+ if (this.now() - entry.insertedAt > this.ttlMs) {
299
+ this.drop(frameId);
300
+ return null;
301
+ }
302
+ return entry.frame;
303
+ }
304
+ /** Free + drop the lease for `frameId` (the prompt release path). Idempotent. */
305
+ release(frameId) {
306
+ this.drop(frameId);
307
+ }
308
+ /** Free + drop every lease older than the TTL. */
309
+ sweepExpired() {
310
+ const cutoff = this.now() - this.ttlMs;
311
+ for (const [frameId, entry] of this.entries) if (entry.insertedAt < cutoff) this.drop(frameId);
312
+ }
313
+ /** Free + drop every retained lease. Idempotent (re-dial + teardown call it). */
314
+ clear() {
315
+ for (const entry of this.entries.values()) entry.frame.free();
316
+ this.entries.clear();
317
+ this.bytes = 0;
318
+ }
319
+ /** Free + remove one entry, keeping the byte tally exact. */
320
+ drop(frameId) {
321
+ const entry = this.entries.get(frameId);
322
+ if (!entry) return;
323
+ this.entries.delete(frameId);
324
+ this.bytes -= entry.frame.byteLength;
325
+ entry.frame.free();
326
+ }
327
+ };
328
+ //#endregion
108
329
  //#region src/session-decode/decode-worker-child.ts
109
330
  /**
110
331
  * Decode-worker CHILD entry point — Epic C P1, Task 2.
@@ -130,11 +351,59 @@ var FrameSlot = class {
130
351
  * NOT `$process`)". Raw `process.on('message')` / `process.send` are the
131
352
  * correct, sanctioned mechanism for this specific file only.
132
353
  */
354
+ /** Wrap a software YUV420P frame as a lease entry (byte size ≈ w×h×1.5). */
355
+ function toLeasedNavFrame(frame) {
356
+ return {
357
+ frame,
358
+ byteLength: Math.ceil(frame.width * frame.height * 1.5),
359
+ free: () => frame.free()
360
+ };
361
+ }
133
362
  /** Re-dial backoff on a transient dial error/EOF (mirrors the decoder's PULL_REDIAL_MS). */
134
363
  var REDIAL_MS = 3e3;
135
364
  /** How often the worker emits its `framesDecoded/framesSkipped/deliveredFps` line. */
136
365
  var METRICS_INTERVAL_MS = 1e4;
137
366
  /**
367
+ * Hard cap on the native-frame retention ring (native-res crop feature). Kept
368
+ * DELIBERATELY TINY — its only job is to cover the detection→crop latency
369
+ * (typically 1-4 frames), NOT the full shm ring depth. On the HW path every
370
+ * retained slot pins a GPU/VAAPI surface out of the decoder pool (the historic
371
+ * leak site), so the cap MUST stay small; a miss is free (caller falls back to
372
+ * today's detection-frame crop). Override with `CAMSTACK_SESSION_NATIVE_CROP_RING`
373
+ * (0 disables retention entirely); clamped to [0,4], default 2.
374
+ */
375
+ var NATIVE_RING_CAP = (() => {
376
+ const raw = Number(process.env["CAMSTACK_SESSION_NATIVE_CROP_RING"]);
377
+ if (!Number.isFinite(raw)) return 2;
378
+ return Math.min(4, Math.max(0, Math.floor(raw)));
379
+ })();
380
+ /**
381
+ * Hard RAM budget (bytes) for the native-frame LEASE store — the primary
382
+ * native-crop survival window. Unlike {@link NATIVE_RING_CAP} (GPU surfaces,
383
+ * leak-prone → tiny), a lease is a downloaded RAM copy, so the window is sized
384
+ * by memory, not a 2-frame count, and the late cross-process crop reliably
385
+ * hits. The primary eviction is the {@link NATIVE_LEASE_TTL_MS} TTL — a 64MB
386
+ * budget held only ~5 native 4K frames (~0.2s), which the late crop still
387
+ * outran; the budget is now a HIGH safety ceiling (default 1024MB) so the TTL
388
+ * is the effective cap and the ~40-200ms crop reliably lands within it.
389
+ * `CAMSTACK_SESSION_NATIVE_LEASE_BUDGET_MB` (default 1024); `0` DISABLES the
390
+ * lease and falls back to the {@link NATIVE_RING_CAP} GPU ring (today's path).
391
+ */
392
+ var NATIVE_LEASE_BUDGET_BYTES = (() => {
393
+ const raw = Number(process.env["CAMSTACK_SESSION_NATIVE_LEASE_BUDGET_MB"]);
394
+ return (Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 1024) * 1024 * 1024;
395
+ })();
396
+ /**
397
+ * TTL (ms) after which a native-frame lease is treated as a miss and reclaimed.
398
+ * The backstop that bounds in-flight RAM even if the explicit `releaseNativeLease`
399
+ * is dropped — comfortably longer than the detection inference + crop round-trip
400
+ * (~40-200ms). `CAMSTACK_SESSION_NATIVE_LEASE_TTL_MS` (default 500).
401
+ */
402
+ var NATIVE_LEASE_TTL_MS = (() => {
403
+ const raw = Number(process.env["CAMSTACK_SESSION_NATIVE_LEASE_TTL_MS"]);
404
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 500;
405
+ })();
406
+ /**
138
407
  * The libav GPU scale filter for a hwaccel backend, or `null` when none is
139
408
  * known here — those backends fall back to the software crop+scale path.
140
409
  * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
@@ -272,37 +541,6 @@ function backendToHwDeviceConst(backend, consts) {
272
541
  default: return null;
273
542
  }
274
543
  }
275
- /** Clamp to [min,max] then floor to the nearest even number (YUV420P chroma is 2x2 subsampled). */
276
- function clampEven(value, min, max) {
277
- const bounded = Math.min(Math.max(value, min), max);
278
- return bounded - bounded % 2;
279
- }
280
- /** Resolve `FrameImageOptions.crop` against the decoded frame's dimensions, defaulting to the full frame. */
281
- function resolveCropRegion(frameWidth, frameHeight, crop) {
282
- const left = clampEven(crop?.left ?? 0, 0, Math.max(0, frameWidth - 2));
283
- const top = clampEven(crop?.top ?? 0, 0, Math.max(0, frameHeight - 2));
284
- return {
285
- left,
286
- top,
287
- width: clampEven(crop?.width ?? frameWidth - left, 2, frameWidth - left),
288
- height: clampEven(crop?.height ?? frameHeight - top, 2, frameHeight - top)
289
- };
290
- }
291
- /** Resolve `FrameImageOptions.resize`, defaulting to the (already cropped) source dimensions. */
292
- function resolveResizeTarget(cropWidth, cropHeight, resize) {
293
- if (!resize) return {
294
- width: cropWidth,
295
- height: cropHeight
296
- };
297
- return {
298
- width: Math.max(1, Math.round(resize.width)),
299
- height: Math.max(1, Math.round(resize.height))
300
- };
301
- }
302
- /** Whether `region` covers the entire `srcW x srcH` frame (i.e. no real crop). */
303
- function isFullFrameRegion(region, srcW, srcH) {
304
- return region.left === 0 && region.top === 0 && region.width === srcW && region.height === srcH;
305
- }
306
544
  /**
307
545
  * Build the libav filtergraph description for the GPU crop+scale path.
308
546
  *
@@ -384,6 +622,53 @@ var DecodeWorkerChild = class {
384
622
  */
385
623
  hwFilter = null;
386
624
  hwFilterKey = "";
625
+ /**
626
+ * DEDICATED native-crop GPU filtergraph + software scaler, kept SEPARATE from
627
+ * the detection {@link hwFilter}/{@link scaler} so a native-crop request never
628
+ * evicts (rebuilds) the hot-path detection filter cache — the crop geometry
629
+ * differs from the stable detection geometry, so sharing one cache would
630
+ * thrash it. Same dispose discipline: both are closed on every re-dial
631
+ * ({@link closeInput}) and on teardown. Rebuilt lazily per crop geometry.
632
+ */
633
+ nativeCropHwFilter = null;
634
+ nativeCropHwFilterKey = "";
635
+ nativeCropScaler = null;
636
+ nativeCropScalerKey = "";
637
+ /**
638
+ * Bounded native-frame retention ring — the survival window for a frame's
639
+ * NATIVE surface between detection (on the shipped small frame) and the crop
640
+ * request that follows. Hard-capped at {@link NATIVE_RING_CAP}; single
641
+ * ownership via the {@link FrameSlot} `retainSuperseded` hook (a frame enters
642
+ * the ring exactly when the slot would otherwise free it). Cleared on every
643
+ * re-dial + teardown (see {@link closeInput}).
644
+ */
645
+ nativeRing = new NativeFrameRing(NATIVE_RING_CAP);
646
+ /**
647
+ * PRIMARY native-frame survival window — a bounded RAM copy of each shipped
648
+ * detection frame, keyed by frameId, held until release / TTL / budget
649
+ * eviction. Replaces the tiny GPU {@link nativeRing} as the default (the ring
650
+ * is used only when the lease is disabled via a 0 budget). Because it holds
651
+ * DOWNLOADED (system-memory) frames, retaining a large window has no VAAPI
652
+ * surface-pool starvation risk. Cleared on every re-dial + teardown.
653
+ */
654
+ leaseStore = new NativeLeaseStore({
655
+ budgetBytes: NATIVE_LEASE_BUDGET_BYTES,
656
+ ttlMs: NATIVE_LEASE_TTL_MS
657
+ });
658
+ /**
659
+ * DEDICATED full-frame GPU→system download filtergraph
660
+ * (`scale_<be>=iw:ih,hwdownload,format=nv12,format=yuv420p`) used ONLY to
661
+ * materialize a HW surface into a leasable software frame at NATIVE
662
+ * resolution (no scale). Kept separate from the detection/native-crop filters
663
+ * (different geometry). Same dispose discipline: closed on every re-dial
664
+ * ({@link closeInput}) + teardown, since it references the decoder's per-dial
665
+ * `hw_frames_ctx`.
666
+ */
667
+ nativeLeaseDownloadFilter = null;
668
+ nativeLeaseDownloadFilterKey = "";
669
+ /** Throttled native-crop hit/miss counters, emitted with the throughput line. */
670
+ nativeCropHits = 0;
671
+ nativeCropMisses = 0;
387
672
  /** Scrypted-style throughput counters (plain integers, no per-frame allocation). */
388
673
  framesDecoded = 0;
389
674
  /** Child-side skips only (HW-guard drops + probe/degrade frees); slot drops add {@link FrameSlot.droppedCount}. */
@@ -416,7 +701,7 @@ var DecodeWorkerChild = class {
416
701
  * the reserve-on-pull invariant (`e3c78a9b`) is unit-tested without
417
702
  * node-av. See that file's doc comment for the full semantics.
418
703
  */
419
- frames = new FrameSlot();
704
+ frames = new FrameSlot({ retainSuperseded: (frameId, frame) => this.captureNativeFrame(frameId, frame) });
420
705
  /**
421
706
  * Mirrors whether `this.frames` currently has a pull waiting, so
422
707
  * `teardown` knows to resolve it with `{kind:'ended'}` — `FrameSlot`
@@ -442,6 +727,12 @@ var DecodeWorkerChild = class {
442
727
  case "toBuffer":
443
728
  this.handleToBuffer(message.frameId, message.opts);
444
729
  return;
730
+ case "nativeCrop":
731
+ this.handleNativeCrop(message.requestId, message.frameId, message.bbox, message.maxWidth);
732
+ return;
733
+ case "releaseNativeLease":
734
+ this.leaseStore.release(message.frameId);
735
+ return;
445
736
  case "stop":
446
737
  this.handleStop();
447
738
  return;
@@ -466,6 +757,9 @@ var DecodeWorkerChild = class {
466
757
  this.scaler?.[Symbol.dispose]?.();
467
758
  this.scaler = null;
468
759
  this.scalerKey = "";
760
+ this.nativeCropScaler?.[Symbol.dispose]?.();
761
+ this.nativeCropScaler = null;
762
+ this.nativeCropScalerKey = "";
469
763
  this.hwContext?.[Symbol.dispose]?.();
470
764
  this.hwContext = null;
471
765
  if (this.pendingPull) {
@@ -480,7 +774,7 @@ var DecodeWorkerChild = class {
480
774
  const deliveredDelta = this.framesDelivered - this.lastDeliveredSnapshot;
481
775
  const deliveredFps = Math.round(deliveredDelta / windowMs * 1e3 * 10) / 10;
482
776
  const skipped = this.framesSkipped + this.frames.droppedCount;
483
- this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}}${final ? " (final)" : ""}\n`);
777
+ this.emitStderr(`session-decode metrics {framesDecoded:${this.framesDecoded}, framesSkipped:${skipped}, deliveredFps:${deliveredFps}, nativeCropHits:${this.nativeCropHits}, nativeCropMisses:${this.nativeCropMisses}}${final ? " (final)" : ""}\n`);
484
778
  this.lastMetricsAt = now;
485
779
  this.lastDeliveredSnapshot = this.framesDelivered;
486
780
  }
@@ -530,6 +824,207 @@ var DecodeWorkerChild = class {
530
824
  this.sendError(`decode-worker-child: toBuffer failed — ${errMessage(err)}`, frameId);
531
825
  }
532
826
  }
827
+ /**
828
+ * Best-effort NATIVE-resolution crop of a still-retained frame. Resolves the
829
+ * frame from the reserved slot (the latest, most common case) or the
830
+ * retention ring (an older frame detection has since moved past). A miss
831
+ * (frame aged out) or ANY crop error replies `nativeCropMiss` so the caller
832
+ * falls back to today's detection-frame crop — it NEVER degrades the
833
+ * detection session (unlike {@link hwScaleToBuffer}, which flips the whole
834
+ * session to software on a GPU-filter error).
835
+ */
836
+ handleNativeCrop(requestId, frameId, bbox, maxWidth) {
837
+ const frame = this.leaseStore.get(frameId)?.frame ?? this.resolveRetainedFrame(frameId);
838
+ if (!frame) {
839
+ this.nativeCropMisses++;
840
+ this.send({
841
+ kind: "nativeCropMiss",
842
+ requestId
843
+ });
844
+ return;
845
+ }
846
+ try {
847
+ const resolved = this.resolveNativeCrop(frame, bbox, maxWidth);
848
+ const bytes = frame.isHwFrame() ? this.nativeHwCropToBuffer(frame, resolved) : this.nativeScaleCropToBuffer(frame, resolved);
849
+ this.nativeCropHits++;
850
+ this.send({
851
+ kind: "nativeCropResult",
852
+ requestId,
853
+ bytes,
854
+ width: resolved.target.width,
855
+ height: resolved.target.height
856
+ });
857
+ } catch (err) {
858
+ this.nativeCropMisses++;
859
+ this.emitStderr(`decode-worker-child: native crop failed (requestId ${requestId}) — ${errMessage(err)}\n`);
860
+ this.send({
861
+ kind: "nativeCropMiss",
862
+ requestId
863
+ });
864
+ }
865
+ }
866
+ /** The GPU-ring or reserved-slot frame for `frameId` (lease-disabled / newest paths). */
867
+ resolveRetainedFrame(frameId) {
868
+ return this.nativeRing.get(frameId) ?? this.frames.toBuffer(frameId);
869
+ }
870
+ /**
871
+ * Retain a just-superseded DELIVERED frame for later native crops. Transfers
872
+ * ownership from the {@link FrameSlot} (the caller MUST NOT free when this
873
+ * returns `true`).
874
+ *
875
+ * - Lease ENABLED (default): materialize a SOFTWARE RAM copy and put it in the
876
+ * {@link leaseStore}. A HW surface is downloaded to system memory ONCE here
877
+ * (freeing the GPU surface immediately — no VAAPI-pool pin); a software frame
878
+ * is leased directly. A download failure still consumes ownership (freed
879
+ * here) so the slot never double-frees — the crop simply misses → fallback.
880
+ * - Lease DISABLED (`budget 0`): fall back to today's tiny GPU {@link nativeRing}.
881
+ */
882
+ captureNativeFrame(frameId, frame) {
883
+ if (!this.leaseStore.enabled) return this.nativeRing.retain(frameId, frame);
884
+ if (frame.isHwFrame()) {
885
+ const software = this.downloadToSoftware(frame);
886
+ frame.free();
887
+ if (!software) return true;
888
+ this.leaseStore.put(frameId, toLeasedNavFrame(software));
889
+ return true;
890
+ }
891
+ this.leaseStore.put(frameId, toLeasedNavFrame(frame));
892
+ return true;
893
+ }
894
+ /**
895
+ * Download a HW surface to a NATIVE-resolution software YUV420P frame via the
896
+ * dedicated full-frame download filtergraph. Returns the software frame (owned
897
+ * by the caller) or `null` on any failure (the lease is then simply skipped).
898
+ * `processAllSync` only refs the input surface, so the caller frees it after.
899
+ */
900
+ downloadToSoftware(frame) {
901
+ const nav = this.nav;
902
+ const scaleFilter = this.gpuScaleFilter;
903
+ if (!nav || !scaleFilter || !this.hwContext) return null;
904
+ try {
905
+ const outputs = this.ensureLeaseDownloadFilter(nav, scaleFilter, frame.width, frame.height).processAllSync(frame);
906
+ const first = outputs[0] ?? null;
907
+ for (let i = 1; i < outputs.length; i++) outputs[i]?.free();
908
+ return first;
909
+ } catch (err) {
910
+ this.emitStderr(`decode-worker-child: native lease download failed — ${errMessage(err)}\n`);
911
+ return null;
912
+ }
913
+ }
914
+ /** Build-once / reuse the full-frame GPU→system YUV420P download filtergraph. */
915
+ ensureLeaseDownloadFilter(nav, scaleFilter, srcW, srcH) {
916
+ const key = `${srcW}x${srcH}`;
917
+ const cached = this.nativeLeaseDownloadFilter;
918
+ if (cached && key === this.nativeLeaseDownloadFilterKey) return cached;
919
+ this.nativeLeaseDownloadFilter?.close();
920
+ this.nativeLeaseDownloadFilter = null;
921
+ this.nativeLeaseDownloadFilterKey = "";
922
+ const description = `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`;
923
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
924
+ this.nativeLeaseDownloadFilter = filter;
925
+ this.nativeLeaseDownloadFilterKey = key;
926
+ return filter;
927
+ }
928
+ /** Map a normalized bbox to an even-aligned pixel crop + (optionally capped) target. */
929
+ resolveNativeCrop(frame, bbox, maxWidth) {
930
+ return resolveNativeCropGeometry(frame.width, frame.height, bbox, maxWidth);
931
+ }
932
+ /**
933
+ * Software native crop — the leak-free path: offset the retained YUV420P
934
+ * frame's plane pointers to the crop origin (zero-copy) and scale ONLY the
935
+ * ROI into a tightly-packed rgb buffer via a DEDICATED scaler (never the
936
+ * detection {@link scaler}). Mirrors {@link scaleRoiToBuffer}, rgb-fixed.
937
+ */
938
+ nativeScaleCropToBuffer(frame, resolved) {
939
+ const nav = this.nav;
940
+ const C = this.consts;
941
+ if (!nav || !C) throw new Error("node-av not initialized");
942
+ const { region, target } = resolved;
943
+ const planes = frame.data;
944
+ const strides = frame.linesize;
945
+ const yPlane = planes?.[0];
946
+ const uPlane = planes?.[1];
947
+ const vPlane = planes?.[2];
948
+ if (!yPlane || !uPlane || !vPlane) throw new Error("decode-worker-child: retained frame missing planar YUV420P data");
949
+ const yStride = strides[0] ?? 0;
950
+ const cStride = strides[1] ?? 0;
951
+ const ySlice = yPlane.subarray(region.top * yStride + region.left);
952
+ const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
953
+ const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
954
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height);
955
+ const dstStride = target.width * 3;
956
+ const dstBuffer = Buffer.allocUnsafe(dstStride * target.height);
957
+ const scaledHeight = scaler.scaleSync([
958
+ ySlice,
959
+ uSlice,
960
+ vSlice
961
+ ], [
962
+ yStride,
963
+ cStride,
964
+ cStride
965
+ ], 0, region.height, [dstBuffer], [dstStride]);
966
+ if (scaledHeight < 0) throw new Error(`native crop sws_scale failed: ${scaledHeight}`);
967
+ if (scaledHeight !== target.height) throw new Error(`native crop scaler produced ${scaledHeight} of ${target.height} rows`);
968
+ return dstBuffer;
969
+ }
970
+ /**
971
+ * Hardware native crop — run the retained GPU surface through a DEDICATED
972
+ * crop filtergraph (`hwdownload,format=nv12,crop=…,scale=…,format=rgb24`) and
973
+ * pack the small result. Mirrors {@link hwScaleToBuffer} but uses its OWN
974
+ * cached filter and, on error, throws WITHOUT degrading the session.
975
+ */
976
+ nativeHwCropToBuffer(frame, resolved) {
977
+ const { region, target } = resolved;
978
+ const outputs = this.ensureNativeCropHwFilter(region, target).processAllSync(frame);
979
+ const first = outputs[0];
980
+ if (!first) {
981
+ for (const out of outputs) out.free();
982
+ throw new Error("native crop GPU filtergraph produced no frame");
983
+ }
984
+ try {
985
+ const plane = first.data?.[0];
986
+ if (!plane) throw new Error("native crop GPU frame missing packed plane data");
987
+ return packSinglePlane(plane, first.linesize[0] ?? 0, target.width, target.height, 3);
988
+ } finally {
989
+ for (const out of outputs) out.free();
990
+ }
991
+ }
992
+ /** Build-once / reuse the dedicated native-crop software scaler (rgb out). */
993
+ ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH) {
994
+ const key = `${srcW}x${srcH}->${dstW}x${dstH}`;
995
+ const cached = this.nativeCropScaler;
996
+ if (cached && key === this.nativeCropScalerKey) return cached;
997
+ this.nativeCropScaler?.[Symbol.dispose]?.();
998
+ this.nativeCropScaler = null;
999
+ this.nativeCropScalerKey = "";
1000
+ const scaler = new nav.SoftwareScaleContext();
1001
+ scaler.getContext(srcW, srcH, C.AV_PIX_FMT_YUV420P, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
1002
+ const initRet = scaler.initContext();
1003
+ if (initRet < 0) {
1004
+ scaler[Symbol.dispose]?.();
1005
+ throw new Error(`native crop sws_init_context failed: ${initRet}`);
1006
+ }
1007
+ this.nativeCropScaler = scaler;
1008
+ this.nativeCropScalerKey = key;
1009
+ return scaler;
1010
+ }
1011
+ /** Build-once / reuse the dedicated native-crop GPU filtergraph (rgb out). */
1012
+ ensureNativeCropHwFilter(region, target) {
1013
+ const nav = this.nav;
1014
+ const scaleFilter = this.gpuScaleFilter;
1015
+ if (!nav || !scaleFilter || !this.hwContext) throw new Error("decode-worker-child: native GPU crop requested without a HW context");
1016
+ const key = `${region.left},${region.top},${region.width},${region.height}->${target.width}x${target.height}`;
1017
+ const cached = this.nativeCropHwFilter;
1018
+ if (cached && key === this.nativeCropHwFilterKey) return cached;
1019
+ this.nativeCropHwFilter?.close();
1020
+ this.nativeCropHwFilter = null;
1021
+ this.nativeCropHwFilterKey = "";
1022
+ const description = buildGpuFilterDescription(scaleFilter, region, target, false, "rgb24");
1023
+ const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
1024
+ this.nativeCropHwFilter = filter;
1025
+ this.nativeCropHwFilterKey = key;
1026
+ return filter;
1027
+ }
533
1028
  handleStop() {
534
1029
  this.teardown();
535
1030
  process.exit(0);
@@ -883,6 +1378,14 @@ var DecodeWorkerChild = class {
883
1378
  * the next dial's first `toBuffer`.
884
1379
  */
885
1380
  closeInput() {
1381
+ this.nativeRing.clear();
1382
+ this.leaseStore.clear();
1383
+ this.nativeLeaseDownloadFilter?.close();
1384
+ this.nativeLeaseDownloadFilter = null;
1385
+ this.nativeLeaseDownloadFilterKey = "";
1386
+ this.nativeCropHwFilter?.close();
1387
+ this.nativeCropHwFilter = null;
1388
+ this.nativeCropHwFilterKey = "";
886
1389
  this.hwFilter?.close();
887
1390
  this.hwFilter = null;
888
1391
  this.hwFilterKey = "";