@camstack/addon-pipeline 1.1.70 → 1.2.0

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 (34) 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 +181 -469
  4. package/dist/detection-pipeline/index.mjs +182 -469
  5. package/dist/{dist-CurVt3xC.mjs → dist-DLWXeXC1.mjs} +239 -60
  6. package/dist/{dist-BWn5k0A5.js → dist-DWy-H6bo.js} +250 -59
  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 +24 -2
  10. package/dist/pipeline-runner/index.mjs +24 -2
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/session-decode/decode-worker-child.js +177 -12
  14. package/dist/session-decode/decode-worker-child.mjs +177 -12
  15. package/dist/{step-definitions-B7e8rPe3.js → step-definitions-BQG_6dmZ.js} +6 -2
  16. package/dist/{step-definitions-BKm17nce.mjs → step-definitions-CVk19Y0u.mjs} +6 -2
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-yAAGMET5.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CpB1jG0N.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-BflyNuZv.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-5Zc9wj-A.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-DUzRsTmt.mjs → hostInit--Zair8Av.mjs} +3 -3
  22. package/dist/stream-broker/index.js +1 -1
  23. package/dist/stream-broker/index.mjs +1 -1
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-D6bWMuew.js → MaskShapeCanvas-DI4BY7W2-sOOxiGvP.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-C-1enV21.js → MotionZonesSettings-NcxxQN8r-DdTQuT23.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-By9pdGP7.js → PrivacyMaskSettings-APgPLF7p-BrR0L3ql.js} +1 -1
  28. package/embed-dist/assets/index-D4MGuECB.css +2 -0
  29. package/embed-dist/assets/{index-BPGh79kn.js → index-Dk21l1_3.js} +14 -14
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D68Lj69v.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CDBALHSV.mjs +0 -26
  34. package/embed-dist/assets/index-D2pF9Z3W.css +0 -2
@@ -517,6 +517,87 @@ var NativeLeaseStore = class {
517
517
  }
518
518
  };
519
519
  //#endregion
520
+ //#region src/session-decode/pinned-rgb-crop.ts
521
+ /**
522
+ * Pure decision/plumbing helpers for the `pinnedRgbCrop` native-crop color fix
523
+ * (DEFAULT OFF — see `CAMSTACK_SESSION_PINNED_RGB_CROP` in `decode-worker-child.ts`).
524
+ *
525
+ * ## The bug this fixes
526
+ *
527
+ * On a HARDWARE decode session the FULL-frame download pins its YUV→RGB
528
+ * conversion to the resolved `(matrix, range)` on the GPU filtergraph
529
+ * (`buildGpuFilterDescription` → `colorConvertFilterOptions`). The native-CROP
530
+ * survival path, however, retains the frame as **YUV420P** and later converts
531
+ * it to RGB with a CPU `SoftwareScaleContext` that node-av exposes NO
532
+ * colorspace-pinning surface for — so it falls back to libswscale's own default
533
+ * coefficients. When the stream carries NO color-matrix metadata
534
+ * (`colorSpace = UNSPECIFIED`, e.g. cam 617), the full frame (pinned bt709/
535
+ * limited via {@link resolveSourceColor}'s defaults) and the crop (CPU-sws
536
+ * default) can resolve DIFFERENTLY → the crop gets a wrong hue / desaturation
537
+ * while the full frame looks right.
538
+ *
539
+ * ## The fix (retain-once-in-pinned-RGB)
540
+ *
541
+ * When the flag is on, the HW→system lease download converts to RGB24 ONCE,
542
+ * pinned to the SAME resolved `(matrix, range)` the full-frame path uses. The
543
+ * later crop is then a pure SPATIAL RGB24→RGB24 crop + resize with NO color
544
+ * math, so it cannot re-resolve color differently than the full frame.
545
+ *
546
+ * These helpers are the PURE decision surface (filter description, cache keys,
547
+ * RAM-budget size) so the plumbing is unit-testable without node-av / ffmpeg.
548
+ * The software-DEGRADE path stays YUV420P (its full frame + crop both use the
549
+ * CPU-sws default, so there is no mismatch to fix there).
550
+ */
551
+ /**
552
+ * The full-frame GPU→system download filtergraph description.
553
+ *
554
+ * - `color === null` (flag OFF / legacy): download to YUV420P, NO color convert
555
+ * — byte-for-byte the historical `scale=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`.
556
+ * - `color !== null` (flag ON): pin the SAME resolved src matrix/range + full-
557
+ * range RGB output the full-frame path pins, converting to rgb24 in one pass.
558
+ */
559
+ function buildLeaseDownloadDescription(scaleFilter, color) {
560
+ if (color) return `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,scale=${colorConvertFilterOptions(color)},format=rgb24`;
561
+ return `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`;
562
+ }
563
+ /**
564
+ * Cache key for the lease-download filtergraph. Flag OFF returns the historical
565
+ * `${srcW}x${srcH}` key byte-for-byte; flag ON folds the resolved color in so a
566
+ * mid-session colorspace/range change rebuilds the pinned-convert graph instead
567
+ * of reusing one pinned to the old color.
568
+ */
569
+ function leaseDownloadFilterKey(srcW, srcH, color) {
570
+ return color ? `${srcW}x${srcH}:rgb:${sourceColorKey(color)}` : `${srcW}x${srcH}`;
571
+ }
572
+ /**
573
+ * Cache key for the dedicated native-crop software scaler. YUV420P (flag OFF /
574
+ * degrade) returns the historical `${srcW}x${srcH}->${dstW}x${dstH}` key
575
+ * byte-for-byte; the RGB24 (flag ON) scaler carries an `rgb:` prefix so an RGB
576
+ * scaler and a YUV scaler of identical geometry never alias the same cache slot.
577
+ */
578
+ function nativeCropScalerKey(srcW, srcH, dstW, dstH, rgb24) {
579
+ const base = `${srcW}x${srcH}->${dstW}x${dstH}`;
580
+ return rgb24 ? `rgb:${base}` : base;
581
+ }
582
+ /**
583
+ * Approximate resident bytes of a retained software frame, for the lease
584
+ * RAM-budget tally. RGB24 packs 3 bytes/px (~2x YUV420P's 1.5) — the documented
585
+ * cost of pinned-RGB retention.
586
+ */
587
+ function retainedFrameBytes(width, height, rgb24) {
588
+ return rgb24 ? width * height * 3 : Math.ceil(width * height * 1.5);
589
+ }
590
+ /**
591
+ * Byte offset into a PACKED RGB24 plane for a crop origin — the pure spatial
592
+ * math the `pinnedRgbCrop` crop uses to zero-copy view the ROI. Packed RGB24 is
593
+ * a single plane at 3 bytes/px with NO chroma subsampling, so the offset is a
594
+ * plain `top*stride + left*3` — no half-plane arithmetic and, critically, no
595
+ * color conversion (that already happened, pinned, at download time).
596
+ */
597
+ function rgbCropSourceOffset(top, left, stride) {
598
+ return top * stride + left * 3;
599
+ }
600
+ //#endregion
520
601
  //#region src/session-decode/decode-worker-child.ts
