@camstack/addon-pipeline 1.2.215 → 1.2.218

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 (125) 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-AJyhoTLF.mjs} +1 -1
  4. package/dist/{default-detection-model-f0PImZnh.js → default-detection-model-Bf45EQ98.js} +1 -1
  5. package/dist/detection-pipeline/index.js +5 -5
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-DoHcimdu.js → dist-BbvtiBNc.js} +278 -20
  8. package/dist/{dist-DttqKvsQ.mjs → dist-Dl6GntEh.mjs} +278 -20
  9. package/dist/{lazy-sharp-BeeQGCUu.js → lazy-sharp-L9Lcr62v.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-CEyw52D2.mjs} +1 -1
  13. package/dist/{node-6uF72wri.js → node-CbCVHEvJ.js} +1 -1
  14. package/dist/pipeline-runner/index.js +546 -25
  15. package/dist/pipeline-runner/index.mjs +545 -24
  16. package/dist/{process-memory-BRdyl5fE.js → process-memory-DBxdiCSF.js} +1 -1
  17. package/dist/{process-memory-CddpCDWL.mjs → process-memory-Du00R6-e.mjs} +1 -1
  18. package/dist/recorder/index.js +238 -52
  19. package/dist/recorder/index.mjs +238 -52
  20. package/dist/{segment-demux-js-CsFtPVzH.mjs → segment-demux-js-B278aBtB.mjs} +1 -1
  21. package/dist/{segment-demux-js-CkJPdyDH.js → segment-demux-js-D7bjzY8M.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +58 -45
  23. package/dist/session-decode/decode-worker-child.mjs +57 -44
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZIja2AOe.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BnmbfnAd.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-DpatMS4g.mjs → hostInit-MIKQqs86.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-BKwkLZWT.mjs → worker-protocol-BEbD5adq.mjs} +1 -1
  35. package/dist/{worker-protocol-BWO2WNjw.js → worker-protocol-C4rh5uiS.js} +1 -1
  36. package/package.json +1 -1
  37. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  39. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_backpressure.cpython-312-pytest-9.1.1.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_backpressure.cpython-312.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312-pytest-9.1.1.pyc +0 -0
  44. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-312.pyc +0 -0
  45. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  46. package/python/__pycache__/test_inference_pool_deadline.cpython-314.pyc +0 -0
  47. package/python/__pycache__/test_inference_pool_device_selection.cpython-312-pytest-9.1.1.pyc +0 -0
  48. package/python/__pycache__/test_inference_pool_device_selection.cpython-312.pyc +0 -0
  49. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  50. package/python/__pycache__/test_inference_pool_layout.cpython-312-pytest-9.1.1.pyc +0 -0
  51. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  52. package/python/__pycache__/test_inference_pool_memstats.cpython-312-pytest-9.1.1.pyc +0 -0
  53. package/python/__pycache__/test_inference_pool_memstats.cpython-312.pyc +0 -0
  54. package/python/__pycache__/test_inference_pool_memstats.cpython-314.pyc +0 -0
  55. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312-pytest-9.1.1.pyc +0 -0
  56. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-312.pyc +0 -0
  57. package/python/__pycache__/test_inference_pool_ov_ppp.cpython-314.pyc +0 -0
  58. package/python/__pycache__/test_inference_pool_preprocess.cpython-312-pytest-9.1.1.pyc +0 -0
  59. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  60. package/python/__pycache__/test_inference_pool_reconfigure.cpython-314.pyc +0 -0
  61. package/python/__pycache__/test_inference_pool_static_batch.cpython-312-pytest-9.1.1.pyc +0 -0
  62. package/python/__pycache__/test_inference_pool_static_batch.cpython-312.pyc +0 -0
  63. package/python/__pycache__/test_inference_pool_static_batch.cpython-314.pyc +0 -0
  64. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  65. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  66. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  67. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  68. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  70. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  71. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  72. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  73. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  74. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  75. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  76. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  77. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  78. package/python/postprocessors/__pycache__/plate_slots.cpython-312.pyc +0 -0
  79. package/python/postprocessors/__pycache__/plate_slots.cpython-314.pyc +0 -0
  80. package/python/postprocessors/__pycache__/rfdetr.cpython-312.pyc +0 -0
  81. package/python/postprocessors/__pycache__/rfdetr.cpython-314.pyc +0 -0
  82. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  83. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  84. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  85. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  86. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  87. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  88. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  91. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  92. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  93. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  94. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  95. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  96. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  97. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  98. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  100. package/python/postprocessors/__pycache__/test_ctc.cpython-314.pyc +0 -0
  101. package/python/postprocessors/__pycache__/test_plate_slots.cpython-312-pytest-9.1.1.pyc +0 -0
  102. package/python/postprocessors/__pycache__/test_rfdetr.cpython-312-pytest-9.1.1.pyc +0 -0
  103. package/python/postprocessors/__pycache__/test_rfdetr.cpython-314.pyc +0 -0
  104. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  105. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/python/postprocessors/__pycache__/test_saliency.cpython-314.pyc +0 -0
  107. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  108. package/python/postprocessors/__pycache__/test_softmax.cpython-314.pyc +0 -0
  109. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  110. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-314.pyc +0 -0
  111. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  112. package/python/postprocessors/__pycache__/test_yolonas.cpython-312-pytest-9.1.1.pyc +0 -0
  113. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  114. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  115. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  116. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  117. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  118. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  119. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  120. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  121. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  122. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
  123. package/python/postprocessors/__pycache__/yolonas.cpython-312.pyc +0 -0
  124. package/python/postprocessors/__pycache__/yolonas.cpython-314.pyc +0 -0
  125. 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
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DoHcimdu.js");
1
+ const require_dist = require("./dist-BbvtiBNc.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs);
4
4
  let node_child_process = require("node:child_process");
@@ -1,4 +1,4 @@
1
- import { mt as parseProcStatus } from "./dist-DttqKvsQ.mjs";
1
+ import { mt as parseProcStatus } from "./dist-Dl6GntEh.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile } from "node:child_process";
4
4
  //#region src/shared/node-topology-platform.ts
@@ -2,8 +2,8 @@ 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");
5
+ const require_dist = require("../dist-BbvtiBNc.js");
6
+ const require_node = require("../node-CbCVHEvJ.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
8
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
9
9
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -1333,27 +1333,6 @@ var RecordingIndex = class {
1333
1333
  return [...out.values()];
1334
1334
  }
1335
1335
  /**
1336
- * WHICH devices hold footage on any of `locationIds` — the census, without
1337
- * the archive.
1338
- *
1339
- * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1340
- * location: a full multi-device COPY of every row on the disk plus a sort of
1341
- * it (294 k rows on the live hub), to read one number off each row and throw
1342
- * the array away. This visits each device only until its first matching row.
1343
- * Same membership rule as before — a device is listed when at least one of
1344
- * its rows sits on one of the given locations, so footage stranded on a
1345
- * location id that no longer resolves stays invisible here exactly as it was.
1346
- * @residency archive-shaped
1347
- */
1348
- deviceIdsOnLocations(locationIds) {
1349
- const out = /* @__PURE__ */ new Set();
1350
- for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1351
- out.add(deviceId);
1352
- break;
1353
- }
1354
- return out;
1355
- }
1356
- /**
1357
1336
  * Every RESIDENT segment on a storage location, across devices, oldest-first.
1358
1337
  *
1359
1338
  * ## The one that bit (D295)
@@ -3838,6 +3817,40 @@ async function segmentsOfHour(root, deviceId, profile, hourStartMs) {
3838
3817
  * the directory route serve the profile the client asked for when it exists,
3839
3818
  * and fall back to the availability pick when it does not.
3840
3819
  */
3820
+ /**
3821
+ * EVERY profile this camera holds footage in, across every root.
3822
+ *
3823
+ * {@link availabilityProfile} deliberately returns ONE — the timeline is a
3824
+ * single bar. That is right for the bar and wrong for the ANSWER: on
3825
+ * 2026-09-10 `getAvailability` reported twenty `high` ranges for camera 615
3826
+ * and the conclusion drawn was that this deployment records no `low` at all,
3827
+ * while `/media-fast/recordingsLow/615/low/` held 14 GB on a different
3828
+ * location (D433).
3829
+ *
3830
+ * Unions across roots for exactly that reason: `high` and `low` do not share a
3831
+ * location here, so a per-root answer sees one of them.
3832
+ *
3833
+ * Non-emptiness, not existence — the same rule {@link availabilityProfile}
3834
+ * keeps, so a directory left behind by a config change is not offered. And
3835
+ * only {@link KNOWN_PROFILES}: the camera dir also hosts `events/` and the
3836
+ * retired `strips/` trees, which a walker that trusts readdir would present as
3837
+ * playable.
3838
+ *
3839
+ * `list` is injectable for the test; production passes the module's own
3840
+ * directory read.
3841
+ */
3842
+ async function profilesWithFootage(roots, deviceId, list = listDir) {
3843
+ const found = /* @__PURE__ */ new Set();
3844
+ for (const root of roots) {
3845
+ const entries = await list(node_path.default.join(root, String(deviceId)));
3846
+ for (const entry of entries) {
3847
+ if (!KNOWN_PROFILES$1.has(entry)) continue;
3848
+ if (found.has(entry)) continue;
3849
+ if ((await list(node_path.default.join(root, String(deviceId), entry))).length > 0) found.add(entry);
3850
+ }
3851
+ }
3852
+ return [...found].sort();
3853
+ }
3841
3854
  async function profileHasFootage(root, deviceId, profile) {
3842
3855
  return (await listDir(node_path.default.join(root, String(deviceId), profile))).length > 0;
3843
3856
  }
@@ -3959,6 +3972,8 @@ var CalendarIndex = class {
3959
3972
  segsReadAt = /* @__PURE__ */ new Map();
3960
3973
  /** The profile availability reads from, resolved once per device. */
3961
3974
  profileFor = /* @__PURE__ */ new Map();
3975
+ profilesFor = /* @__PURE__ */ new Map();
3976
+ profilesInFlight = /* @__PURE__ */ new Map();
3962
3977
  /** `deviceId:profile` → whether the device records that profile, resolved
3963
3978
  * once. Same stability trade as {@link profileFor}: a config change that
3964
3979
  * adds a profile mid-session is picked up on restart, not on a timer. */
@@ -4241,6 +4256,31 @@ var CalendarIndex = class {
4241
4256
  * reads at a time and they ALL ask for the profile before the first answer
4242
4257
  * lands, so a cache alone let the pick run eight times. Parallelism found it.
4243
4258
  */
4259
+ /**
4260
+ * Every profile this camera holds footage in, across every root — cached
4261
+ * like the pick, because it is the same shallow read (D433).
4262
+ *
4263
+ * {@link availabilityProfileFor} answers with ONE; this says what the one
4264
+ * was chosen FROM, so a caller cannot mistake the timeline's single bar for
4265
+ * the whole archive.
4266
+ */
4267
+ async profilesWithFootageFor(deviceId) {
4268
+ const cached = this.profilesFor.get(deviceId);
4269
+ if (cached !== void 0) return cached;
4270
+ const running = this.profilesInFlight.get(deviceId);
4271
+ if (running) return running;
4272
+ const resolve = (async () => {
4273
+ const found = await profilesWithFootage(this.deps.roots(), deviceId);
4274
+ this.profilesFor.set(deviceId, found);
4275
+ return found;
4276
+ })();
4277
+ this.profilesInFlight.set(deviceId, resolve);
4278
+ try {
4279
+ return await resolve;
4280
+ } finally {
4281
+ if (this.profilesInFlight.get(deviceId) === resolve) this.profilesInFlight.delete(deviceId);
4282
+ }
4283
+ }
4244
4284
  async availabilityProfileFor(deviceId) {
4245
4285
  const cached = this.profileFor.get(deviceId);
4246
4286
  if (cached !== void 0) return cached;
@@ -5885,6 +5925,8 @@ var TIME_RE = /time=(\d+):(\d+):(\d+(?:\.\d+)?)/;
5885
5925
  * whatever ffmpeg build is installed.
5886
5926
  */
5887
5927
  var EXPORT_MIN_PLAUSIBLE_BYTES = 1024;
5928
+ /** ffmpeg's own announcement of that pass — the only signal it gives. */
5929
+ var FASTSTART_RE = /Starting second pass: moving the moov atom/i;
5888
5930
  /** Expected OUTPUT duration (seconds) for progress scaling. */
5889
5931
  function expectedOutSeconds(rec) {
5890
5932
  const rangeSec = Math.max(.001, (rec.toMs - rec.fromMs) / 1e3);
@@ -6194,6 +6236,7 @@ var ExportEngine = class {
6194
6236
  args
6195
6237
  } });
6196
6238
  const stallMs = this.deps.stallMs ?? 9e4;
6239
+ const faststartStallMs = this.deps.faststartStallMs ?? 9e5;
6197
6240
  return new Promise((resolve) => {
6198
6241
  const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
6199
6242
  this.active.set(id, proc);
@@ -6205,14 +6248,16 @@ var ExportEngine = class {
6205
6248
  stallTimer = null;
6206
6249
  }
6207
6250
  };
6251
+ let budgetMs = stallMs;
6208
6252
  const armStall = () => {
6209
6253
  clearStall();
6254
+ const armedFor = budgetMs;
6210
6255
  stallTimer = setTimeout(() => {
6211
- stderrTail.push(`ffmpeg stalled: no progress for ${stallMs}ms`);
6256
+ stderrTail.push(`ffmpeg stalled: no progress for ${armedFor}ms`);
6212
6257
  try {
6213
6258
  proc.kill("SIGKILL");
6214
6259
  } catch {}
6215
- }, stallMs);
6260
+ }, armedFor);
6216
6261
  };
6217
6262
  armStall();
6218
6263
  proc.stderr?.on("data", (chunk) => {
@@ -6223,6 +6268,10 @@ var ExportEngine = class {
6223
6268
  stderrTail.push(trimmed);
6224
6269
  if (stderrTail.length > STDERR_TAIL_LINES$1) stderrTail.shift();
6225
6270
  }
6271
+ if (FASTSTART_RE.test(line)) {
6272
+ budgetMs = faststartStallMs;
6273
+ armStall();
6274
+ }
6226
6275
  const m = TIME_RE.exec(line);
6227
6276
  if (m) {
6228
6277
  armStall();
@@ -6866,6 +6915,81 @@ function buildRecordingDeviceSchema() {
6866
6915
  }]
6867
6916
  }] };
6868
6917
  }
