@camstack/addon-pipeline 1.2.214 → 1.2.217

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 (39) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-C2vYSX3O.mjs → default-detection-model-BwNEV8Wl.mjs} +1 -1
  4. package/dist/{default-detection-model-f0PImZnh.js → default-detection-model-DzzuINe-.js} +1 -1
  5. package/dist/detection-pipeline/index.js +6 -7
  6. package/dist/detection-pipeline/index.mjs +5 -6
  7. package/dist/{dist-DoHcimdu.js → dist-D7hxiBnC.js} +237 -16
  8. package/dist/{dist-DttqKvsQ.mjs → dist-DgeXZfwn.mjs} +237 -16
  9. package/dist/{lazy-sharp-BeeQGCUu.js → lazy-sharp-BSkbiuz5.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-NtM7__8q.mjs → node-C9GcBrwk.mjs} +1 -1
  13. package/dist/{node-6uF72wri.js → node-DDhmPcOx.js} +1 -1
  14. package/dist/pipeline-runner/index.js +541 -26
  15. package/dist/pipeline-runner/index.mjs +539 -24
  16. package/dist/{process-memory-CddpCDWL.mjs → process-memory-Dw3CGS3D.mjs} +1 -1
  17. package/dist/{process-memory-BRdyl5fE.js → process-memory-ENqmiiis.js} +1 -1
  18. package/dist/recorder/index.js +158 -47
  19. package/dist/recorder/index.mjs +158 -47
  20. package/dist/{segment-demux-js-CsFtPVzH.mjs → segment-demux-js-BGbKlotF.mjs} +1 -1
  21. package/dist/{segment-demux-js-CkJPdyDH.js → segment-demux-js-sLs8mbZQ.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +61 -180
  23. package/dist/session-decode/decode-worker-child.mjs +60 -179
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbBa4iV-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BULotDCb.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-yJ2CyixO.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Uxkwkgy5.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-CagSUJH7.mjs → hostInit-_RQxF72h.mjs} +3 -3
  31. package/dist/stream-broker/index.js +20 -21
  32. package/dist/stream-broker/index.mjs +20 -21
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-BWO2WNjw.js → worker-protocol-DGql9R4g.js} +1 -1
  35. package/dist/{worker-protocol-BKwkLZWT.mjs → worker-protocol-SzmaLNnP.mjs} +1 -1
  36. package/package.json +1 -1
  37. package/dist/event-loop-stall-monitor-DXLNBMkY.mjs +0 -138
  38. package/dist/event-loop-stall-monitor-DaUBcb13.js +0 -149
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-0MF-KL1v.mjs +0 -26
@@ -2,14 +2,13 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DoHcimdu.js");
6
- const require_node = require("../node-6uF72wri.js");
7
- const require_default_detection_model = require("../default-detection-model-f0PImZnh.js");
8
- const require_worker_protocol = require("../worker-protocol-BWO2WNjw.js");
5
+ const require_dist = require("../dist-D7hxiBnC.js");
6
+ const require_node = require("../node-DDhmPcOx.js");
7
+ const require_default_detection_model = require("../default-detection-model-DzzuINe-.js");
8
+ const require_worker_protocol = require("../worker-protocol-DGql9R4g.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
- const require_lazy_sharp = require("../lazy-sharp-BeeQGCUu.js");
10
+ const require_lazy_sharp = require("../lazy-sharp-BSkbiuz5.js");
11
11
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
12
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
13
12
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
14
13
  let node_os = require("node:os");
15
14
  node_os = require_dist.__toESM(node_os);
@@ -18,6 +17,7 @@ let sharp = require("sharp");
18
17
  sharp = require_dist.__toESM(sharp);
19
18
  let node_url = require("node:url");
20
19
  let node_perf_hooks = require("node:perf_hooks");
20
+ let _camstack_system_addon_utils = require("@camstack/system/addon-utils");
21
21
  //#region src/session-decode/fork-decode-worker.ts
22
22
  /** Absolute path to the built decode-worker child entry (a vite build entry). */
23
23
  var CHILD_ENTRY_PATH = (0, node_url.fileURLToPath)(new URL("../session-decode/decode-worker-child.js", require("url").pathToFileURL(__filename).href));
@@ -386,6 +386,84 @@ function positiveOrUndefined(value) {
386
386
  return Number.isFinite(value) && value > 0 ? value : void 0;
387
387
  }
388
388
  //#endregion
389
+ //#region src/session-decode/native-crop-lease-age.ts
390
+ /**
391
+ * THE age bound on a native crop lease (ADR-0419).
392
+ *
393
+ * ## The measurement
394
+ *
395
+ * Mac agent node (`MinidiGianluca.localdomain`), 2026-09-09, a 54-minute
396
+ * window: 332 `native crop miss` lines, every one of them `worker-lease-gone`,
397
+ * at a MEDIAN `handleAgeMs` of 30 099 ms — thirty seconds. The node-wide
398
+ * {@link ../session-decode/native-crop-registry.NativeCropRegistry} sat pinned
399
+ * at `registrySize 4096 / registryCap 4096` for the whole window. Measured
400
+ * consequence: on track `d64ace88` (device 1443) the frontal-face frames never
401
+ * reached the face detector, because every native fetch missed.
402
+ *
403
+ * ## Why an AGE bound and not a bigger cap
404
+ *
405
+ * The pinned cap is what the miss line shows, so raising it is the instinctive
406
+ * move, and it is wrong twice. It keeps more full-resolution surfaces alive
407
+ * (real RAM, on a ring that has leaked a VAAPI surface pool before), and it
408
+ * only moves the threshold at which the same pinning returns. It also cannot
409
+ * work: the decode worker's own tile store expires a frame's tiles at
410
+ * `NATIVE_TILE_TTL_MS` (30 s), so a handle 30 s old has nothing behind it at
411
+ * ANY registry size. The operator's decision, 2026-09-09: **a native lease
412
+ * lives at most ten seconds**; a crop asked for later than that cannot be
413
+ * useful, and the caller's detection-frame fallback — which
414
+ * `native-frame-ring.ts` calls FREE and expected — is the right answer.
415
+ *
416
+ * ## Why the bound is enforced at the registry
417
+ *
418
+ * The registry is the only place in the system where a handle's registration
419
+ * wall-clock exists: `FrameHandle` carries no correlatable clock (`pts` is
420
+ * explicitly process-local and must never be compared against another), so
421
+ * neither the hub-side requester nor the decode worker can decide this
422
+ * question correctly. It is also the single node-wide chokepoint every native
423
+ * path funnels through — the `getNativeCrop` cap, `getNativeCropForDetail`,
424
+ * and `runDetailSubtree`'s rung 1 — so ONE rule covers all of them, and it
425
+ * sits in-process, in FRONT of the round trip that the 332 misses paid for.
426
+ * Refusing here makes the wasted work impossible rather than merely faster.
427
+ *
428
+ * Deliberately a constant and not an operator knob: it is a property of the
429
+ * pipeline, not of a node's hardware (same argument as the lease knobs in
430
+ * `@camstack/types` `pipeline/native-lease.ts`), and a per-node value would
431
+ * make the same camera produce different crop quality depending on where the
432
+ * balancer placed it.
433
+ */
434
+ /**
435
+ * How long a registered handle may be asked for a native crop. Inclusive: an
436
+ * ask at exactly this age is still served, one millisecond later is refused.
437
+ *
438
+ * ## Ten seconds was tried on the fleet, and the fleet refused it
439
+ *
440
+ * The operator's number was ten seconds, chosen on the belief — stated in this
441
+ * file's first version, and sourced from `@camstack/types`
442
+ * `pipeline/native-lease.ts` sizing prose rather than from a measurement of
443
+ * THIS path — that a p90 ask lands at 4.3 s and a p50 at 367 ms. Deployed
444
+ * 2026-09-09 ~14:00, it produced, on the hub, **3 371 `no-registry-entry`
445
+ * misses in fifteen minutes** (2 036 of them on `salone` alone) against
446
+ * essentially ZERO in the half hour before the deploy: the registry held 46
447
+ * entries where it had held 4 096, and the asks arriving between 10 s and 30 s
448
+ * found nothing at all. The operator saw it as tracks with missing media.
449
+ *
450
+ * So the bound is the worker's own tile TTL (`NATIVE_TILE_TTL_MS`, 30 s,
451
+ * `decode-worker-child.ts`) — the age past which there is provably nothing
452
+ * behind the handle at ANY registry size. It still bounds the lease by AGE
453
+ * rather than by a 4 096-entry capacity eviction, which is what D419 set out
454
+ * to fix; it simply stops refusing asks the worker could still have answered.
455
+ *
456
+ * The real defect is caller-side and is not fixed here: a ~1 Hz
457
+ * `TrackDetailDispatcher` re-asks a frozen handle for the life of a coasted
458
+ * track, and `persistKeyFrames` / `offerGroupShot` are the only capture sites
459
+ * that omit `staleAfterMs`. ADR-0419 named both as open; until they are fixed
460
+ * a tighter bound cannot be told apart from a broken one.
461
+ */
462
+ var NATIVE_CROP_LEASE_MAX_AGE_MS = 3e4;
463
+ function classifyNativeCropLeaseAge(ageMs, maxAgeMs = NATIVE_CROP_LEASE_MAX_AGE_MS) {
464
+ return ageMs > maxAgeMs ? "expired" : "live";
465
+ }
466
+ //#endregion
389
467
  //#region src/session-decode/native-crop-registry.ts
390
468
  var DEFAULT_NATIVE_CROP_REGISTRY_CAP = (() => {
391
469
  const raw = Number(process.env["CAMSTACK_NATIVE_CROP_REGISTRY_CAP"]);
@@ -399,9 +477,11 @@ function safeRelease(request) {
399
477
  }
400
478
  var NativeCropRegistry = class {
401
479
  capacity;
480
+ maxAgeMs;
402
481
  entries = /* @__PURE__ */ new Map();
403
- constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP) {
482
+ constructor(capacity = DEFAULT_NATIVE_CROP_REGISTRY_CAP, maxAgeMs = NATIVE_CROP_LEASE_MAX_AGE_MS) {
404
483
  this.capacity = capacity;
484
+ this.maxAgeMs = maxAgeMs;
405
485
  }
406
486
  /** Number of registered handles (tests / metrics). */
407
487
  get size() {
@@ -416,6 +496,7 @@ var NativeCropRegistry = class {
416
496
  * its worker-side RAM lease so a lease never outlives its registry entry.
417
497
  */
418
498
  register(handle, request, deviceId) {
499
+ this.sweepExpired();
419
500
  const k = this.key(handle);
420
501
  this.entries.delete(k);
421
502
  this.entries.set(k, {
@@ -426,27 +507,76 @@ var NativeCropRegistry = class {
426
507
  while (this.entries.size > this.capacity) {
427
508
  const oldest = this.entries.keys().next().value;
428
509
  if (oldest === void 0) break;
429
- this.releaseEntry(oldest);
510
+ this.releaseEntry(oldest, "capacity");
430
511
  }
431
512
  }
432
- /** The requester for `handle`, or `null` if never registered / already evicted. */
433
- get(handle) {
434
- const entry = this.entries.get(this.key(handle));
435
- if (entry === void 0) return null;
513
+ /**
514
+ * Resolve `handle`, applying the age bound (ADR-0419).
515
+ *
516
+ * The bound is applied HERE and not at the caller or the worker because this
517
+ * is the only place `registeredAt` exists — see `native-crop-lease-age.ts`.
518
+ * An expired handle is refused and released without the requester ever being
519
+ * invoked, so the doomed cross-process fetch does not happen at all.
520
+ */
521
+ lookup(handle) {
522
+ const key = this.key(handle);
523
+ const entry = this.entries.get(key);
524
+ if (entry === void 0) {
525
+ const swept = this.sweptByAge.get(key);
526
+ if (swept === void 0) return { kind: "unknown" };
527
+ return {
528
+ kind: "expired",
529
+ deviceId: swept.deviceId,
530
+ ageMs: Date.now() - swept.registeredAt
531
+ };
532
+ }
533
+ const ageMs = Date.now() - entry.registeredAt;
534
+ if (classifyNativeCropLeaseAge(ageMs, this.maxAgeMs) === "expired") {
535
+ this.releaseEntry(key, "age");
536
+ return {
537
+ kind: "expired",
538
+ deviceId: entry.deviceId,
539
+ ageMs
540
+ };
541
+ }
436
542
  return {
543
+ kind: "live",
437
544
  request: entry.request,
438
545
  deviceId: entry.deviceId,
439
- ageMs: Date.now() - entry.registeredAt
546
+ ageMs
440
547
  };
441
548
  }
442
549
  /**
550
+ * Drop every entry past the age bound, oldest first.
551
+ *
552
+ * The map is insertion-ordered and insertion order IS registration order, so
553
+ * the head is the oldest and the walk stops at the first live entry. Run on
554
+ * every `register`, which is the only path that reliably ticks on a busy
555
+ * node — a timer here would be a second owner of the same lifetime.
556
+ *
557
+ * It can never drop an entry a request would have been served from: anything
558
+ * it releases would have been refused by {@link lookup} in the same instant.
559
+ * What it buys is that the worker's lease is released when it stops being
560
+ * useful (10 s) instead of when 4 095 later handles have pushed it out of a
561
+ * capacity eviction (~30 s on this cluster) — the reason the registry sat
562
+ * pinned at `4096/4096`.
563
+ */
564
+ sweepExpired() {
565
+ const now = Date.now();
566
+ for (const [key, entry] of this.entries) {
567
+ if (classifyNativeCropLeaseAge(now - entry.registeredAt, this.maxAgeMs) === "live") return;
568
+ this.releaseEntry(key, "age");
569
+ }
570
+ }
571
+ /**
443
572
  * The deviceId a handle was registered under, even AFTER its entry was
444
573
  * evicted — kept in a small parallel ring so a `no-registry-entry` miss can
445
574
  * still be attributed to a camera (a miss line without `deviceId` cannot
446
575
  * answer "why is 617 worse than 615?").
447
576
  */
448
577
  deviceIdFor(handle) {
449
- return this.evictedDeviceIds.get(this.key(handle)) ?? this.entries.get(this.key(handle))?.deviceId ?? null;
578
+ const key = this.key(handle);
579
+ return this.evictedDeviceIds.get(key) ?? this.sweptByAge.get(key)?.deviceId ?? this.entries.get(key)?.deviceId ?? null;
450
580
  }
451
581
  /**
452
582
  * Release the worker-side RAM native lease for `handle` and drop its entry —
@@ -461,13 +591,24 @@ var NativeCropRegistry = class {
461
591
  for (const entry of this.entries.values()) safeRelease(entry.request);
462
592
  this.entries.clear();
463
593
  this.evictedDeviceIds.clear();
594
+ this.sweptByAge.clear();
464
595
  }
465
- /** Release + drop one entry by its map key, remembering its camera. */
466
- releaseEntry(key) {
596
+ /**
597
+ * Release + drop one entry by its map key, remembering WHY it left.
598
+ *
599
+ * `age` — past the lease bound (a `lookup` refusal or a `register` sweep):
600
+ * remembered with its registration clock, so a later ask for the same
601
+ * handle is still answered `expired` with a real age. `capacity` — the cap
602
+ * bound and pushed it out; `released` — the caller was done with it. Those
603
+ * two stay `unknown` on a later ask: neither is a late ask, and folding
604
+ * them into the refusal count would make the bound look tighter than it is.
605
+ */
606
+ releaseEntry(key, cause = "released") {
467
607
  const entry = this.entries.get(key);
468
608
  if (!entry) return;
469
609
  this.entries.delete(key);
470
- this.rememberEvicted(key, entry.deviceId);
610
+ if (cause === "age") this.rememberSweptByAge(key, entry);
611
+ else this.rememberEvicted(key, entry.deviceId);
471
612
  safeRelease(entry.request);
472
613
  }
473
614
  /** Bounded memory of evicted handles → deviceId, for miss attribution. */
@@ -480,6 +621,24 @@ var NativeCropRegistry = class {
480
621
  this.evictedDeviceIds.delete(oldest);
481
622
  }
482
623
  }
624
+ /**
625
+ * Bounded memory of handles the AGE bound dropped, with the clock the
626
+ * refusal is measured from. Same capacity as the live map: at the busiest
627
+ * node's rate that is one more lease window of memory, after which a
628
+ * refusal degrades to `unknown` — an ask that late is not a bound question.
629
+ */
630
+ sweptByAge = /* @__PURE__ */ new Map();
631
+ rememberSweptByAge(key, entry) {
632
+ this.sweptByAge.set(key, {
633
+ deviceId: entry.deviceId,
634
+ registeredAt: entry.registeredAt
635
+ });
636
+ while (this.sweptByAge.size > this.capacity) {
637
+ const oldest = this.sweptByAge.keys().next().value;
638
+ if (oldest === void 0) break;
639
+ this.sweptByAge.delete(oldest);
640
+ }
641
+ }
483
642
  };
484
643
  //#endregion
485
644
  //#region src/session-decode/node-decode-budget.ts
@@ -574,6 +733,160 @@ function createRetainedContentFallback(registry, ref) {
574
733
  }
575
734
  } };
576
735
  }
736
+ //#endregion
737
+ //#region src/session-decode/parked-frame-store.ts
738
+ /** Per-camera parcel bound. 24 live tracks × two kinds on one camera is a
739
+ * crowd scene; past that the camera's oldest parcel leaves first. Read ONCE
740
+ * from `CAMSTACK_PARKED_FRAMES_PER_DEVICE`. */
741
+ var PARKED_FRAMES_PER_DEVICE_MAX = (() => {
742
+ const raw = Number(process.env["CAMSTACK_PARKED_FRAMES_PER_DEVICE"]);
743
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 48;
744
+ })();
745
+ /** {@link PARKED_FRAME_BUDGET_MB} in bytes. */
746
+ var PARKED_FRAME_BUDGET_BYTES = (() => {
747
+ const raw = Number(process.env["CAMSTACK_PARKED_FRAME_MB"]);
748
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 64;
749
+ })() * 1024 * 1024;
750
+ function keyOf$1(deviceId, trackId, kind) {
751
+ return `${deviceId}|${trackId}|${kind}`;
752
+ }
753
+ var ParkedFrameStore = class {
754
+ budgetBytes;
755
+ perDeviceMax;
756
+ maxAgeMs;
757
+ now;
758
+ onEvict;
759
+ /** Insertion-ordered → the head is the globally oldest park. A replace
760
+ * re-inserts, so order IS park order. */
761
+ entries = /* @__PURE__ */ new Map();
762
+ /** Per-device keys in park order, mirroring {@link entries} for the bound. */
763
+ perDeviceKeys = /* @__PURE__ */ new Map();
764
+ totalBytes = 0;
765
+ constructor(options = {}) {
766
+ this.budgetBytes = options.budgetBytes ?? PARKED_FRAME_BUDGET_BYTES;
767
+ this.perDeviceMax = options.perDeviceMax ?? PARKED_FRAMES_PER_DEVICE_MAX;
768
+ this.maxAgeMs = options.maxAgeMs ?? 6e5;
769
+ this.now = options.now ?? Date.now;
770
+ this.onEvict = options.onEvict;
771
+ }
772
+ /** Number of parcels held (tests / metrics). */
773
+ get size() {
774
+ return this.entries.size;
775
+ }
776
+ /** Bytes of JPEG held (tests / metrics). */
777
+ get bytes() {
778
+ return this.totalBytes;
779
+ }
780
+ /**
781
+ * Park one parcel. Replaces an existing parcel for the key unless the new
782
+ * one shows an OLDER frame instant (a racing loser). Sweeps the age ceiling,
783
+ * then enforces the per-device bound and the node budget — evicting the
784
+ * oldest of the offending scope, never refusing the new parcel.
785
+ */
786
+ park(deviceId, trackId, kind, frame) {
787
+ const nowMs = this.now();
788
+ this.sweepExpired(nowMs);
789
+ const key = keyOf$1(deviceId, trackId, kind);
790
+ const existing = this.entries.get(key);
791
+ if (existing !== void 0 && frame.timestamp < existing.frame.timestamp) return "older-ignored";
792
+ if (existing !== void 0) this.remove(key);
793
+ this.entries.set(key, {
794
+ deviceId,
795
+ trackId,
796
+ kind,
797
+ frame,
798
+ parkedAt: nowMs
799
+ });
800
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
801
+ if (deviceKeys) deviceKeys.push(key);
802
+ else this.perDeviceKeys.set(deviceId, [key]);
803
+ this.totalBytes += frame.jpeg.byteLength;
804
+ this.enforceDeviceBound(deviceId);
805
+ this.enforceBudget();
806
+ return existing === void 0 ? "stored" : "replaced";
807
+ }
808
+ /** The parcel for a key, or `null` — never anything else, never by handle. */
809
+ get(deviceId, trackId, kind) {
810
+ const entry = this.entries.get(keyOf$1(deviceId, trackId, kind));
811
+ if (entry === void 0) return null;
812
+ if (this.now() - entry.parkedAt > this.maxAgeMs) {
813
+ this.evict(keyOf$1(deviceId, trackId, kind), "age");
814
+ return null;
815
+ }
816
+ return {
817
+ ...entry.frame,
818
+ parkedAt: entry.parkedAt
819
+ };
820
+ }
821
+ /** Drop every parcel of a track (the hub closed it). Returns how many left. */
822
+ release(deviceId, trackId) {
823
+ const keys = (this.perDeviceKeys.get(deviceId) ?? []).filter((key) => this.entries.get(key)?.trackId === trackId);
824
+ for (const key of keys) this.remove(key);
825
+ return keys.length;
826
+ }
827
+ /** What one camera is costing right now. */
828
+ statsByDevice(deviceId) {
829
+ const keys = this.perDeviceKeys.get(deviceId) ?? [];
830
+ let bytes = 0;
831
+ for (const key of keys) bytes += this.entries.get(key)?.frame.jpeg.byteLength ?? 0;
832
+ return {
833
+ parcels: keys.length,
834
+ bytes
835
+ };
836
+ }
837
+ /** Drop everything (addon shutdown). */
838
+ clear() {
839
+ this.entries.clear();
840
+ this.perDeviceKeys.clear();
841
+ this.totalBytes = 0;
842
+ }
843
+ /** Age sweep from the head — insertion order is park order, so it stops at
844
+ * the first parcel still inside the ceiling. */
845
+ sweepExpired(nowMs) {
846
+ for (const [key, entry] of this.entries) {
847
+ if (nowMs - entry.parkedAt <= this.maxAgeMs) return;
848
+ this.evict(key, "age");
849
+ }
850
+ }
851
+ enforceDeviceBound(deviceId) {
852
+ const deviceKeys = this.perDeviceKeys.get(deviceId);
853
+ while (deviceKeys !== void 0 && deviceKeys.length > this.perDeviceMax) {
854
+ const oldest = deviceKeys[0];
855
+ if (oldest === void 0) break;
856
+ this.evict(oldest, "device-bound");
857
+ }
858
+ }
859
+ /** Globally oldest first, whatever its device; always keeps ≥ 1. */
860
+ enforceBudget() {
861
+ for (const key of [...this.entries.keys()]) {
862
+ if (this.totalBytes <= this.budgetBytes || this.entries.size <= 1) return;
863
+ this.evict(key, "node-budget");
864
+ }
865
+ }
866
+ evict(key, cause) {
867
+ const entry = this.entries.get(key);
868
+ if (entry === void 0) return;
869
+ this.remove(key);
870
+ this.onEvict?.({
871
+ deviceId: entry.deviceId,
872
+ trackId: entry.trackId,
873
+ kind: entry.kind,
874
+ cause
875
+ });
876
+ }
877
+ remove(key) {
878
+ const entry = this.entries.get(key);
879
+ if (entry === void 0) return;
880
+ this.entries.delete(key);
881
+ this.totalBytes -= entry.frame.jpeg.byteLength;
882
+ const deviceKeys = this.perDeviceKeys.get(entry.deviceId);
883
+ if (deviceKeys) {
884
+ const idx = deviceKeys.indexOf(key);
885
+ if (idx >= 0) deviceKeys.splice(idx, 1);
886
+ if (deviceKeys.length === 0) this.perDeviceKeys.delete(entry.deviceId);
887
+ }
888
+ }
889
+ };
577
890
  /** {@link RETAINED_FRAME_BUDGET_MB} in bytes. */
578
891
  var RETAINED_FRAME_BUDGET_BYTES = (() => {
579
892
  const raw = Number(process.env["CAMSTACK_SESSION_RETAIN_MB"]);
@@ -2451,7 +2764,7 @@ var EMPTY_SAMPLE = {
2451
2764
  */
2452
2765
  function applyStallTick(sample, lateByMs, gcWindow, reportThresholdMs) {
2453
2766
  if (lateByMs < reportThresholdMs) return sample;
2454
- const verdict = require_event_loop_stall_monitor.attributeStall(lateByMs, Math.round(gcWindow.totalMs), gcWindow.count, Math.round(gcWindow.longestMs));
2767
+ const verdict = (0, _camstack_system_addon_utils.attributeStall)(lateByMs, Math.round(gcWindow.totalMs), gcWindow.count, Math.round(gcWindow.longestMs));
2455
2768
  return {
2456
2769
  count: sample.count + 1,
2457
2770
  maxMs: Math.max(sample.maxMs, lateByMs),
@@ -6038,6 +6351,90 @@ function isUsable(bbox) {
6038
6351
  return bbox.width > 0 && bbox.height > 0;
6039
6352
  }
6040
6353
  //#endregion
6354
+ //#region src/pipeline-runner/track-frame-park.ts
6355
+ /** Park one frame for one track. Never throws; a refusal is an answer. */
6356
+ async function parkTrackFrame(deps, input) {
6357
+ const { deviceId, trackId, kind } = input;
6358
+ const handleKey = `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`;
6359
+ const resolved = await deps.resolvePixels({
6360
+ handle: input.handle,
6361
+ bbox: input.bbox,
6362
+ ...input.maxWidth !== void 0 ? { maxWidth: input.maxWidth } : {}
6363
+ });
6364
+ if (resolved.pixels === null || resolved.tier !== "native") {
6365
+ const missReason = resolved.missReason ?? "worker-lease-gone";
6366
+ const detail = resolved.workerReason !== void 0 ? `${missReason}: ${resolved.workerReason}` : missReason;
6367
+ deps.logger.warn("track frame park refused — the handle no longer resolves at park time", {
6368
+ tags: { deviceId },
6369
+ meta: {
6370
+ trackId,
6371
+ kind,
6372
+ reason: "handle-gone",
6373
+ missReason,
6374
+ handleAgeMs: resolved.handleAgeMs,
6375
+ servedTier: resolved.tier,
6376
+ handle: handleKey,
6377
+ ...resolved.workerReason !== void 0 ? { workerReason: resolved.workerReason } : {}
6378
+ }
6379
+ });
6380
+ return {
6381
+ parked: false,
6382
+ nodeId: deps.nodeId,
6383
+ reason: "handle-gone",
6384
+ handleAgeMs: resolved.handleAgeMs,
6385
+ detail
6386
+ };
6387
+ }
6388
+ const jpeg = await deps.encodeJpeg(resolved.pixels);
6389
+ if (jpeg === null) {
6390
+ deps.logger.warn("track frame park refused — JPEG encode failed", {
6391
+ tags: { deviceId },
6392
+ meta: {
6393
+ trackId,
6394
+ kind,
6395
+ reason: "encode-failed",
6396
+ handleAgeMs: resolved.handleAgeMs,
6397
+ handle: handleKey,
6398
+ width: resolved.pixels.width,
6399
+ height: resolved.pixels.height
6400
+ }
6401
+ });
6402
+ return {
6403
+ parked: false,
6404
+ nodeId: deps.nodeId,
6405
+ reason: "encode-failed",
6406
+ handleAgeMs: resolved.handleAgeMs
6407
+ };
6408
+ }
6409
+ const verdict = deps.store.park(deviceId, trackId, kind, {
6410
+ jpeg,
6411
+ width: resolved.pixels.width,
6412
+ height: resolved.pixels.height,
6413
+ timestamp: input.timestamp
6414
+ });
6415
+ deps.logger.debug("track frame parked", {
6416
+ tags: { deviceId },
6417
+ meta: {
6418
+ trackId,
6419
+ kind,
6420
+ verdict,
6421
+ bytes: jpeg.byteLength,
6422
+ width: resolved.pixels.width,
6423
+ height: resolved.pixels.height,
6424
+ handleAgeMs: resolved.handleAgeMs,
6425
+ source: resolved.pixels.source ?? "lease"
6426
+ }
6427
+ });
6428
+ return {
6429
+ parked: true,
6430
+ nodeId: deps.nodeId,
6431
+ width: resolved.pixels.width,
6432
+ height: resolved.pixels.height,
6433
+ bytes: jpeg.byteLength,
6434
+ replaced: verdict === "replaced"
6435
+ };
6436
+ }
6437
+ //#endregion
6041
6438
  //#region src/pipeline-runner/index.ts
6042
6439
  /**
6043
6440
  * addon-pipeline-runner — per-node detection scheduler.
@@ -6107,6 +6504,8 @@ var PACKAGE_ZONE_UNION_TTL_MS = 6e4;
6107
6504
  var MAX_BOUND_FRAME_CUTTERS = 256;
6108
6505
  /** How often a camera reports its undispatched cuts. */
6109
6506
  var TILE_CUT_DROP_LOG_INTERVAL_MS = 6e4;
6507
+ /** How often a camera reports parcels that left without a release (D422). */
6508
+ var PARKED_EVICTION_LOG_INTERVAL_MS = 6e4;
6110
6509
  /**
6111
6510
  * How often a camera reports what carrying its birth evidence COST (D379).
6112
6511
  *
@@ -6136,7 +6535,6 @@ var DEFAULT_CONFIG = {
6136
6535
  * this window REUSES the warm worker instead of re-forking it — killing the
6137
6536
  * spawn/teardown churn (and its cold-start latency spike) that on-motion
6138
6537
  * cameras produced when every gate open/close forked and killed a worker.
6139
- * Mirrors Scrypted's decode-fork ~15s idle-timeout.
6140
6538
  */
6141
6539
  /** JPEG quality for the runner→executor detection-frame hop. q82 keeps
6142
6540
  * detector-relevant detail (source is h264 anyway) at ~10-15× less payload
@@ -6494,6 +6892,17 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6494
6892
  * Initialised in {@link onInitialize} once `nodeId` is known.
6495
6893
  */
6496
6894
  retainedFrames = null;
6895
+ /**
6896
+ * Per-track PARCELS (D422): one JPEG per `(deviceId, trackId, kind)`, cut
6897
+ * from the native surface at the instant the hub judged the frame relevant,
6898
+ * retrieved later by that key — never by handle, never by age. Bounded per
6899
+ * device and per node in `parked-frame-store.ts`, where the numbers are
6900
+ * stated. Initialised in {@link onInitialize} beside the retained store.
6901
+ */
6902
+ parkedFrames = null;
6903
+ /** Per-camera tally of parcels that left WITHOUT a release, by cause. */
6904
+ parkedEvictions = /* @__PURE__ */ new Map();
6905
+ parkedEvictionLoggedAt = /* @__PURE__ */ new Map();
6497
6906
  frameLazy = new FrameLazyTelemetry();
6498
6907
  retention = null;
6499
6908
  attached = /* @__PURE__ */ new Map();
@@ -6724,6 +7133,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6724
7133
  nodeId: this.nodeId,
6725
7134
  continuousRetain: isContinuousRetainEnabled()
6726
7135
  });
7136
+ this.parkedFrames = new ParkedFrameStore({ onEvict: (eviction) => this.noteParkedEviction(eviction.deviceId, eviction.cause) });
6727
7137
  this.runner = new PipelineRunner({
6728
7138
  maxQueueDepth: this.config.maxQueueDepth,
6729
7139
  maxConcurrentInferences: this.config.maxConcurrentInferences,
@@ -6875,6 +7285,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
6875
7285
  this.nativeCropRegistry.clear();
6876
7286
  this.retainedFrames?.clear();
6877
7287
  this.retainedFrames = null;
7288
+ this.parkedFrames?.clear();
7289
+ this.parkedFrames = null;
6878
7290
  this.retention = null;
6879
7291
  }
6880
7292
  async cacheBenchFrame(input) {
@@ -7669,6 +8081,96 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7669
8081
  }, resolved.tier, input.encodeJpeg);
7670
8082
  }
7671
8083
  /**
8084
+ * PARK a frame for a track (D422) — the hub judged it relevant, and this node
8085
+ * still holds its pixels. Same native-lease resolve `getNativeCrop` uses,
8086
+ * same single encoder; the parcel lives in {@link parkedFrames} under
8087
+ * `(deviceId, trackId, kind)`. A refusal is an answer and a `deviceId`-tagged
8088
+ * log line, never a retry.
8089
+ */
8090
+ async parkTrackFrame(input) {
8091
+ const store = this.parkedFrames;
8092
+ if (store === null) return {
8093
+ parked: false,
8094
+ nodeId: this.nodeId,
8095
+ reason: "handle-gone",
8096
+ handleAgeMs: null,
8097
+ detail: "runner not initialised"
8098
+ };
8099
+ return parkTrackFrame({
8100
+ nodeId: this.nodeId,
8101
+ store,
8102
+ resolvePixels: (request) => this.resolveNativeCropPixels(request),
8103
+ encodeJpeg: async (pixels) => {
8104
+ const encoded = await this.encodeNativeCropResult(pixels, "native", true);
8105
+ return encoded?.jpeg !== void 0 ? Buffer.from(encoded.jpeg, "base64") : null;
8106
+ },
8107
+ logger: this.ctx.logger
8108
+ }, input);
8109
+ }
8110
+ /** A parcel by `(deviceId, trackId, kind)`; `null` = nothing parked here. */
8111
+ async getParkedTrackFrame(input) {
8112
+ const parcel = this.parkedFrames?.get(input.deviceId, input.trackId, input.kind) ?? null;
8113
+ if (parcel === null) {
8114
+ this.ctxIfReady?.logger.debug("parked frame miss — nothing under this key", {
8115
+ tags: { deviceId: input.deviceId },
8116
+ meta: {
8117
+ trackId: input.trackId,
8118
+ kind: input.kind,
8119
+ ...this.parkedFrames?.statsByDevice(input.deviceId) ?? {
8120
+ parcels: 0,
8121
+ bytes: 0
8122
+ }
8123
+ }
8124
+ });
8125
+ return null;
8126
+ }
8127
+ return {
8128
+ jpeg: parcel.jpeg.toString("base64"),
8129
+ width: parcel.width,
8130
+ height: parcel.height,
8131
+ timestamp: parcel.timestamp,
8132
+ parkedAt: parcel.parkedAt
8133
+ };
8134
+ }
8135
+ /** The hub closed a track: drop its parcels. Idempotent. */
8136
+ async releaseParkedTrackFrames(input) {
8137
+ return { released: this.parkedFrames?.release(input.deviceId, input.trackId) ?? 0 };
8138
+ }
8139
+ /**
8140
+ * A parcel left without a release. Counted per camera by cause and reported
8141
+ * one line per camera per minute: a camera whose parcels keep leaving under
8142
+ * the device bound is a crowd, under the node budget is a neighbour's
8143
+ * traffic, and under the age ceiling is a hub that stopped releasing.
8144
+ */
8145
+ noteParkedEviction(deviceId, cause) {
8146
+ const tally = this.parkedEvictions.get(deviceId) ?? {
8147
+ deviceBound: 0,
8148
+ nodeBudget: 0,
8149
+ age: 0
8150
+ };
8151
+ if (cause === "device-bound") tally.deviceBound += 1;
8152
+ else if (cause === "node-budget") tally.nodeBudget += 1;
8153
+ else tally.age += 1;
8154
+ this.parkedEvictions.set(deviceId, tally);
8155
+ const now = Date.now();
8156
+ if (now - (this.parkedEvictionLoggedAt.get(deviceId) ?? 0) < PARKED_EVICTION_LOG_INTERVAL_MS) return;
8157
+ this.parkedEvictionLoggedAt.set(deviceId, now);
8158
+ this.ctxIfReady?.logger.info("parked frames evicted without a release", {
8159
+ tags: { deviceId },
8160
+ meta: {
8161
+ ...tally,
8162
+ lastCause: cause,
8163
+ parcelsHeld: this.parkedFrames?.size ?? 0,
8164
+ bytesHeld: this.parkedFrames?.bytes ?? 0
8165
+ }
8166
+ });
8167
+ this.parkedEvictions.set(deviceId, {
8168
+ deviceBound: 0,
8169
+ nodeBudget: 0,
8170
+ age: 0
8171
+ });
8172
+ }
8173
+ /**
7672
8174
  * Turn a finished frame into subject TILES and end its hold — the runner half
7673
8175
  * of R2 (`docs/design/2026-08-13-native-lease-two-tier-redesign.md`).
7674
8176
  *
@@ -7938,11 +8440,14 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7938
8440
  let nativeResult = null;
7939
8441
  let missReason = null;
7940
8442
  let handleAgeMs = null;
8443
+ /** The camera the lookup attributed the handle to, live or expired. */
8444
+ let deviceIdForMiss = null;
7941
8445
  let workerReason;
7942
8446
  if (!isUnboundedFullFrame) {
7943
- const found = this.nativeCropRegistry.get(input.handle);
7944
- handleAgeMs = found?.ageMs ?? null;
7945
- if (found) {
8447
+ const found = this.nativeCropRegistry.lookup(input.handle);
8448
+ handleAgeMs = found.kind === "unknown" ? null : found.ageMs;
8449
+ deviceIdForMiss = found.kind === "unknown" ? null : found.deviceId;
8450
+ if (found.kind === "live") {
7946
8451
  const attempt = await found.request(input.bbox, input.maxWidth);
7947
8452
  if (attempt.kind === "crop") nativeResult = {
7948
8453
  bytes: attempt.crop.bytes,
@@ -7954,11 +8459,12 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7954
8459
  missReason = "worker-lease-gone";
7955
8460
  workerReason = attempt.workerReason;
7956
8461
  }
7957
- } else missReason = "no-registry-entry";
8462
+ } else if (found.kind === "expired") missReason = "handle-expired";
8463
+ else missReason = "no-registry-entry";
7958
8464
  if (missReason !== null) {
7959
8465
  const tierOnMiss = resolveNativeCropSource(isFullFrame, false);
7960
8466
  const fields = {
7961
- tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
8467
+ tags: { deviceId: deviceIdForMiss ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
7962
8468
  meta: {
7963
8469
  reason: missReason,
7964
8470
  servedTier: tierOnMiss,
@@ -7970,8 +8476,16 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
7970
8476
  ...workerReason !== void 0 ? { workerReason } : {}
7971
8477
  }
7972
8478
  };
7973
- if (tierOnMiss === "miss") this.ctx.logger.warn("native crop miss", fields);
7974
- else this.ctx.logger.debug("native crop miss", fields);
8479
+ const message = missReason === "handle-expired" ? "native crop refused — the handle is past the lease age bound" : "native crop miss";
8480
+ const withBound = missReason === "handle-expired" ? {
8481
+ ...fields,
8482
+ meta: {
8483
+ ...fields.meta,
8484
+ leaseMaxAgeMs: NATIVE_CROP_LEASE_MAX_AGE_MS
8485
+ }
8486
+ } : fields;
8487
+ if (tierOnMiss === "miss") this.ctx.logger.warn(message, withBound);
8488
+ else this.ctx.logger.debug(message, withBound);
7975
8489
  }
7976
8490
  }
7977
8491
  return {
@@ -9887,6 +10401,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
9887
10401
  frame: result,
9888
10402
  nodeId: this.nodeId,
9889
10403
  frameHandle: handle,
10404
+ emittedAt: Date.now(),
9890
10405
  ...typeof capturedAt === "number" && capturedAt > 0 ? { capturedAt } : {},
9891
10406
  ...detailSteps && detailSteps.length > 0 ? { detailSteps } : {}
9892
10407
  };