521
602
  /**
522
603
  * Decode-worker CHILD entry point — Epic C P1, Task 2.
@@ -546,7 +627,19 @@ var NativeLeaseStore = class {
546
627
  function toLeasedNavFrame(frame) {
547
628
  return {
548
629
  frame,
549
- byteLength: Math.ceil(frame.width * frame.height * 1.5),
630
+ byteLength: retainedFrameBytes(frame.width, frame.height, false),
631
+ free: () => frame.free()
632
+ };
633
+ }
634
+ /**
635
+ * Wrap a software RGB24 frame as a lease entry (byte size = w×h×3) — the
636
+ * `pinnedRgbCrop` path. Kept separate from {@link toLeasedNavFrame} so the
637
+ * flag-off YUV420P wrap is untouched.
638
+ */
639
+ function toLeasedRgbNavFrame(frame) {
640
+ return {
641
+ frame,
642
+ byteLength: retainedFrameBytes(frame.width, frame.height, true),
550
643
  free: () => frame.free()
551
644
  };
552
645
  }
@@ -611,6 +704,35 @@ var NATIVE_LEASE_ACTIVITY_WINDOW_MS = (() => {
611
704
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 15e3;
612
705
  })();
613
706
  /**
707
+ * `pinnedRgbCrop` — pinned-color RGB24 native-crop retention. DEFAULT **ON**
708
+ * (2026-07-20 rollout); set `CAMSTACK_SESSION_PINNED_RGB_CROP=0` to opt out.
709
+ *
710
+ * ## What it fixes
711
+ * On a HARDWARE session the full-frame download pins its YUV→RGB conversion to
712
+ * the resolved `(matrix, range)` on the GPU filtergraph, but the native-CROP
713
+ * lease path retains the frame as YUV420P and later converts it with a CPU
714
+ * `SoftwareScaleContext` that node-av gives NO colorspace-pinning surface for —
715
+ * so the crop falls back to libswscale's default coefficients. On a stream with
716
+ * NO color-matrix metadata (`colorSpace = UNSPECIFIED`, e.g. cam 617) the full
717
+ * frame (pinned bt709/limited by {@link resolveSourceColor}'s defaults) and the
718
+ * crop (CPU-sws default) can resolve DIFFERENTLY → the crop gets a wrong hue /
719
+ * desaturation while the full frame looks right.
720
+ *
721
+ * ## What it does when ON
722
+ * The HW→system lease download converts to RGB24 ONCE, pinned to the SAME
723
+ * resolved color the full-frame path uses (see {@link buildLeaseDownloadDescription});
724
+ * the later crop is then a pure SPATIAL RGB24→RGB24 crop + resize with no color
725
+ * math (see {@link nativeScaleCropToBuffer}'s RGB branch), so it cannot diverge.
726
+ *
727
+ * ## Cost
728
+ * A retained RGB24 frame is ~2x the RAM of YUV420P (3 vs 1.5 bytes/px — ~6.2 vs
729
+ * ~3.1 MB per 1080p frame). Retention stays DEMAND-GATED by {@link leaseGate},
730
+ * so cameras nothing is cropping retain nothing regardless of this flag. The
731
+ * software-DEGRADE path is left on YUV420P (its full frame + crop both use the
732
+ * CPU-sws default, so there is no mismatch to fix there).
733
+ */
734
+ var PINNED_RGB_CROP = process.env["CAMSTACK_SESSION_PINNED_RGB_CROP"] !== "0";
735
+ /**
614
736
  * The libav GPU scale filter for a hwaccel backend, or `null` when none is
615
737
  * known here — those backends fall back to the software crop+scale path.
616
738
  * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
@@ -1148,7 +1270,7 @@ var DecodeWorkerChild = class {
1148
1270
  const software = this.downloadToSoftware(frame);
1149
1271
  frame.free();
1150
1272
  if (!software) return true;
1151
- this.leaseStore.put(frameId, toLeasedNavFrame(software));
1273
+ this.leaseStore.put(frameId, PINNED_RGB_CROP ? toLeasedRgbNavFrame(software) : toLeasedNavFrame(software));
1152
1274
  return true;
1153
1275
  }
1154
1276
  this.leaseStore.put(frameId, toLeasedNavFrame(frame));
@@ -1165,7 +1287,9 @@ var DecodeWorkerChild = class {
1165
1287
  const scaleFilter = this.gpuScaleFilter;
1166
1288
  if (!nav || !scaleFilter || !this.hwContext) return null;
1167
1289
  try {
1168
- const outputs = this.ensureLeaseDownloadFilter(nav, scaleFilter, frame.width, frame.height).processAllSync(frame);
1290
+ const color = PINNED_RGB_CROP ? this.resolveFrameColor(frame) : null;
1291
+ if (color) this.logColorProvenance("hw-crop", color);
1292
+ const outputs = this.ensureLeaseDownloadFilter(nav, scaleFilter, frame.width, frame.height, color).processAllSync(frame);
1169
1293
  const first = outputs[0] ?? null;
1170
1294
  for (let i = 1; i < outputs.length; i++) outputs[i]?.free();
1171
1295
  return first;
@@ -1179,9 +1303,17 @@ var DecodeWorkerChild = class {
1179
1303
  return null;
1180
1304
  }
1181
1305
  }
1182
- /** Build-once / reuse the full-frame GPU→system YUV420P download filtergraph. */
1183
- ensureLeaseDownloadFilter(nav, scaleFilter, srcW, srcH) {
1184
- const key = `${srcW}x${srcH}`;
1306
+ /**
1307
+ * Build-once / reuse the full-frame GPU→system download filtergraph.
1308
+ *
1309
+ * - `color === null` (flag OFF): YUV420P download, byte-for-byte the historical
1310
+ * `scale=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p` + `${srcW}x${srcH}` key.
1311
+ * - `color !== null` (flag ON): pinned-color RGB24 download; the resolved color
1312
+ * is folded into the cache key so a mid-session colorspace/range change
1313
+ * rebuilds the graph instead of reusing one pinned to the old color.
1314
+ */
1315
+ ensureLeaseDownloadFilter(nav, scaleFilter, srcW, srcH, color) {
1316
+ const key = leaseDownloadFilterKey(srcW, srcH, color);
1185
1317
  const cached = this.nativeLeaseDownloadFilter;
1186
1318
  if (cached && key === this.nativeLeaseDownloadFilterKey) {
1187
1319
  this.leaseDownloadFilterUses++;
@@ -1190,7 +1322,7 @@ var DecodeWorkerChild = class {
1190
1322
  this.nativeLeaseDownloadFilter?.close();
1191
1323
  this.nativeLeaseDownloadFilter = null;
1192
1324
  this.nativeLeaseDownloadFilterKey = "";
1193
- const description = `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`;
1325
+ const description = buildLeaseDownloadDescription(scaleFilter, color);
1194
1326
  const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
1195
1327
  this.nativeLeaseDownloadFilter = filter;
1196
1328
  this.nativeLeaseDownloadFilterKey = key;
@@ -1212,6 +1344,7 @@ var DecodeWorkerChild = class {
1212
1344
  const nav = this.nav;
1213
1345
  const C = this.consts;
1214
1346
  if (!nav || !C) throw new Error("node-av not initialized");
1347
+ if (frame.format === C.AV_PIX_FMT_RGB24) return this.nativeRgbCropToBuffer(nav, C, frame, resolved);
1215
1348
  this.logColorProvenance("sw-crop", this.resolveFrameColor(frame));
1216
1349
  const { region, target } = resolved;
1217
1350
  const planes = frame.data;
@@ -1225,7 +1358,7 @@ var DecodeWorkerChild = class {
1225
1358
  const ySlice = yPlane.subarray(region.top * yStride + region.left);
1226
1359
  const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
1227
1360
  const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
1228
- const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height);
1361
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_YUV420P);
1229
1362
  const tightStride = target.width * 3;
1230
1363
  const dstStride = tightStride + 63 & -64;
1231
1364
  const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
@@ -1244,6 +1377,31 @@ var DecodeWorkerChild = class {
1244
1377
  return packSinglePlane(dstBuffer, dstStride, target.width, target.height, 3);
1245
1378
  }
1246
1379
  /**
1380
+ * `pinnedRgbCrop` software crop — the retained frame is ALREADY pinned
1381
+ * full-range RGB24 (color resolved once at download), so this is a pure
1382
+ * SPATIAL crop + RGB→RGB resize: offset the single packed plane to the crop
1383
+ * origin (zero-copy) and scale ONLY the ROI. NO color math, NO YUV planes, NO
1384
+ * provenance marker (the conversion happened + was logged at download time).
1385
+ * Mirrors {@link nativeScaleCropToBuffer}'s stride-alignment + tail-slack
1386
+ * discipline exactly (same SIMD-overshoot heap-corruption guard).
1387
+ */
1388
+ nativeRgbCropToBuffer(nav, C, frame, resolved) {
1389
+ const { region, target } = resolved;
1390
+ const plane = frame.data?.[0];
1391
+ if (!plane) throw new Error("decode-worker-child: retained RGB frame missing packed plane data");
1392
+ const srcStride = frame.linesize[0] ?? 0;
1393
+ const srcSlice = plane.subarray(rgbCropSourceOffset(region.top, region.left, srcStride));
1394
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_RGB24);
1395
+ const tightStride = target.width * 3;
1396
+ const dstStride = tightStride + 63 & -64;
1397
+ const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
1398
+ const scaledHeight = scaler.scaleSync([srcSlice], [srcStride], 0, region.height, [dstBuffer], [dstStride]);
1399
+ if (scaledHeight < 0) throw new Error(`native rgb crop sws_scale failed: ${scaledHeight}`);
1400
+ if (scaledHeight !== target.height) throw new Error(`native rgb crop scaler produced ${scaledHeight} of ${target.height} rows`);
1401
+ if (dstStride === tightStride) return dstBuffer.subarray(0, tightStride * target.height);
1402
+ return packSinglePlane(dstBuffer, dstStride, target.width, target.height, 3);
1403
+ }
1404
+ /**
1247
1405
  * Hardware native crop — run the retained GPU surface through a DEDICATED
1248
1406
  * crop filtergraph (`hwdownload,format=nv12,crop=…,scale=…,format=rgb24`) and
1249
1407
  * pack the small result. Mirrors {@link hwScaleToBuffer} but uses its OWN
@@ -1267,16 +1425,23 @@ var DecodeWorkerChild = class {
1267
1425
  for (const out of outputs) out.free();
1268
1426
  }
1269
1427
  }
1270
- /** Build-once / reuse the dedicated native-crop software scaler (rgb out). */
1271
- ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH) {
1272
- const key = `${srcW}x${srcH}->${dstW}x${dstH}`;
1428
+ /**
1429
+ * Build-once / reuse the dedicated native-crop software scaler (rgb out).
1430
+ * `srcFmt` parameterizes the SOURCE pixel format so the scaler matches the
1431
+ * retained frame: `AV_PIX_FMT_YUV420P` (flag OFF / degrade — YUV→RGB convert)
1432
+ * or `AV_PIX_FMT_RGB24` (`pinnedRgbCrop` — a pure RGB→RGB spatial resize). The
1433
+ * cache key folds the format in so an RGB and a YUV scaler of identical
1434
+ * geometry never alias one slot (see {@link nativeCropScalerKey}).
1435
+ */
1436
+ ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH, srcFmt) {
1437
+ const key = nativeCropScalerKey(srcW, srcH, dstW, dstH, srcFmt === C.AV_PIX_FMT_RGB24);
1273
1438
  const cached = this.nativeCropScaler;
1274
1439
  if (cached && key === this.nativeCropScalerKey) return cached;
1275
1440
  this.nativeCropScaler?.[Symbol.dispose]?.();
1276
1441
  this.nativeCropScaler = null;
1277
1442
  this.nativeCropScalerKey = "";
1278
1443
  const scaler = new nav.SoftwareScaleContext();
1279
- scaler.getContext(srcW, srcH, C.AV_PIX_FMT_YUV420P, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
1444
+ scaler.getContext(srcW, srcH, srcFmt, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
1280
1445
  const initRet = scaler.initContext();
1281
1446
  if (initRet < 0) {
1282
1447
  scaler[Symbol.dispose]?.();
@@ -513,6 +513,87 @@ var NativeLeaseStore = class {
513
513
  }
514
514
  };
515
515
  //#endregion
516
+ //#region src/session-decode/pinned-rgb-crop.ts
517
+ /**
518
+ * Pure decision/plumbing helpers for the `pinnedRgbCrop` native-crop color fix
519
+ * (DEFAULT OFF — see `CAMSTACK_SESSION_PINNED_RGB_CROP` in `decode-worker-child.ts`).
520
+ *
521
+ * ## The bug this fixes
522
+ *
523
+ * On a HARDWARE decode session the FULL-frame download pins its YUV→RGB
524
+ * conversion to the resolved `(matrix, range)` on the GPU filtergraph
525
+ * (`buildGpuFilterDescription` → `colorConvertFilterOptions`). The native-CROP
526
+ * survival path, however, retains the frame as **YUV420P** and later converts
527
+ * it to RGB with a CPU `SoftwareScaleContext` that node-av exposes NO
528
+ * colorspace-pinning surface for — so it falls back to libswscale's own default
529
+ * coefficients. When the stream carries NO color-matrix metadata
530
+ * (`colorSpace = UNSPECIFIED`, e.g. cam 617), the full frame (pinned bt709/
531
+ * limited via {@link resolveSourceColor}'s defaults) and the crop (CPU-sws
532
+ * default) can resolve DIFFERENTLY → the crop gets a wrong hue / desaturation
533
+ * while the full frame looks right.
534
+ *
535
+ * ## The fix (retain-once-in-pinned-RGB)
536
+ *
537
+ * When the flag is on, the HW→system lease download converts to RGB24 ONCE,
538
+ * pinned to the SAME resolved `(matrix, range)` the full-frame path uses. The
539
+ * later crop is then a pure SPATIAL RGB24→RGB24 crop + resize with NO color
540
+ * math, so it cannot re-resolve color differently than the full frame.
541
+ *
542
+ * These helpers are the PURE decision surface (filter description, cache keys,
543
+ * RAM-budget size) so the plumbing is unit-testable without node-av / ffmpeg.
544
+ * The software-DEGRADE path stays YUV420P (its full frame + crop both use the
545
+ * CPU-sws default, so there is no mismatch to fix there).
546
+ */
547
+ /**
548
+ * The full-frame GPU→system download filtergraph description.
549
+ *
550
+ * - `color === null` (flag OFF / legacy): download to YUV420P, NO color convert
551
+ * — byte-for-byte the historical `scale=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`.
552
+ * - `color !== null` (flag ON): pin the SAME resolved src matrix/range + full-
553
+ * range RGB output the full-frame path pins, converting to rgb24 in one pass.
554
+ */
555
+ function buildLeaseDownloadDescription(scaleFilter, color) {
556
+ if (color) return `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,scale=${colorConvertFilterOptions(color)},format=rgb24`;
557
+ return `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`;
558
+ }
559
+ /**
560
+ * Cache key for the lease-download filtergraph. Flag OFF returns the historical
561
+ * `${srcW}x${srcH}` key byte-for-byte; flag ON folds the resolved color in so a
562
+ * mid-session colorspace/range change rebuilds the pinned-convert graph instead
563
+ * of reusing one pinned to the old color.
564
+ */
565
+ function leaseDownloadFilterKey(srcW, srcH, color) {
566
+ return color ? `${srcW}x${srcH}:rgb:${sourceColorKey(color)}` : `${srcW}x${srcH}`;
567
+ }
568
+ /**
569
+ * Cache key for the dedicated native-crop software scaler. YUV420P (flag OFF /
570
+ * degrade) returns the historical `${srcW}x${srcH}->${dstW}x${dstH}` key
571
+ * byte-for-byte; the RGB24 (flag ON) scaler carries an `rgb:` prefix so an RGB
572
+ * scaler and a YUV scaler of identical geometry never alias the same cache slot.
573
+ */
574
+ function nativeCropScalerKey(srcW, srcH, dstW, dstH, rgb24) {
575
+ const base = `${srcW}x${srcH}->${dstW}x${dstH}`;
576
+ return rgb24 ? `rgb:${base}` : base;
577
+ }
578
+ /**
579
+ * Approximate resident bytes of a retained software frame, for the lease
580
+ * RAM-budget tally. RGB24 packs 3 bytes/px (~2x YUV420P's 1.5) — the documented
581
+ * cost of pinned-RGB retention.
582
+ */
583
+ function retainedFrameBytes(width, height, rgb24) {
584
+ return rgb24 ? width * height * 3 : Math.ceil(width * height * 1.5);
585
+ }
586
+ /**
587
+ * Byte offset into a PACKED RGB24 plane for a crop origin — the pure spatial
588
+ * math the `pinnedRgbCrop` crop uses to zero-copy view the ROI. Packed RGB24 is
589
+ * a single plane at 3 bytes/px with NO chroma subsampling, so the offset is a
590
+ * plain `top*stride + left*3` — no half-plane arithmetic and, critically, no
591
+ * color conversion (that already happened, pinned, at download time).
592
+ */
593
+ function rgbCropSourceOffset(top, left, stride) {
594
+ return top * stride + left * 3;
595
+ }
596
+ //#endregion
516
597
  //#region src/session-decode/decode-worker-child.ts
517
598
  /**
518
599
  * Decode-worker CHILD entry point — Epic C P1, Task 2.
@@ -542,7 +623,19 @@ var NativeLeaseStore = class {
542
623
  function toLeasedNavFrame(frame) {
543
624
  return {
544
625
  frame,
545
- byteLength: Math.ceil(frame.width * frame.height * 1.5),
626
+ byteLength: retainedFrameBytes(frame.width, frame.height, false),
627
+ free: () => frame.free()
628
+ };
629
+ }
630
+ /**
631
+ * Wrap a software RGB24 frame as a lease entry (byte size = w×h×3) — the
632
+ * `pinnedRgbCrop` path. Kept separate from {@link toLeasedNavFrame} so the
633
+ * flag-off YUV420P wrap is untouched.
634
+ */
635
+ function toLeasedRgbNavFrame(frame) {
636
+ return {
637
+ frame,
638
+ byteLength: retainedFrameBytes(frame.width, frame.height, true),
546
639
  free: () => frame.free()
547
640
  };
548
641
  }
@@ -607,6 +700,35 @@ var NATIVE_LEASE_ACTIVITY_WINDOW_MS = (() => {
607
700
  return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 15e3;
608
701
  })();
609
702
  /**
703
+ * `pinnedRgbCrop` — pinned-color RGB24 native-crop retention. DEFAULT **ON**
704
+ * (2026-07-20 rollout); set `CAMSTACK_SESSION_PINNED_RGB_CROP=0` to opt out.
705
+ *
706
+ * ## What it fixes
707
+ * On a HARDWARE session the full-frame download pins its YUV→RGB conversion to
708
+ * the resolved `(matrix, range)` on the GPU filtergraph, but the native-CROP
709
+ * lease path retains the frame as YUV420P and later converts it with a CPU
710
+ * `SoftwareScaleContext` that node-av gives NO colorspace-pinning surface for —
711
+ * so the crop falls back to libswscale's default coefficients. On a stream with
712
+ * NO color-matrix metadata (`colorSpace = UNSPECIFIED`, e.g. cam 617) the full
713
+ * frame (pinned bt709/limited by {@link resolveSourceColor}'s defaults) and the
714
+ * crop (CPU-sws default) can resolve DIFFERENTLY → the crop gets a wrong hue /
715
+ * desaturation while the full frame looks right.
716
+ *
717
+ * ## What it does when ON
718
+ * The HW→system lease download converts to RGB24 ONCE, pinned to the SAME
719
+ * resolved color the full-frame path uses (see {@link buildLeaseDownloadDescription});
720
+ * the later crop is then a pure SPATIAL RGB24→RGB24 crop + resize with no color
721
+ * math (see {@link nativeScaleCropToBuffer}'s RGB branch), so it cannot diverge.
722
+ *
723
+ * ## Cost
724
+ * A retained RGB24 frame is ~2x the RAM of YUV420P (3 vs 1.5 bytes/px — ~6.2 vs
725
+ * ~3.1 MB per 1080p frame). Retention stays DEMAND-GATED by {@link leaseGate},
726
+ * so cameras nothing is cropping retain nothing regardless of this flag. The
727
+ * software-DEGRADE path is left on YUV420P (its full frame + crop both use the
728
+ * CPU-sws default, so there is no mismatch to fix there).
729
+ */
730
+ var PINNED_RGB_CROP = process.env["CAMSTACK_SESSION_PINNED_RGB_CROP"] !== "0";
731
+ /**
610
732
  * The libav GPU scale filter for a hwaccel backend, or `null` when none is
611
733
  * known here — those backends fall back to the software crop+scale path.
612
734
  * Mirrors `addon-decoder-ffmpeg/src/ffmpeg-args.ts` `gpuScaleFilterForBackend`.
@@ -1144,7 +1266,7 @@ var DecodeWorkerChild = class {
1144
1266
  const software = this.downloadToSoftware(frame);
1145
1267
  frame.free();
1146
1268
  if (!software) return true;
1147
- this.leaseStore.put(frameId, toLeasedNavFrame(software));
1269
+ this.leaseStore.put(frameId, PINNED_RGB_CROP ? toLeasedRgbNavFrame(software) : toLeasedNavFrame(software));
1148
1270
  return true;
1149
1271
  }
1150
1272
  this.leaseStore.put(frameId, toLeasedNavFrame(frame));
@@ -1161,7 +1283,9 @@ var DecodeWorkerChild = class {
1161
1283
  const scaleFilter = this.gpuScaleFilter;
1162
1284
  if (!nav || !scaleFilter || !this.hwContext) return null;
1163
1285
  try {
1164
- const outputs = this.ensureLeaseDownloadFilter(nav, scaleFilter, frame.width, frame.height).processAllSync(frame);
1286
+ const color = PINNED_RGB_CROP ? this.resolveFrameColor(frame) : null;
1287
+ if (color) this.logColorProvenance("hw-crop", color);
1288
+ const outputs = this.ensureLeaseDownloadFilter(nav, scaleFilter, frame.width, frame.height, color).processAllSync(frame);
1165
1289
  const first = outputs[0] ?? null;
1166
1290
  for (let i = 1; i < outputs.length; i++) outputs[i]?.free();
1167
1291
  return first;
@@ -1175,9 +1299,17 @@ var DecodeWorkerChild = class {
1175
1299
  return null;
1176
1300
  }
1177
1301
  }
1178
- /** Build-once / reuse the full-frame GPU→system YUV420P download filtergraph. */
1179
- ensureLeaseDownloadFilter(nav, scaleFilter, srcW, srcH) {
1180
- const key = `${srcW}x${srcH}`;
1302
+ /**
1303
+ * Build-once / reuse the full-frame GPU→system download filtergraph.
1304
+ *
1305
+ * - `color === null` (flag OFF): YUV420P download, byte-for-byte the historical
1306
+ * `scale=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p` + `${srcW}x${srcH}` key.
1307
+ * - `color !== null` (flag ON): pinned-color RGB24 download; the resolved color
1308
+ * is folded into the cache key so a mid-session colorspace/range change
1309
+ * rebuilds the graph instead of reusing one pinned to the old color.
1310
+ */
1311
+ ensureLeaseDownloadFilter(nav, scaleFilter, srcW, srcH, color) {
1312
+ const key = leaseDownloadFilterKey(srcW, srcH, color);
1181
1313
  const cached = this.nativeLeaseDownloadFilter;
1182
1314
  if (cached && key === this.nativeLeaseDownloadFilterKey) {
1183
1315
  this.leaseDownloadFilterUses++;
@@ -1186,7 +1318,7 @@ var DecodeWorkerChild = class {
1186
1318
  this.nativeLeaseDownloadFilter?.close();
1187
1319
  this.nativeLeaseDownloadFilter = null;
1188
1320
  this.nativeLeaseDownloadFilterKey = "";
1189
- const description = `${scaleFilter}=w=iw:h=ih,hwdownload,format=nv12,format=yuv420p`;
1321
+ const description = buildLeaseDownloadDescription(scaleFilter, color);
1190
1322
  const filter = nav.FilterAPI.create(description, { hardware: this.hwContext });
1191
1323
  this.nativeLeaseDownloadFilter = filter;
1192
1324
  this.nativeLeaseDownloadFilterKey = key;
@@ -1208,6 +1340,7 @@ var DecodeWorkerChild = class {
1208
1340
  const nav = this.nav;
1209
1341
  const C = this.consts;
1210
1342
  if (!nav || !C) throw new Error("node-av not initialized");
1343
+ if (frame.format === C.AV_PIX_FMT_RGB24) return this.nativeRgbCropToBuffer(nav, C, frame, resolved);
1211
1344
  this.logColorProvenance("sw-crop", this.resolveFrameColor(frame));
1212
1345
  const { region, target } = resolved;
1213
1346
  const planes = frame.data;
@@ -1221,7 +1354,7 @@ var DecodeWorkerChild = class {
1221
1354
  const ySlice = yPlane.subarray(region.top * yStride + region.left);
1222
1355
  const uSlice = uPlane.subarray(region.top / 2 * cStride + region.left / 2);
1223
1356
  const vSlice = vPlane.subarray(region.top / 2 * cStride + region.left / 2);
1224
- const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height);
1357
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_YUV420P);
1225
1358
  const tightStride = target.width * 3;
1226
1359
  const dstStride = tightStride + 63 & -64;
1227
1360
  const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
@@ -1240,6 +1373,31 @@ var DecodeWorkerChild = class {
1240
1373
  return packSinglePlane(dstBuffer, dstStride, target.width, target.height, 3);
1241
1374
  }
1242
1375
  /**
1376
+ * `pinnedRgbCrop` software crop — the retained frame is ALREADY pinned
1377
+ * full-range RGB24 (color resolved once at download), so this is a pure
1378
+ * SPATIAL crop + RGB→RGB resize: offset the single packed plane to the crop
1379
+ * origin (zero-copy) and scale ONLY the ROI. NO color math, NO YUV planes, NO
1380
+ * provenance marker (the conversion happened + was logged at download time).
1381
+ * Mirrors {@link nativeScaleCropToBuffer}'s stride-alignment + tail-slack
1382
+ * discipline exactly (same SIMD-overshoot heap-corruption guard).
1383
+ */
1384
+ nativeRgbCropToBuffer(nav, C, frame, resolved) {
1385
+ const { region, target } = resolved;
1386
+ const plane = frame.data?.[0];
1387
+ if (!plane) throw new Error("decode-worker-child: retained RGB frame missing packed plane data");
1388
+ const srcStride = frame.linesize[0] ?? 0;
1389
+ const srcSlice = plane.subarray(rgbCropSourceOffset(region.top, region.left, srcStride));
1390
+ const scaler = this.ensureNativeCropScaler(nav, C, region.width, region.height, target.width, target.height, C.AV_PIX_FMT_RGB24);
1391
+ const tightStride = target.width * 3;
1392
+ const dstStride = tightStride + 63 & -64;
1393
+ const dstBuffer = Buffer.allocUnsafe(dstStride * target.height + 64);
1394
+ const scaledHeight = scaler.scaleSync([srcSlice], [srcStride], 0, region.height, [dstBuffer], [dstStride]);
1395
+ if (scaledHeight < 0) throw new Error(`native rgb crop sws_scale failed: ${scaledHeight}`);
1396
+ if (scaledHeight !== target.height) throw new Error(`native rgb crop scaler produced ${scaledHeight} of ${target.height} rows`);
1397
+ if (dstStride === tightStride) return dstBuffer.subarray(0, tightStride * target.height);
1398
+ return packSinglePlane(dstBuffer, dstStride, target.width, target.height, 3);
1399
+ }
1400
+ /**
1243
1401
  * Hardware native crop — run the retained GPU surface through a DEDICATED
1244
1402
  * crop filtergraph (`hwdownload,format=nv12,crop=…,scale=…,format=rgb24`) and
1245
1403
  * pack the small result. Mirrors {@link hwScaleToBuffer} but uses its OWN
@@ -1263,16 +1421,23 @@ var DecodeWorkerChild = class {
1263
1421
  for (const out of outputs) out.free();
1264
1422
  }
1265
1423
  }
1266
- /** Build-once / reuse the dedicated native-crop software scaler (rgb out). */
1267
- ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH) {
1268
- const key = `${srcW}x${srcH}->${dstW}x${dstH}`;
1424
+ /**
1425
+ * Build-once / reuse the dedicated native-crop software scaler (rgb out).
1426
+ * `srcFmt` parameterizes the SOURCE pixel format so the scaler matches the
1427
+ * retained frame: `AV_PIX_FMT_YUV420P` (flag OFF / degrade — YUV→RGB convert)
1428
+ * or `AV_PIX_FMT_RGB24` (`pinnedRgbCrop` — a pure RGB→RGB spatial resize). The
1429
+ * cache key folds the format in so an RGB and a YUV scaler of identical
1430
+ * geometry never alias one slot (see {@link nativeCropScalerKey}).
1431
+ */
1432
+ ensureNativeCropScaler(nav, C, srcW, srcH, dstW, dstH, srcFmt) {
1433
+ const key = nativeCropScalerKey(srcW, srcH, dstW, dstH, srcFmt === C.AV_PIX_FMT_RGB24);
1269
1434
  const cached = this.nativeCropScaler;
1270
1435
  if (cached && key === this.nativeCropScalerKey) return cached;
1271
1436
  this.nativeCropScaler?.[Symbol.dispose]?.();
1272
1437
  this.nativeCropScaler = null;
1273
1438
  this.nativeCropScalerKey = "";
1274
1439
  const scaler = new nav.SoftwareScaleContext();
1275
- scaler.getContext(srcW, srcH, C.AV_PIX_FMT_YUV420P, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
1440
+ scaler.getContext(srcW, srcH, srcFmt, dstW, dstH, C.AV_PIX_FMT_RGB24, C.SWS_FAST_BILINEAR);
1276
1441
  const initRet = scaler.initContext();
1277
1442
  if (initRet < 0) {
1278
1443
  scaler[Symbol.dispose]?.();
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-BWn5k0A5.js");
1
+ const require_dist = require("./dist-DWy-H6bo.js");
2
2
  //#region src/detection-pipeline/registry/model-catalogs.ts
3
3
  var HF_REPO = "camstack/camstack-models";
4
4
  var HF_SCRYPTED = "scrypted/plugin-models";
@@ -1273,7 +1273,11 @@ var ObjectDetectionStep = class {
1273
1273
  max: 1,
1274
1274
  step: .05,
1275
1275
  default: .5,
1276
- showValue: true
1276
+ showValue: true,
1277
+ showWhen: {
1278
+ field: "enabledMacroClasses",
1279
+ includes: "package"
1280
+ }
1277
1281
  },
1278
1282
  {
1279
1283
  type: "slider",
@@ -1,4 +1,4 @@
1
- import { a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS, w as hfModelUrl } from "./dist-CurVt3xC.mjs";
1
+ import { E as hfModelUrl, a as COCO_TO_MACRO, i as COCO_80_LABELS, r as AUDIO_MACRO_LABELS } from "./dist-DLWXeXC1.mjs";
2
2
  //#region src/detection-pipeline/registry/model-catalogs.ts
3
3
  var HF_REPO = "camstack/camstack-models";
4
4
  var HF_SCRYPTED = "scrypted/plugin-models";
@@ -1273,7 +1273,11 @@ var ObjectDetectionStep = class {
1273
1273
  max: 1,
1274
1274
  step: .05,
1275
1275
  default: .5,
1276
- showValue: true
1276
+ showValue: true,
1277
+ showWhen: {
1278
+ field: "enabledMacroClasses",
1279
+ includes: "package"
1280
+ }
1277
1281
  },
1278
1282
  {
1279
1283
  type: "slider",
@@ -1,8 +1,8 @@
1
- import { a as e, c as t, d as n, i as r, l as i, n as a, o, r as s, s as c, t as l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CDBALHSV.mjs";
1
+ import { a as e, c as t, d as n, i as r, l as i, n as a, o, r as s, s as c, t as l, u } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-5Zc9wj-A.mjs";
2
2
  import { a as d, i as f, n as p, o as m, r as h, t as g } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as _, r as v, t as y } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
4
  import { n as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
5
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D68Lj69v.mjs";
5
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BflyNuZv.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
7
7
  var C = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), w = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), T = (e) => {
8
8
  let t = w(e);
@@ -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.30",
6
+ version: "1.2.0",
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.57",
21
+ version: "1.2.0",
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.45",
36
+ version: "1.2.0",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",