6918
+ //#endregion
6919
+ //#region src/recorder/addon/device-storage-usage.ts
6920
+ /**
6921
+ * Gap between the per-camera sum and the per-volume total that is worth
6922
+ * saying out loud, as a share of the volume total.
6923
+ *
6924
+ * The two are aggregations of the SAME hour rows over the same location set —
6925
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
6926
+ * agree to the byte. A few tenths of a percent is a write landing between the
6927
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
6928
+ * rebuilt somewhere new.
6929
+ */
6930
+ var USAGE_MISMATCH_SHARE = .02;
6931
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
6932
+ * warn on a single in-flight segment. */
6933
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
6934
+ /**
6935
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
6936
+ * first (deviceId breaks a tie, so two consecutive calls compare).
6937
+ *
6938
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
6939
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
6940
+ * the same question with the wrong quantity and look exactly like a correct
6941
+ * answer, which is how this shipped in the first place.
6942
+ */
6943
+ function buildDeviceUsage(input) {
6944
+ if (input.archive === null) {
6945
+ input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
6946
+ return {
6947
+ devices: [],
6948
+ totalUsedBytes: 0,
6949
+ read: "unavailable"
6950
+ };
6951
+ }
6952
+ const devices = [];
6953
+ let totalUsedBytes = 0;
6954
+ for (const [deviceId, archived] of input.archive) {
6955
+ const resident = input.resident(deviceId);
6956
+ totalUsedBytes += archived.bytes;
6957
+ devices.push({
6958
+ deviceId,
6959
+ usedBytes: archived.bytes,
6960
+ oldestMs: archived.oldestMs,
6961
+ segments: archived.segments,
6962
+ residentBytes: resident.bytes,
6963
+ residentOldestMs: resident.oldestMs
6964
+ });
6965
+ }
6966
+ return {
6967
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
6968
+ totalUsedBytes,
6969
+ read: "archive"
6970
+ };
6971
+ }
6972
+ /**
6973
+ * Say it when the per-camera sum and the per-volume total disagree.
6974
+ *
6975
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
6976
+ * the same rows counted two ways, so a gap means one of the two reads saw a
6977
+ * fraction of the archive — and the whole point of D418 is that a page which
6978
+ * shows both without comparing them lets a 97% gap read as a fact about the
6979
+ * disks.
6980
+ */
6981
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
6982
+ if (read !== "archive") return;
6983
+ if (volumeBytes === 0) return;
6984
+ const gap = Math.abs(volumeBytes - deviceBytes);
6985
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
6986
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
6987
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
6988
+ deviceBytes,
6989
+ volumeBytes,
6990
+ gap
6991
+ } });
6992
+ }
6869
6993
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
6870
6994
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
6871
6995
  function sourceKey(source) {
@@ -7325,13 +7449,15 @@ function buildRecordingProvider(deps) {
7325
7449
  * and by the batch alike — the batch collapses the TRANSPORT, and sharing the
7326
7450
  * function is what makes that literally true instead of aspirational.
7327
7451
  */
7328
- const availabilityFor = async (deviceId, fromMs, toMs) => {
7452
+ const availabilityFor = async (deviceId, fromMs, toMs, requestedProfile) => {
7329
7453
  const calendar = deps.calendar;
7330
7454
  if (calendar) {
7331
- const profile = await calendar.availabilityProfileFor(deviceId);
7455
+ const profilesWithFootage = await calendar.profilesWithFootageFor(deviceId);
7456
+ const profile = await calendar.servedProfileFor(deviceId, requestedProfile);
7332
7457
  if (profile === null) return {
7333
7458
  deviceId,
7334
- ranges: []
7459
+ ranges: [],
7460
+ profilesWithFootage: [...profilesWithFootage]
7335
7461
  };
7336
7462
  const ranges = [];
7337
7463
  for (const r of await calendar.rangesIn(deviceId, profile, fromMs, toMs)) ranges.push({
@@ -7341,16 +7467,20 @@ function buildRecordingProvider(deps) {
7341
7467
  });
7342
7468
  return {
7343
7469
  deviceId,
7344
- ranges
7470
+ ranges,
7471
+ profilesWithFootage: [...profilesWithFootage]
7345
7472
  };
7346
7473
  }
7474
+ const profiles = new Set(deps.index.segments(deviceId).map((s) => s.profile));
7475
+ const asked = requestedProfile !== void 0 && profiles.has(requestedProfile);
7347
7476
  return {
7348
7477
  deviceId,
7349
- ranges: [...new Set(deps.index.segments(deviceId).map((s) => s.profile))].flatMap((profile) => deps.index.ranges(deviceId, profile, fromMs, toMs, RANGE_MERGE_GAP_MS).map((r) => ({
7478
+ ranges: [...profiles].filter((p) => !asked || p === requestedProfile).flatMap((profile) => deps.index.ranges(deviceId, profile, fromMs, toMs, RANGE_MERGE_GAP_MS).map((r) => ({
7350
7479
  profile,
7351
7480
  startMs: r.startMs,
7352
7481
  endMs: r.endMs
7353
- })))
7482
+ }))),
7483
+ profilesWithFootage: [...profiles].sort()
7354
7484
  };
7355
7485
  };
7356
7486
  /** The per-camera day read. Same rule as {@link availabilityFor}: one body. */
@@ -7380,7 +7510,7 @@ function buildRecordingProvider(deps) {
7380
7510
  getStatus: async ({ deviceId }) => {
7381
7511
  return statusFor(deps, deviceId, await loadDeviceConfig(deps.configStore, deviceId));
7382
7512
  },
7383
- getAvailability: ({ deviceId, fromMs, toMs }) => availabilityFor(deviceId, fromMs, toMs),
7513
+ getAvailability: ({ deviceId, fromMs, toMs, profile }) => availabilityFor(deviceId, fromMs, toMs, profile),
7384
7514
  /**
7385
7515
  * `getAvailability` for a SET of cameras. Same body per camera, run
7386
7516
  * concurrently; only the transport collapses.
@@ -7394,11 +7524,12 @@ function buildRecordingProvider(deps) {
7394
7524
  refuseOverCap("getAvailabilityBatch", deviceIds.length);
7395
7525
  return Promise.all(deviceIds.map(async (deviceId) => {
7396
7526
  try {
7397
- const { ranges } = await availabilityFor(deviceId, fromMs, toMs);
7527
+ const { ranges, profilesWithFootage } = await availabilityFor(deviceId, fromMs, toMs);
7398
7528
  return {
7399
7529
  deviceId,
7400
7530
  read: "read",
7401
- ranges
7531
+ ranges,
7532
+ profilesWithFootage
7402
7533
  };
7403
7534
  } catch (err) {
7404
7535
  deps.logger.warn("recording availability could not be read", {
@@ -7412,7 +7543,8 @@ function buildRecordingProvider(deps) {
7412
7543
  return {
7413
7544
  deviceId,
7414
7545
  read: "unreadable",
7415
- ranges: []
7546
+ ranges: [],
7547
+ profilesWithFootage: []
7416
7548
  };
7417
7549
  }
7418
7550
  }));
@@ -7453,18 +7585,15 @@ function buildRecordingProvider(deps) {
7453
7585
  getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs),
7454
7586
  getStorageUsage: async () => {
7455
7587
  const locations = deps.locations();
7456
- const devices = [];
7457
- const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
7458
- let totalUsedBytes = 0;
7459
- for (const deviceId of seenDevices) {
7460
- const accounting = deps.index.accounting(deviceId);
7461
- totalUsedBytes += accounting.bytes;
7462
- devices.push({
7463
- deviceId,
7464
- usedBytes: accounting.bytes,
7465
- oldestMs: accounting.oldestMs
7466
- });
7467
- }
7588
+ const locationIds = new Set(locations.map((l) => l.id));
7589
+ const deviceUsage = buildDeviceUsage({
7590
+ locationIds,
7591
+ archive: deps.archiveDeviceUsage?.(locationIds) ?? null,
7592
+ resident: (deviceId) => deps.index.accounting(deviceId),
7593
+ logger: deps.logger
7594
+ });
7595
+ const devices = deviceUsage.devices;
7596
+ const totalUsedBytes = deviceUsage.totalUsedBytes;
7468
7597
  const byPhysical = /* @__PURE__ */ new Map();
7469
7598
  for (const loc of locations) {
7470
7599
  const key = physicalRootKey(loc.root);
@@ -7493,11 +7622,12 @@ function buildRecordingProvider(deps) {
7493
7622
  totalBytes: cap.totalBytes
7494
7623
  };
7495
7624
  }));
7496
- devices.sort((a, b) => b.usedBytes - a.usedBytes);
7625
+ warnOnUsageMismatch(deps.logger, totalUsedBytes, locationUsages.reduce((sum, l) => sum + l.usedBytes, 0), deviceUsage.read);
7497
7626
  return {
7498
7627
  nodeId: deps.nodeId,
7499
7628
  totalUsedBytes,
7500
- devices,
7629
+ devices: [...devices],
7630
+ devicesRead: deviceUsage.read,
7501
7631
  locations: locationUsages
7502
7632
  };
7503
7633
  },
@@ -8391,6 +8521,14 @@ var DurableLedger = class DurableLedger {
8391
8521
  //#endregion
8392
8522
  //#region src/recorder/addon/segment-hour-ledger.ts
8393
8523
  var HOUR_MS$3 = 36e5;
8524
+ /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8525
+ function minOf(a, b) {
8526
+ return a === null ? b : Math.min(a, b);
8527
+ }
8528
+ /** `Math.max` with the same rule as {@link minOf}. */
8529
+ function maxOf(a, b) {
8530
+ return a === null ? b : Math.max(a, b);
8531
+ }
8394
8532
  var macrotask$1 = () => new Promise((resolve) => {
8395
8533
  setImmediate(resolve);
8396
8534
  });
@@ -9041,7 +9179,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9041
9179
  *
9042
9180
  * The reader repairs the durable index it just reconstructed, so the next
9043
9181
  * cold read of this hour is a point read instead of another `readdir`
9044
- * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
9182
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9045
9183
  * constraints, both enforced by the caller and re-stated here because
9046
9184
  * getting either wrong turns a READ into durable damage: the hour must be
9047
9185
  * CLOSED (a live hour's directory races the writer, so a walk of it is a
@@ -9274,6 +9412,53 @@ var SegmentHourLedger = class SegmentHourLedger {
9274
9412
  for (const row of this.ledger.snapshot()) if (locationIds.has(row.locationId)) out.add(row.deviceId);
9275
9413
  return out;
9276
9414
  }
9415
+ /**
9416
+ * Bytes / segments / oldest / newest per CAMERA on these locations — the
9417
+ * per-camera half of the storage page, answered by the ARCHIVE (D418).
9418
+ *
9419
+ * ## Why the T1 fold and not `census({ deviceId })` per camera
9420
+ *
9421
+ * `census` is the right authority for a SCOPE, and it cannot express this
9422
+ * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
9423
+ * "camera 615 across the three resolvable volumes" is three aggregates, and
9424
+ * `census({ deviceId })` alone would silently count footage stranded on a
9425
+ * location id that no longer resolves — bytes no volume row on the same page
9426
+ * accounts for. Per camera per volume that is `cameras × volumes` SQL
9427
+ * aggregates for one operator click, and it grows with the fleet.
9428
+ *
9429
+ * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9430
+ * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9431
+ * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9432
+ * is write-behind, so it is never BEHIND the store. The cost is one pass
9433
+ * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9434
+ * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9435
+ * i.e. it does NOT scale with the segment count — and it is capped for good
9436
+ * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9437
+ *
9438
+ * ## What an empty answer means
9439
+ *
9440
+ * The same thing it means for {@link devicesWithFootage}: "the mirror knows
9441
+ * no hour here" — which is also what a ledger whose load failed reports. The
9442
+ * caller must not render that as "no camera has footage"; `getStorageUsage`
9443
+ * cross-checks the per-camera sum against the volume aggregate and SAYS so
9444
+ * when the two disagree.
9445
+ */
9446
+ usageByDevice(locationIds) {
9447
+ const out = /* @__PURE__ */ new Map();
9448
+ if (locationIds.size === 0) return out;
9449
+ for (const row of this.ledger.snapshot()) {
9450
+ if (!locationIds.has(row.locationId)) continue;
9451
+ const empty = row.segments === 0;
9452
+ const prev = out.get(row.deviceId);
9453
+ out.set(row.deviceId, {
9454
+ bytes: (prev?.bytes ?? 0) + row.bytes,
9455
+ segments: (prev?.segments ?? 0) + row.segments,
9456
+ oldestMs: empty ? prev?.oldestMs ?? null : minOf(prev?.oldestMs ?? null, row.minStartMs),
9457
+ newestMs: empty ? prev?.newestMs ?? null : maxOf(prev?.newestMs ?? null, row.maxStartMs)
9458
+ });
9459
+ }
9460
+ return out;
9461
+ }
9277
9462
  async oldestSegmentsScoped(locationIds, deviceIds, targetBytes) {
9278
9463
  const ids = [...locationIds];
9279
9464
  if (ids.length === 0 || targetBytes <= 0) return {
@@ -14533,7 +14718,7 @@ var RecordingController = class {
14533
14718
  /**
14534
14719
  * The ADAPTIVE attach bound. A transient `spawn ffmpeg` failure (EAGAIN) is
14535
14720
  * the host saying it cannot fork right now, and the answer is to attach
14536
- * FEWER cameras at once — not to serialise them, which `e2fa17a2b` removed
14721
+ * FEWER cameras at once — not to serialise them, which removed
14537
14722
  * for good reason. Every bounded-parallel attach in this file reads it.
14538
14723
  */
14539
14724
  spawnPressure;
@@ -16038,7 +16223,7 @@ function selectRedundantSegments(segs) {
16038
16223
  * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
16039
16224
  * from `runFullArchiveWalk`, which was reachable only from
16040
16225
  * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
16041
- * (commit `9308428b4`) all three were deleted together as dead code. That was
16226
+ * all three were deleted together as dead code. That was
16042
16227
  * correct about the walk and left the system with **no duplicate pruner
16043
16228
  * whatsoever**. The precedent for letting this class of waste run unattended is
16044
16229
  * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
@@ -17449,6 +17634,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17449
17634
  refreshLocations: () => this.refreshLocations(),
17450
17635
  capacity: (root) => this.locationCapacity(root),
17451
17636
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
17637
+ archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
17452
17638
  archive: () => this.segmentHours,
17453
17639
  ledgerWalk: this.ledgerWalkReconciler(),
17454
17640
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),