@camstack/addon-pipeline 1.2.135 → 1.2.137

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 (41) hide show
  1. package/dist/{addon-utils-CKf8kbwh.js → addon-utils-C6b5Tnia.js} +3 -3
  2. package/dist/audio-analyzer/index.js +5 -5
  3. package/dist/audio-analyzer/index.mjs +4 -4
  4. package/dist/detection-pipeline/index.js +55 -17
  5. package/dist/detection-pipeline/index.mjs +53 -15
  6. package/dist/{dist-BzLiQ8Ue.js → dist-4EMKvxCp.js} +275 -25
  7. package/dist/{dist-C1dXLLQq.mjs → dist-g0Or5_pR.mjs} +270 -26
  8. package/dist/{event-loop-stall-monitor-CU-xxX5c.mjs → event-loop-stall-monitor-CskL9Lgu.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-DjV4Bow7.js → event-loop-stall-monitor-DDm07Ei6.js} +1 -1
  10. package/dist/{lazy-sharp-DxWmhTqk.js → lazy-sharp-BlA9PxWk.js} +3 -3
  11. package/dist/motion-wasm/index.js +3 -3
  12. package/dist/motion-wasm/index.mjs +3 -3
  13. package/dist/node-BOHn8gpk.js +721 -0
  14. package/dist/node-Bo1g8hLO.mjs +690 -0
  15. package/dist/pipeline-runner/index.js +37 -11
  16. package/dist/pipeline-runner/index.mjs +37 -11
  17. package/dist/{process-memory-CZK3D3kM.js → process-memory-C3084DOt.js} +2 -2
  18. package/dist/{process-memory-CaEwi5Cd.mjs → process-memory-DmrHWybb.mjs} +2 -2
  19. package/dist/recorder/index.js +382 -35
  20. package/dist/recorder/index.mjs +382 -35
  21. package/dist/{segment-demux-js-BbpqygHL.js → segment-demux-js-BrQ_A0oi.js} +1 -1
  22. package/dist/{segment-demux-js-njRO85gd.mjs → segment-demux-js-C-a2pghM.mjs} +1 -1
  23. package/dist/session-decode/decode-worker-child.js +2 -2
  24. package/dist/session-decode/decode-worker-child.mjs +2 -2
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C14Qqrqn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-AH6OJ3hJ.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQyiEd0l.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BrTM_tsb.mjs +26 -0
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-kjsveCjG.mjs → hostInit-XNyn-FTM.mjs} +3 -3
  32. package/dist/stream-broker/index.js +88 -515
  33. package/dist/stream-broker/index.mjs +85 -510
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/{worker-protocol-DvTZBvh2.js → worker-protocol-BLfaD5uC.js} +1 -1
  36. package/dist/{worker-protocol-DxsVx0G7.mjs → worker-protocol-vc8WWhjx.mjs} +1 -1
  37. package/package.json +17 -3
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +0 -26
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs +0 -26
  40. package/dist/{addon-utils-CgBCF-mE.mjs → addon-utils-C8KTuPdE.mjs} +2 -2
  41. package/dist/{lazy-sharp-BtT5x-qP.mjs → lazy-sharp-DXsqwpph.mjs} +1 -1
@@ -1,12 +1,13 @@
1
- import { $t as EventCategory, At as DeviceFeature, B as defineCustomActions, Bt as selectAssignedProfileSlots, E as RecordingConfigSchema, J as evaluateSensorEdge, Jt as number, L as customAction, Lt as nodePin, N as batteryCapability, Nt as hydrateSchema, Ot as BaseAddon, Q as isFirstLevelMacroClass, St as errMsg, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, Ut as array, V as deriveBatteryPresence, Wt as boolean, Xt as record, Yt as object, Zt as string, _ as ExportRecordSchema, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as recordingCapability, ft as recordingExportCapability, gt as resolveRecordingProfiles, h as EVENT_PAD_MS, i as BatteryStatusSchema, jt as DeviceType, vt as storageEvictableCapability, x as OpsLogEntrySchema } from "../dist-C1dXLLQq.mjs";
1
+ import { $ as loadContributionCapability, B as defineCustomActions, Ct as errMsg, E as RecordingConfigSchema, Gt as boolean, J as evaluateSensorEdge, L as customAction, Mt as DeviceType, N as batteryCapability, Pt as hydrateSchema, Q as isFirstLevelMacroClass, Qt as string, Rt as nodePin, T as RECORDING_EXPORT_MAX_READ_BYTES, U as deriveRecordingMode, V as deriveBatteryPresence, Vt as selectAssignedProfileSlots, Wt as array, Xt as object, Yt as number, Zt as record, _ as ExportRecordSchema, _t as resolveRecordingProfiles, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, en as EventCategory, ft as recordingCapability, h as EVENT_PAD_MS, i as BatteryStatusSchema, jt as DeviceFeature, kt as BaseAddon, pt as recordingExportCapability, x as OpsLogEntrySchema, yt as storageEvictableCapability } from "../dist-g0Or5_pR.mjs";
2
+ import { i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Bo1g8hLO.mjs";
2
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
4
  import { r as withRecordingIntent } from "../restream-intent-B4BXZra7.mjs";
4
- import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-CgBCF-mE.mjs";
5
+ import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-C8KTuPdE.mjs";
5
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
6
7
  import { randomUUID } from "node:crypto";
7
- import { spawn } from "node:child_process";
8
- import path from "node:path";
9
8
  import { createReadStream, promises, realpathSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { spawn } from "node:child_process";
10
11
  import { open } from "node:fs/promises";
11
12
  //#region src/recorder/event-capture.ts
12
13
  function markerFromMotion(data, pad) {
@@ -6223,6 +6224,7 @@ Object.freeze({
6223
6224
  "listProfiles",
6224
6225
  "listRuntimeNodes"
6225
6226
  ],
6227
+ "load-contribution": ["list"],
6226
6228
  "log-channels": ["list"],
6227
6229
  "log-destination": ["query"],
6228
6230
  "login-method": ["getLoginMethods"],
@@ -8732,6 +8734,38 @@ var RESTART_MAX_MS = 1e4;
8732
8734
  /** A process that ran at least this long is "stable" → reset the restart count,
8733
8735
  * so sporadic blips over a long lifetime never accumulate into a give-up. */
8734
8736
  var STABLE_RUN_MS = 3e4;
8737
+ /**
8738
+ * The window the SECOND budget counts over — and it counts EVERY restart,
8739
+ * whatever that run lasted.
8740
+ *
8741
+ * `STABLE_RUN_MS` above is a reset, and a reset is a blind spot: measured on
8742
+ * 2026-08-27, `SegmentWriter restarting` fired **1 814 times in 12 h** and
8743
+ * almost every `ranMs` was over 30 s (122 620 / 165 255 / 345 420 / 394 522 ms
8744
+ * are four consecutive real samples), so `attempt` never left 1, `onGaveUp`
8745
+ * never fired, and `recoverWriterGaveUp` never ran. The storm could have lasted
8746
+ * forever without a single line saying anything was wrong.
8747
+ *
8748
+ * A window cannot be zeroed by a long run, which is the whole point. Modelled
8749
+ * on `CrashSupervisor` (kernel, D6): timestamps age out of `windowMs`, and the
8750
+ * count inside it is the budget.
8751
+ */
8752
+ var RESTART_WINDOW_MS = 60 * 6e4;
8753
+ /**
8754
+ * Restarts inside {@link RESTART_WINDOW_MS} before the writer stops respawning
8755
+ * and escalates to the controller.
8756
+ *
8757
+ * 15/hour, from the measured distribution. Per writer over the same 12 h:
8758
+ * 1441/high 549 (**45.8/h**), 615/high 241 (20.1/h), 618/high 217 (18.1/h),
8759
+ * 592/high 211 (17.6/h), 590/high 195 (16.3/h) — every one of them a storm and
8760
+ * every one of them silent — against `low` profiles at 4–15 per 12 h (≤1.3/h),
8761
+ * which is what a healthy writer looks like. 15 separates them with room for a
8762
+ * camera that blips hourly and is otherwise fine.
8763
+ *
8764
+ * It never shadows {@link MAX_RESTARTS}: 10 CONSECUTIVE rapid failures happen
8765
+ * inside ~90 s, so the rapid breaker still trips first on a hard failure. This
8766
+ * one exists for the slow storm the rapid breaker structurally cannot see.
8767
+ */
8768
+ var MAX_RESTARTS_IN_WINDOW = 15;
8735
8769
  /** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
8736
8770
  var KILL_GRACE_MS = 500;
8737
8771
  /** Supervises one passthrough ffmpeg for a single (camera, profile). */
@@ -8741,10 +8775,22 @@ var SegmentWriter = class {
8741
8775
  proc = null;
8742
8776
  stopped = false;
8743
8777
  restarts = 0;
8778
+ /**
8779
+ * Epoch-ms of every termination still inside {@link RESTART_WINDOW_MS}.
8780
+ * Replaced, never mutated in place, so a reader can never observe a
8781
+ * half-pruned window.
8782
+ */
8783
+ restartWindow = [];
8744
8784
  restartTimer = null;
8745
8785
  startedAt = 0;
8746
8786
  exitWait = null;
8747
8787
  resolveExit = null;
8788
+ /**
8789
+ * The cost claim for the CURRENT ffmpeg generation. Taken in `start()` and
8790
+ * released in `onTermination()` — the two edges of one process, so a restart
8791
+ * storm cannot accumulate claims for children that are already gone.
8792
+ */
8793
+ costClaim = NO_COST_CLAIM;
8748
8794
  constructor(cfg, deps) {
8749
8795
  this.cfg = cfg;
8750
8796
  this.deps = deps;
@@ -8758,6 +8804,7 @@ var SegmentWriter = class {
8758
8804
  this.resolveExit = resolve;
8759
8805
  });
8760
8806
  this.startedAt = Date.now();
8807
+ this.costClaim = this.deps.claimCost?.(proc.pid) ?? NO_COST_CLAIM;
8761
8808
  const stderrTail = [];
8762
8809
  proc.stderr?.on("data", (chunk) => {
8763
8810
  const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
@@ -8774,10 +8821,11 @@ var SegmentWriter = class {
8774
8821
  settled = true;
8775
8822
  this.onTermination(termination, stderrTail);
8776
8823
  };
8777
- proc.on("exit", (code) => {
8824
+ proc.on("exit", (code, signal) => {
8778
8825
  terminate({
8779
8826
  reason: "exit",
8780
- exitCode: typeof code === "number" ? code : null
8827
+ exitCode: typeof code === "number" ? code : null,
8828
+ signal: typeof signal === "string" ? signal : null
8781
8829
  });
8782
8830
  });
8783
8831
  proc.on("error", (raw) => {
@@ -8793,32 +8841,53 @@ var SegmentWriter = class {
8793
8841
  /**
8794
8842
  * The ONE restart policy, reached from both `exit` and `error`.
8795
8843
  *
8796
- * `deps.logger` is the device-scoped child the controller binds
8797
- * (`logger.withTags({ deviceId })`), so every line below carries
8798
- * `tags: { deviceId }` "why is 617 worse than 615" is always asked per
8799
- * camera, and a writer that stops recording one camera must be greppable by
8800
- * that camera.
8844
+ * Every line below carries `tags: { deviceId }` from `deps.deviceId` "why
8845
+ * is 617 worse than 615" is always asked per camera, and a writer that stops
8846
+ * recording one camera must be greppable by that camera.
8801
8847
  */
8802
8848
  onTermination(termination, stderrTail) {
8849
+ this.costClaim.release();
8850
+ this.costClaim = NO_COST_CLAIM;
8803
8851
  this.resolveExit?.();
8804
8852
  this.resolveExit = null;
8805
8853
  this.proc = null;
8806
8854
  if (this.stopped) return;
8807
- const ranMs = Date.now() - this.startedAt;
8855
+ const now = Date.now();
8856
+ const ranMs = now - this.startedAt;
8808
8857
  if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
8809
- if (termination.reason === "spawn-error") {
8810
- this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", { meta: {
8858
+ this.restartWindow = [...this.restartWindow.filter((at) => at > now - RESTART_WINDOW_MS), now];
8859
+ const restartsInWindow = this.restartWindow.length;
8860
+ if (termination.reason === "exit") this.deps.logger.warn("SegmentWriter ffmpeg exited", {
8861
+ tags: { deviceId: this.deps.deviceId },
8862
+ meta: {
8811
8863
  outDir: this.cfg.outDir,
8812
- errorCode: termination.errorCode,
8813
- error: termination.message,
8814
- permanent: termination.permanent
8815
- } });
8816
- if (termination.permanent) {
8817
- this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", { meta: {
8864
+ exitCode: termination.exitCode,
8865
+ signal: termination.signal,
8866
+ ranMs,
8867
+ restartsInWindow,
8868
+ windowMs: RESTART_WINDOW_MS,
8869
+ stderrTail: stderrTail.join(" | ")
8870
+ }
8871
+ });
8872
+ if (termination.reason === "spawn-error") {
8873
+ this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", {
8874
+ tags: { deviceId: this.deps.deviceId },
8875
+ meta: {
8818
8876
  outDir: this.cfg.outDir,
8819
8877
  errorCode: termination.errorCode,
8820
- error: termination.message
8821
- } });
8878
+ error: termination.message,
8879
+ permanent: termination.permanent
8880
+ }
8881
+ });
8882
+ if (termination.permanent) {
8883
+ this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", {
8884
+ tags: { deviceId: this.deps.deviceId },
8885
+ meta: {
8886
+ outDir: this.cfg.outDir,
8887
+ errorCode: termination.errorCode,
8888
+ error: termination.message
8889
+ }
8890
+ });
8822
8891
  this.stopped = true;
8823
8892
  this.deps.onGaveUp?.();
8824
8893
  return;
@@ -8826,25 +8895,51 @@ var SegmentWriter = class {
8826
8895
  this.deps.onResourcePressure?.();
8827
8896
  }
8828
8897
  if (this.restarts >= MAX_RESTARTS) {
8829
- this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
8830
- outDir: this.cfg.outDir,
8831
- reason: termination.reason,
8832
- code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8833
- stderrTail: stderrTail.join(" | ")
8834
- } });
8898
+ this.deps.logger.warn("SegmentWriter giving up after max restarts", {
8899
+ tags: { deviceId: this.deps.deviceId },
8900
+ meta: {
8901
+ outDir: this.cfg.outDir,
8902
+ reason: termination.reason,
8903
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8904
+ stderrTail: stderrTail.join(" | ")
8905
+ }
8906
+ });
8907
+ this.stopped = true;
8908
+ this.deps.onGaveUp?.();
8909
+ return;
8910
+ }
8911
+ if (restartsInWindow >= MAX_RESTARTS_IN_WINDOW) {
8912
+ this.deps.logger.warn("SegmentWriter giving up: restart storm", {
8913
+ tags: { deviceId: this.deps.deviceId },
8914
+ meta: {
8915
+ outDir: this.cfg.outDir,
8916
+ restartsInWindow,
8917
+ windowMs: RESTART_WINDOW_MS,
8918
+ budget: MAX_RESTARTS_IN_WINDOW,
8919
+ attempt: this.restarts,
8920
+ ranMs,
8921
+ reason: termination.reason,
8922
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8923
+ stderrTail: stderrTail.join(" | ")
8924
+ }
8925
+ });
8835
8926
  this.stopped = true;
8836
8927
  this.deps.onGaveUp?.();
8837
8928
  return;
8838
8929
  }
8839
8930
  this.restarts++;
8840
8931
  const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
8841
- this.deps.logger.info("SegmentWriter restarting", { meta: {
8842
- outDir: this.cfg.outDir,
8843
- attempt: this.restarts,
8844
- delayMs,
8845
- ranMs,
8846
- reason: termination.reason
8847
- } });
8932
+ this.deps.logger.info("SegmentWriter restarting", {
8933
+ tags: { deviceId: this.deps.deviceId },
8934
+ meta: {
8935
+ outDir: this.cfg.outDir,
8936
+ attempt: this.restarts,
8937
+ restartsInWindow,
8938
+ delayMs,
8939
+ ranMs,
8940
+ reason: termination.reason
8941
+ }
8942
+ });
8848
8943
  this.restartTimer = setTimeout(() => {
8849
8944
  this.restartTimer = null;
8850
8945
  this.start();
@@ -10108,6 +10203,13 @@ var RecordingController = class {
10108
10203
  }, {
10109
10204
  spawn: this.deps.spawn,
10110
10205
  logger: deviceLog,
10206
+ deviceId,
10207
+ claimCost: (pid) => this.deps.costRegistry?.claim({
10208
+ role: "recording",
10209
+ deviceId,
10210
+ unit: `${deviceId}/${profile}`,
10211
+ pid
10212
+ }) ?? NO_COST_CLAIM,
10111
10213
  onGaveUp: () => {
10112
10214
  this.recoverWriterGaveUp(deviceId, profile);
10113
10215
  },
@@ -10531,6 +10633,209 @@ function buildRecordingExportProvider(deps) {
10531
10633
  */
10532
10634
  var resolveRecordingHubHostname = resolveHubHostname;
10533
10635
  //#endregion
10636
+ //#region src/recorder/redundant-segments.ts
10637
+ /**
10638
+ * Return the indices (into `segs`) of segments safe to delete: those fully
10639
+ * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
10640
+ * ascending, then by end DESCENDING, guarantees the containing (longer) segment
10641
+ * is seen first and kept, and any shorter segment nested inside it is removed.
10642
+ */
10643
+ function selectRedundantSegments(segs) {
10644
+ if (segs.length < 2) return [];
10645
+ const order = segs.map((s, i) => ({
10646
+ start: s.startMs,
10647
+ end: s.startMs + s.durMs,
10648
+ i
10649
+ })).toSorted((a, b) => a.start - b.start || b.end - a.end);
10650
+ const remove = [];
10651
+ let coverStart = Number.NEGATIVE_INFINITY;
10652
+ let coverEnd = Number.NEGATIVE_INFINITY;
10653
+ for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
10654
+ else {
10655
+ coverStart = seg.start;
10656
+ coverEnd = seg.end;
10657
+ }
10658
+ return remove;
10659
+ }
10660
+ //#endregion
10661
+ //#region src/recorder/addon/redundancy-sweep.ts
10662
+ /**
10663
+ * Periodic redundancy sweep — the only thing that removes duplicated footage.
10664
+ *
10665
+ * WHY IT EXISTS AGAIN
10666
+ * ───────────────────
10667
+ * Every `SegmentWriter` restart re-dials the broker with `withRecordingIntent`,
10668
+ * and the broker answers a RECORDING dial with its pre-roll ring (≈10 s). So a
10669
+ * restart does not resume at the live edge: it re-writes ~10 s of media that is
10670
+ * already on disk. At the 2026-08-27 measurement — **1 814 restarts in 12 h** —
10671
+ * that is ~5 hours of duplicated `high`-profile footage per day, on a fuse
10672
+ * share that was already at 90 % full.
10673
+ *
10674
+ * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
10675
+ * from `runFullArchiveWalk`, which was reachable only from
10676
+ * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
10677
+ * (commit `9308428b4`) all three were deleted together as dead code. That was
10678
+ * correct about the walk and left the system with **no duplicate pruner
10679
+ * whatsoever**. The precedent for letting this class of waste run unattended is
10680
+ * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
10681
+ *
10682
+ * WHAT IS DIFFERENT FROM THE ONE THAT WAS DELETED
10683
+ * ───────────────────────────────────────────────
10684
+ * The old janitor ran once, after a walk of the WHOLE archive, and asked the
10685
+ * in-RAM index for `segments(deviceId)` — a full copy plus a sort of every row
10686
+ * the device owns. At the live shape (7.1 M rows) that copy alone was measured
10687
+ * at ~560 MB of transient heap, which is precisely the cost D248 had just
10688
+ * finished removing from the pressure sweep. Reconnecting it in that shape
10689
+ * would have traded one regression for another.
10690
+ *
10691
+ * So this one is bounded by a LOOKBACK WINDOW, not by the archive: duplicates
10692
+ * are made by a restart, restarts are now, and `segmentsStartingIn` slices the
10693
+ * sorted view instead of copying it. Cost grows with footage RECORDED in the
10694
+ * window — the invariant the recorder is supposed to satisfy — never with rows
10695
+ * already indexed.
10696
+ *
10697
+ * WHAT IT WILL NOT DO
10698
+ * ───────────────────
10699
+ * It never deletes a segment that contributes unique coverage:
10700
+ * {@link selectRedundantSegments} selects only segments fully contained inside
10701
+ * another segment that is KEPT, and staggered partial overlaps are always kept.
10702
+ * The delete itself is `SegmentStore.evict` — the same audited path
10703
+ * disk-pressure eviction uses, so a removed segment is de-indexed exactly as an
10704
+ * evicted one is. A failure for one (device, profile, location) is logged and
10705
+ * never aborts the pass.
10706
+ */
10707
+ /** The profiles a recorder writes. Same set `staging-reconcile` sweeps. */
10708
+ var SWEPT_PROFILES = [
10709
+ "high",
10710
+ "mid",
10711
+ "low"
10712
+ ];
10713
+ /** Group rows by the location that owns them. `evict` is per-location. */
10714
+ function byLocation(rows) {
10715
+ const out = /* @__PURE__ */ new Map();
10716
+ for (const row of rows) {
10717
+ const found = out.get(row.locationId);
10718
+ if (found) found.push(row);
10719
+ else out.set(row.locationId, [row]);
10720
+ }
10721
+ return out;
10722
+ }
10723
+ /**
10724
+ * Owns its timer and a single-flight guard, the same shape the retention sweep
10725
+ * and the export janitor already use — a slow pass is never overlapped by the
10726
+ * next tick.
10727
+ */
10728
+ var RedundancySweeper = class {
10729
+ deps;
10730
+ timer = null;
10731
+ sweeping = false;
10732
+ constructor(deps) {
10733
+ this.deps = deps;
10734
+ }
10735
+ /**
10736
+ * Arm the sweep on its interval. Idempotent.
10737
+ *
10738
+ * Deliberately NOT run immediately: boot is when the writers are attaching,
10739
+ * the staging reconcile is relocating thousands of files over shfs and the
10740
+ * first viewer is painting. A duplicate that has waited an hour can wait
10741
+ * another one; the write path cannot.
10742
+ */
10743
+ start() {
10744
+ if (this.timer !== null) return;
10745
+ this.timer = setInterval(() => {
10746
+ this.sweep();
10747
+ }, this.deps.intervalMs);
10748
+ this.timer.unref?.();
10749
+ }
10750
+ /** Stop the timer. Idempotent. An in-flight pass still completes. */
10751
+ stop() {
10752
+ if (this.timer !== null) {
10753
+ clearInterval(this.timer);
10754
+ this.timer = null;
10755
+ }
10756
+ }
10757
+ /**
10758
+ * One pass. Never throws: a device-list read failure aborts THIS pass
10759
+ * (logged), a per-location evict failure is logged and the pass continues.
10760
+ */
10761
+ async sweep() {
10762
+ const empty = {
10763
+ files: 0,
10764
+ bytes: 0,
10765
+ devices: 0
10766
+ };
10767
+ if (this.sweeping) {
10768
+ this.deps.logger.debug("recorder: redundancy sweep already running — skipping this tick");
10769
+ return empty;
10770
+ }
10771
+ this.sweeping = true;
10772
+ try {
10773
+ let deviceIds;
10774
+ try {
10775
+ deviceIds = await this.deps.deviceIds();
10776
+ } catch (err) {
10777
+ this.deps.logger.warn("recorder: redundancy sweep could not read the device list", { meta: { error: errMsg(err) } });
10778
+ return empty;
10779
+ }
10780
+ const toMs = (this.deps.now ?? Date.now)();
10781
+ const fromMs = toMs - this.deps.lookbackMs;
10782
+ let files = 0;
10783
+ let bytes = 0;
10784
+ let devices = 0;
10785
+ for (const deviceId of deviceIds) {
10786
+ let removedHere = 0;
10787
+ for (const profile of SWEPT_PROFILES) {
10788
+ const rows = this.deps.segmentsInWindow(deviceId, profile, fromMs, toMs);
10789
+ const redundant = selectRedundantSegments(rows);
10790
+ if (redundant.length === 0) continue;
10791
+ const victims = redundant.map((i) => rows[i]).filter((row) => row !== void 0);
10792
+ for (const [locationId, group] of byLocation(victims)) try {
10793
+ const reclaimed = await this.deps.evict(locationId, group);
10794
+ files += group.length;
10795
+ bytes += reclaimed;
10796
+ removedHere += group.length;
10797
+ } catch (err) {
10798
+ this.deps.logger.warn("recorder: redundancy sweep could not evict duplicates", {
10799
+ tags: { deviceId },
10800
+ meta: {
10801
+ profile,
10802
+ locationId,
10803
+ count: group.length,
10804
+ error: errMsg(err)
10805
+ }
10806
+ });
10807
+ }
10808
+ }
10809
+ if (removedHere > 0) {
10810
+ devices += 1;
10811
+ this.deps.logger.info("recorder: removed duplicated segments for device", {
10812
+ tags: { deviceId },
10813
+ meta: {
10814
+ files: removedHere,
10815
+ fromMs,
10816
+ toMs
10817
+ }
10818
+ });
10819
+ }
10820
+ }
10821
+ this.deps.logger.info("recorder: redundancy sweep complete", { meta: {
10822
+ devices,
10823
+ files,
10824
+ bytes,
10825
+ lookbackMs: this.deps.lookbackMs,
10826
+ deviceCount: deviceIds.length
10827
+ } });
10828
+ return {
10829
+ files,
10830
+ bytes,
10831
+ devices
10832
+ };
10833
+ } finally {
10834
+ this.sweeping = false;
10835
+ }
10836
+ }
10837
+ };
10838
+ //#endregion
10534
10839
  //#region src/recorder/addon/retention-sweep.ts
10535
10840
  /**
10536
10841
  * Periodic footage-retention sweep (recording-spec §6).
@@ -12091,6 +12396,18 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
12091
12396
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
12092
12397
  * the recording-spec §6 periodic retention. */
12093
12398
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
12399
+ /** How often the redundancy sweep removes footage a writer restart duplicated. */
12400
+ var REDUNDANCY_SWEEP_INTERVAL_MS = 60 * 6e4;
12401
+ /**
12402
+ * How far back each redundancy pass looks.
12403
+ *
12404
+ * Twice the interval, so a pass that is skipped (single-flight) or delayed by a
12405
+ * slow predecessor still covers the window its predecessor was supposed to.
12406
+ * It is deliberately NOT "the whole archive": duplicates are made by a writer
12407
+ * restart, restarts are recent, and a full-archive selection is the ~560 MB
12408
+ * transient copy D248 removed. See `redundancy-sweep.ts`.
12409
+ */
12410
+ var REDUNDANCY_SWEEP_LOOKBACK_MS = 2 * REDUNDANCY_SWEEP_INTERVAL_MS;
12094
12411
  /** What the per-volume usage row reports when the hour ledger is unavailable:
12095
12412
  * nothing measured, rather than a total counted off a partial RAM index. */
12096
12413
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -12116,6 +12433,16 @@ function isSegmentStoreLogExtras(extras) {
12116
12433
  }
12117
12434
  var RecorderV2Addon = class extends BaseAddon {
12118
12435
  nodeId = "hub";
12436
+ /**
12437
+ * The children this addon started, claimed at the spawn site and released at
12438
+ * their exit. It is what `load-contribution` reports: the recorder knows it
12439
+ * ran an ffmpeg for camera 615 profile `high`, so nobody else has to work it
12440
+ * out from a process table.
12441
+ *
12442
+ * One registry per PROCESS, and it dies with the process — which is correct,
12443
+ * because those children were this process's children.
12444
+ */
12445
+ costRegistry = new ChildCostRegistry();
12119
12446
  /** True when THIS node is the designated recording node (`recordingNodeId`).
12120
12447
  * Every other node stays fully inert — registers no providers, serves no
12121
12448
  * playback, runs no controller. Resolved in `onInitialize`. */
@@ -12243,6 +12570,9 @@ var RecorderV2Addon = class extends BaseAddon {
12243
12570
  /** Periodic footage-retention sweep (owns its timer + single-flight guard).
12244
12571
  * Constructed + started in `onHubReachable`, stopped on shutdown. */
12245
12572
  retentionSweeper = null;
12573
+ /** Periodic duplicate-footage sweep — the ONLY pruner of media a writer
12574
+ * restart re-wrote. Constructed + started in `onHubReachable`. */
12575
+ redundancySweeper = null;
12246
12576
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
12247
12577
  storageMigrationLeaseId = null;
12248
12578
  /**
@@ -12459,6 +12789,7 @@ var RecorderV2Addon = class extends BaseAddon {
12459
12789
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
12460
12790
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
12461
12791
  spawn,
12792
+ costRegistry: this.costRegistry,
12462
12793
  segmentSeconds: this.config.segmentSeconds,
12463
12794
  watchIntervalMs: this.config.watchIntervalMs,
12464
12795
  ...consumerHostname !== void 0 ? { consumerHostname } : {},
@@ -12707,6 +13038,10 @@ var RecorderV2Addon = class extends BaseAddon {
12707
13038
  {
12708
13039
  capability: recordingExportCapability,
12709
13040
  provider: exportProvider
13041
+ },
13042
+ {
13043
+ capability: loadContributionCapability,
13044
+ provider: { list: () => this.costRegistry.contributions() }
12710
13045
  }
12711
13046
  ],
12712
13047
  customActions: recorderDebugActions,
@@ -12731,6 +13066,8 @@ var RecorderV2Addon = class extends BaseAddon {
12731
13066
  }
12732
13067
  this.retentionSweeper?.stop();
12733
13068
  this.retentionSweeper = null;
13069
+ this.redundancySweeper?.stop();
13070
+ this.redundancySweeper = null;
12734
13071
  this.exportEngine = null;
12735
13072
  this.recordingProvider = null;
12736
13073
  }
@@ -12870,6 +13207,16 @@ var RecorderV2Addon = class extends BaseAddon {
12870
13207
  intervalMs: RETENTION_SWEEP_INTERVAL_MS
12871
13208
  });
12872
13209
  this.retentionSweeper?.start();
13210
+ const store = this.segmentStore;
13211
+ if (store && this.redundancySweeper === null) this.redundancySweeper = new RedundancySweeper({
13212
+ deviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
13213
+ segmentsInWindow: (deviceId, profile, fromMs, toMs) => this.index.segmentsStartingIn(deviceId, profile, fromMs, toMs),
13214
+ evict: (locationId, rows) => store.evict(locationId, rows),
13215
+ logger: this.ctx.logger,
13216
+ intervalMs: REDUNDANCY_SWEEP_INTERVAL_MS,
13217
+ lookbackMs: REDUNDANCY_SWEEP_LOOKBACK_MS
13218
+ });
13219
+ this.redundancySweeper?.start();
12873
13220
  }
12874
13221
  /**
12875
13222
  * Rebuild the sensor trigger's reverse index from the persisted configs.
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-BzLiQ8Ue.js");
1
+ const require_dist = require("./dist-4EMKvxCp.js");
2
2
  let node_child_process = require("node:child_process");
3
3
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
4
4
  var require_mp4box_all = /* @__PURE__ */ require_dist.__commonJSMin(((exports) => {
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./chunk-DnnnRqeS.mjs";
2
- import { St as errMsg } from "./dist-C1dXLLQq.mjs";
2
+ import { Ct as errMsg } from "./dist-g0Or5_pR.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_worker_protocol = require("../worker-protocol-DvTZBvh2.js");
3
- const require_lazy_sharp = require("../lazy-sharp-DxWmhTqk.js");
2
+ const require_worker_protocol = require("../worker-protocol-BLfaD5uC.js");
3
+ const require_lazy_sharp = require("../lazy-sharp-BlA9PxWk.js");
4
4
  //#region src/session-decode/color-conversion.ts
5
5
  /**
6
6
  * Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
@@ -1,5 +1,5 @@
1
- import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-DxsVx0G7.mjs";
2
- import { n as setSharpWarnSink, r as hostExternalEntryUrls, t as getSharp } from "../lazy-sharp-BtT5x-qP.mjs";
1
+ import { i as formatNativeLeaseKnobs, n as isWorkerRequest, o as resolveNativeLeaseKnobs, r as logLevelForLine } from "../worker-protocol-vc8WWhjx.mjs";
2
+ import { n as setSharpWarnSink, r as hostExternalEntryUrls, t as getSharp } from "../lazy-sharp-DXsqwpph.mjs";
3
3
  //#region src/session-decode/color-conversion.ts
4
4
  /**
5
5
  * Explicit YUV→RGB colorspace/range resolution for the session-decode worker.
@@ -1,7 +1,7 @@
1
- import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs";
1
+ import { a as e, c as t, d as n, f as r, i, l as a, n as o, o as s, p as c, r as l, s as u, t as d, u as f } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BrTM_tsb.mjs";
2
2
  import { a as p, i as m, n as h, o as g, r as _, t as v } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react__loadShare__.js-C9j-2lBe.mjs";
3
3
  import { n as y, r as b, t as x } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-XO0-Pyu6.mjs";
4
- import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs";
4
+ import { t as S } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQyiEd0l.mjs";
5
5
  import { n as C, t as w } from "./_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.js-BO7TIbJV.mjs";
6
6
  //#region ../../node_modules/lucide-react/dist/esm/shared/src/utils.js
7
7
  var T = (e) => e.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), E = (e) => e.replace(/^([A-Z])|[\s-_]+(\w)/g, (e, t, n) => n ? n.toUpperCase() : t.toLowerCase()), D = (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.2.37",
6
+ version: "1.2.39",
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.2.115",
21
+ version: "1.2.117",
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.2.78",
36
+ version: "1.2.80",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_stream_broker_widgets",