@camstack/addon-pipeline 1.2.38 → 1.2.39

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 (90) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +145 -175
  4. package/dist/detection-pipeline/index.mjs +120 -150
  5. package/dist/{dist-BvFLZyhW.js → dist-BtAvtroH.js} +260 -108
  6. package/dist/{dist-Du7_SNiR.mjs → dist-COgGdFMv.mjs} +260 -108
  7. package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-naFYvcOO.mjs} +139 -2
  8. package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-shfwi1_b.js} +150 -1
  9. package/dist/lazy-sharp-B-mb8uWx.mjs +20 -0
  10. package/dist/lazy-sharp-CcYuXtsa.js +25 -0
  11. package/dist/motion-wasm/index.js +3 -3
  12. package/dist/motion-wasm/index.mjs +3 -3
  13. package/dist/pipeline-runner/index.js +3316 -2428
  14. package/dist/pipeline-runner/index.mjs +3315 -2428
  15. package/dist/recorder/index.js +102 -664
  16. package/dist/recorder/index.mjs +102 -658
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CjrKd4xX.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B59GNAJq.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-83qNzMSL.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-BFH17kSk.mjs} +2 -2
  22. package/dist/stream-broker/index.js +717 -211
  23. package/dist/stream-broker/index.mjs +717 -211
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-yeSHfpUy.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-Df4WxM6N.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-COHSiTGq.js} +1 -1
  28. package/embed-dist/assets/index-4fJzjj4H.css +2 -0
  29. package/embed-dist/assets/index-Ct9M2v5n.js +112 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +2 -2
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +0 -26
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +0 -26
  34. package/embed-dist/assets/index-9Ob2YrIf.js +0 -343
  35. package/embed-dist/assets/index-CAe6Dg-X.css +0 -2
  36. package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
  37. package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
  38. package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
  39. package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
  40. package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
  41. package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
  42. package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
  43. package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
  44. package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
  45. package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
  46. package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
  47. package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
  48. package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
  49. package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
  50. package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
  51. package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
  52. package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
  53. package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
  54. package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
  55. package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
  56. package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
  57. package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
  58. package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
  59. package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
  60. package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
  61. package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
  62. package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
  63. package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
  64. package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
  65. package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
  66. package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
  67. package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
  68. package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
  69. package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
  70. package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
  71. package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
  72. package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
  73. package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
  74. package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
  76. package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
  77. package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
  78. package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
  79. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
  80. package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
  81. package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
  82. package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
  83. package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
  84. package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
  85. package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
  86. package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
  87. package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
  88. package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
  89. package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
  90. package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
@@ -1,4 +1,4 @@
1
- import { $ as selectAssignedProfileSlots, B as errMsg, C as deriveRecordingMode, I as storageEvictableCapability, K as hydrateSchema, N as recordingCapability, P as recordingExportCapability, S as defineCustomActions, V as BaseAddon, W as DeviceType, X as nodePin, b as customAction, c as EVENT_PAD_MS, ct as object, f as OpsLogEntrySchema, ft as EventCategory, lt as record, nt as array, p as RecordingConfigSchema, rt as boolean, st as number, u as ExportRecordSchema, ut as string } from "../dist-Du7_SNiR.mjs";
1
+ import { $ as selectAssignedProfileSlots, B as errMsg, C as deriveRecordingMode, I as storageEvictableCapability, K as hydrateSchema, N as recordingCapability, P as recordingExportCapability, V as BaseAddon, W as DeviceType, X as nodePin, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, lt as record, nt as array, p as RecordingConfigSchema, u as ExportRecordSchema, ut as string } from "../dist-COgGdFMv.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
4
4
  import { spawn } from "node:child_process";
@@ -86,8 +86,9 @@ async function availabilityProfile(root, deviceId) {
86
86
  }
87
87
  /** The canonical recording profiles — the ONLY `<deviceId>/` subdirectories
88
88
  * that hold segments. The camera dir also hosts per-camera artifacts that are
89
- * NOT profiles (`strips/`, `events/`); a walker that trusts readdir would
90
- * offer them as playable profiles. */
89
+ * NOT profiles (`events/`, plus the `strips/` trees the retired filmstrip tier
90
+ * left on disk); a walker that trusts readdir would offer them as playable
91
+ * profiles. */
91
92
  var KNOWN_PROFILES = new Set([
92
93
  "high",
93
94
  "mid",
@@ -221,7 +222,7 @@ function mergeRanges(segments, gapToleranceMs) {
221
222
  //#endregion
222
223
  //#region src/recorder/addon/calendar-index.ts
223
224
  var DAY_MS$3 = 864e5;
224
- var HOUR_MS$4 = 36e5;
225
+ var HOUR_MS$3 = 36e5;
225
226
  /**
226
227
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
227
228
  *
@@ -394,7 +395,7 @@ var CalendarIndex = class {
394
395
  }));
395
396
  const days = /* @__PURE__ */ new Set();
396
397
  for (const { day, hours } of perDay) for (const h of hours) {
397
- const at = day + h * HOUR_MS$4;
398
+ const at = day + h * HOUR_MS$3;
398
399
  if (at < fromMs || at >= toMs) continue;
399
400
  days.add(localDayStart(at, tzOffsetMinutes));
400
401
  }
@@ -456,7 +457,7 @@ var CalendarIndex = class {
456
457
  const day = utcDayOf(utcDayMs);
457
458
  for (let h = 0; h < 24; h++) {
458
459
  await this.awaitQuiet();
459
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
460
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$3, true);
460
461
  }
461
462
  }
462
463
  /**
@@ -477,10 +478,10 @@ var CalendarIndex = class {
477
478
  */
478
479
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
479
480
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
480
- const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
481
+ const hour = Math.floor(hourStartMs / HOUR_MS$3) * HOUR_MS$3;
481
482
  const key = `${deviceId}:${profile}:${hour}`;
482
483
  const now = this.deps.now?.() ?? Date.now();
483
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
484
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$3) * HOUR_MS$3;
484
485
  const cached = this.hourSegs.get(key);
485
486
  if (cached !== void 0) {
486
487
  if (!isCurrentHour) return {
@@ -544,7 +545,7 @@ var CalendarIndex = class {
544
545
  */
545
546
  async dayRanges(deviceId, profile, utcDayMs) {
546
547
  const day = utcDayOf(utcDayMs);
547
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
548
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$3), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
548
549
  }
549
550
  /**
550
551
  * Warm the days either side of the one being viewed, in the BACKGROUND.
@@ -628,7 +629,7 @@ var PLAYBACK_PLANE_PATH = "/addon/recorder/playback";
628
629
  * deployed client ever sees a version it cannot read. */
629
630
  var VERSION = 2;
630
631
  /** LEB128, unsigned. Every value here is a non-negative integer. */
631
- function writeVarint$1(out, value) {
632
+ function writeVarint(out, value) {
632
633
  let v = Math.max(0, Math.trunc(value));
633
634
  while (v >= 128) {
634
635
  out.push(v & 127 | 128);
@@ -649,29 +650,29 @@ function encodeDirectory(payload) {
649
650
  out.push(VERSION);
650
651
  out.push(payload.hydrated ? 1 : 0);
651
652
  const profile = new TextEncoder().encode(payload.profile);
652
- writeVarint$1(out, profile.length);
653
+ writeVarint(out, profile.length);
653
654
  for (const b of profile) out.push(b);
654
- writeVarint$1(out, payload.asOfMs);
655
- writeVarint$1(out, payload.rows.length);
655
+ writeVarint(out, payload.asOfMs);
656
+ writeVarint(out, payload.rows.length);
656
657
  let prevStart = 0;
657
658
  for (const r of payload.rows) {
658
- writeVarint$1(out, r.startMs - prevStart);
659
- writeVarint$1(out, r.durMs);
660
- writeVarint$1(out, r.bytes);
659
+ writeVarint(out, r.startMs - prevStart);
660
+ writeVarint(out, r.durMs);
661
+ writeVarint(out, r.bytes);
661
662
  prevStart = r.startMs;
662
663
  const mfra = r.mfra;
663
664
  if (mfra === void 0 || mfra.samples.length === 0) {
664
- writeVarint$1(out, 0);
665
+ writeVarint(out, 0);
665
666
  continue;
666
667
  }
667
- writeVarint$1(out, mfra.samples.length);
668
- writeVarint$1(out, mfra.trackId);
669
- writeVarint$1(out, mfra.mfraOffset);
668
+ writeVarint(out, mfra.samples.length);
669
+ writeVarint(out, mfra.trackId);
670
+ writeVarint(out, mfra.mfraOffset);
670
671
  let prevTicks = 0;
671
672
  let prevMoof = 0;
672
673
  for (const s of mfra.samples) {
673
- writeVarint$1(out, s.timeTicks - prevTicks);
674
- writeVarint$1(out, s.moofOffset - prevMoof);
674
+ writeVarint(out, s.timeTicks - prevTicks);
675
+ writeVarint(out, s.moofOffset - prevMoof);
675
676
  prevTicks = s.timeTicks;
676
677
  prevMoof = s.moofOffset;
677
678
  }
@@ -680,7 +681,7 @@ function encodeDirectory(payload) {
680
681
  }
681
682
  //#endregion
682
683
  //#region src/recorder/addon/directory-handler.ts
683
- var HOUR_MS$3 = 36e5;
684
+ var HOUR_MS$2 = 36e5;
684
685
  /** A window wider than this is refused: it is a client bug, and answering it
685
686
  * would walk the archive on the read path — the thing this replaces. */
686
687
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -727,9 +728,9 @@ function withDirectoryRoute(deps, next) {
727
728
  return;
728
729
  }
729
730
  const rows = [];
730
- const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
731
+ const firstHour = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
731
732
  let hydrated = true;
732
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
733
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$2) {
733
734
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
734
735
  if (!complete) hydrated = false;
735
736
  for (const s of segments) {
@@ -989,12 +990,9 @@ async function readMfraTable(absPath, fileBytes) {
989
990
  try {
990
991
  const idx = parseMfra(tail, fileBytes, info === null ? {} : { trackId: info.videoTrackId });
991
992
  return {
992
- table: {
993
- trackId: idx.trackId,
994
- mfraOffset: idx.mfraOffset,
995
- samples: idx.samples
996
- },
997
- videoTimescale: info?.videoTimescale ?? null
993
+ trackId: idx.trackId,
994
+ mfraOffset: idx.mfraOffset,
995
+ samples: idx.samples
998
996
  };
999
997
  } catch (err) {
1000
998
  if (err instanceof MfraParseError && err.needBytes && attempt === 0) {
@@ -1008,20 +1006,6 @@ async function readMfraTable(absPath, fileBytes) {
1008
1006
  await fh.close();
1009
1007
  }
1010
1008
  }
1011
- /**
1012
- * Wall-clock instants for a segment's keyframes: `-skip_frame nokey` emits
1013
- * them in decode order, so frame i IS sync sample i. Without a timescale the
1014
- * samples are spread evenly — a degraded position beats a missing thumbnail.
1015
- */
1016
- function keyframeInstants(startMs, durMs, captured) {
1017
- const { samples } = captured.table;
1018
- const first = samples[0]?.timeTicks ?? 0;
1019
- const ts = captured.videoTimescale;
1020
- return samples.map((s, i) => {
1021
- if (ts !== null && ts > 0) return Math.round(startMs + (s.timeTicks - first) / ts * 1e3);
1022
- return Math.round(startMs + i / samples.length * durMs);
1023
- });
1024
- }
1025
1009
  /** How long a table stays served. Directory asks are for recent footage far
1026
1010
  * more often than not, and a bounded window keeps this store O(day), not
1027
1011
  * O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
@@ -1070,466 +1054,6 @@ var MfraTableStore = class {
1070
1054
  }
1071
1055
  };
1072
1056
  //#endregion
1073
- //#region src/recorder/strip-format.ts
1074
- /**
1075
- * The thumbnail STRIP — the fast-drag depth of the scrub ladder.
1076
- *
1077
- * One append-only file per camera-hour holds every keyframe of the LOW
1078
- * recording as a JPEG at the low profile's NATIVE resolution. A fast drag
1079
- * crosses an hour in seconds and wants ~20 scattered stills; without the strip
1080
- * that is 20 keyframe reads in 20 different segments under latest-wins —
1081
- * structurally incapable of painting a flow. With it, the positions a drag
1082
- * crosses are a handful of ~25–40 KB ranged reads from ONE file, painted at
1083
- * the capped cadence.
1084
- *
1085
- * TWO wire shapes live here, both halves in one module so they cannot drift:
1086
- *
1087
- * - the STRIP FILE (`CSS1`): [magic][version] then records of
1088
- * [u32 jpegLen][u64 tsMs][jpeg]. Self-describing, so the index is
1089
- * derivable from the file alone after a restart.
1090
- * - the STRIP INDEX (`CSI1`): the varint answer to "what instants does this
1091
- * hour hold, and which bytes paint each one" — served from RAM, consumed by
1092
- * the client to turn a cursor position into one ranged read.
1093
- */
1094
- /** 'CSS1' — camstack strip, v1. */
1095
- var FILE_MAGIC = new Uint8Array([
1096
- 67,
1097
- 83,
1098
- 83,
1099
- 49
1100
- ]);
1101
- var FILE_VERSION = 1;
1102
- var STRIP_FILE_HEADER_BYTES = FILE_MAGIC.length + 1;
1103
- function stripFileHeader() {
1104
- const out = new Uint8Array(STRIP_FILE_HEADER_BYTES);
1105
- out.set(FILE_MAGIC, 0);
1106
- out[FILE_MAGIC.length] = FILE_VERSION;
1107
- return out;
1108
- }
1109
- function encodeStripRecord(tsMs, jpeg) {
1110
- const out = new Uint8Array(12 + jpeg.length);
1111
- const view = new DataView(out.buffer);
1112
- view.setUint32(0, jpeg.length);
1113
- view.setBigUint64(4, BigInt(Math.trunc(tsMs)));
1114
- out.set(jpeg, 12);
1115
- return out;
1116
- }
1117
- /**
1118
- * Walk a strip file's bytes into its entry list. Tolerates a TRUNCATED final
1119
- * record (a crash mid-append) by stopping at it — everything before is intact
1120
- * because records are append-only.
1121
- */
1122
- function parseStripEntries(bytes) {
1123
- if (bytes.length < STRIP_FILE_HEADER_BYTES) return [];
1124
- for (let i = 0; i < FILE_MAGIC.length; i++) if (bytes[i] !== FILE_MAGIC[i]) throw new Error("CSS1: bad magic");
1125
- if (bytes[FILE_MAGIC.length] !== FILE_VERSION) throw new Error(`CSS1: unsupported version ${String(bytes[FILE_MAGIC.length])}`);
1126
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
1127
- const entries = [];
1128
- let p = STRIP_FILE_HEADER_BYTES;
1129
- while (p + 12 <= bytes.length) {
1130
- const len = view.getUint32(p);
1131
- const tsMs = Number(view.getBigUint64(p + 4));
1132
- const offset = p + 12;
1133
- if (offset + len > bytes.length) break;
1134
- entries.push({
1135
- tsMs,
1136
- offset,
1137
- bytes: len
1138
- });
1139
- p = offset + len;
1140
- }
1141
- return entries;
1142
- }
1143
- var INDEX_VERSION = 1;
1144
- function writeVarint(out, value) {
1145
- let v = Math.max(0, Math.trunc(value));
1146
- while (v >= 128) {
1147
- out.push(v & 127 | 128);
1148
- v = Math.floor(v / 128);
1149
- }
1150
- out.push(v);
1151
- }
1152
- /** Entries MUST be ascending by tsMs and offset — append order guarantees it. */
1153
- function encodeStripIndex(entries) {
1154
- const out = [];
1155
- out.push(67, 83, 73, 49);
1156
- out.push(INDEX_VERSION);
1157
- writeVarint(out, entries.length);
1158
- let prevTs = 0;
1159
- let prevOffset = 0;
1160
- for (const e of entries) {
1161
- writeVarint(out, e.tsMs - prevTs);
1162
- writeVarint(out, e.offset - prevOffset);
1163
- writeVarint(out, e.bytes);
1164
- prevTs = e.tsMs;
1165
- prevOffset = e.offset;
1166
- }
1167
- return Uint8Array.from(out);
1168
- }
1169
- //#endregion
1170
- //#region src/recorder/strip-store.ts
1171
- /**
1172
- * The strip files on disk: append at finalize, index on demand.
1173
- *
1174
- * Layout: `<location root>/<deviceId>/strips/YYYY/MM/DD/HH.strip` — rooted
1175
- * under the CAMERA directory like every other per-camera artifact (segments,
1176
- * event media). The profile walkers (`profilesOf`, `hydrateWindowFromStorage`)
1177
- * whitelist the canonical profile names, so `strips/` (and `events/`) sitting
1178
- * next to `high|mid|low/` is never offered as a playable profile.
1179
- *
1180
- * Appends are serialized per file (a per-key promise chain): two segments of
1181
- * the same hour finalizing close together must not interleave their records.
1182
- */
1183
- var HOUR_MS$2 = 36e5;
1184
- /** Location-relative path of one camera-hour's strip. */
1185
- function stripRelPath(deviceId, hourStartMs) {
1186
- const d = /* @__PURE__ */ new Date(Math.floor(hourStartMs / HOUR_MS$2) * HOUR_MS$2);
1187
- const p2 = (n) => String(n).padStart(2, "0");
1188
- return path.join(String(deviceId), "strips", String(d.getUTCFullYear()), p2(d.getUTCMonth() + 1), p2(d.getUTCDate()), `${p2(d.getUTCHours())}.strip`);
1189
- }
1190
- var StripStore = class {
1191
- rootFor;
1192
- /** Per-file append chain — see the header comment. */
1193
- appending = /* @__PURE__ */ new Map();
1194
- /** Parsed entries per file, invalidated when the SIZE moves (append-only
1195
- * files: same size ⟹ same content). */
1196
- indexCache = /* @__PURE__ */ new Map();
1197
- constructor(rootFor) {
1198
- this.rootFor = rootFor;
1199
- }
1200
- /** Absolute path for a camera-hour, or null before locations resolve. */
1201
- absPathFor(deviceId, hourStartMs) {
1202
- const root = this.rootFor();
1203
- if (root === null) return null;
1204
- return path.join(root, stripRelPath(deviceId, hourStartMs));
1205
- }
1206
- /** Append thumbnails (one segment's keyframes — same hour by construction). */
1207
- async append(deviceId, thumbs) {
1208
- const first = thumbs[0];
1209
- if (first === void 0) return;
1210
- const abs = this.absPathFor(deviceId, first.tsMs);
1211
- if (abs === null) return;
1212
- const run = (this.appending.get(abs) ?? Promise.resolve()).then(async () => {
1213
- await promises.mkdir(path.dirname(abs), { recursive: true });
1214
- let created = false;
1215
- try {
1216
- await promises.access(abs);
1217
- } catch {
1218
- created = true;
1219
- }
1220
- const parts = created ? [stripFileHeader()] : [];
1221
- for (const t of thumbs) parts.push(encodeStripRecord(t.tsMs, t.jpeg));
1222
- await promises.appendFile(abs, Buffer.concat(parts.map((p) => Buffer.from(p))));
1223
- this.indexCache.delete(abs);
1224
- });
1225
- this.appending.set(abs, run.catch(() => {}));
1226
- try {
1227
- await run;
1228
- } finally {
1229
- if (this.appending.get(abs) === run) this.appending.delete(abs);
1230
- }
1231
- }
1232
- /**
1233
- * The entry list for a camera-hour, or null when the hour has no strip.
1234
- * Cached by file size — append-only files cannot change under a same-size
1235
- * cache.
1236
- */
1237
- async index(deviceId, hourStartMs) {
1238
- const abs = this.absPathFor(deviceId, hourStartMs);
1239
- if (abs === null) return null;
1240
- let size;
1241
- try {
1242
- size = (await promises.stat(abs)).size;
1243
- } catch {
1244
- return null;
1245
- }
1246
- const cached = this.indexCache.get(abs);
1247
- if (cached && cached.size === size) return cached.entries;
1248
- const entries = parseStripEntries(new Uint8Array(await promises.readFile(abs)));
1249
- this.indexCache.set(abs, {
1250
- size,
1251
- entries
1252
- });
1253
- return entries;
1254
- }
1255
- /** A regeneration clears the device's whole strip tree first. */
1256
- async removeDevice(deviceId) {
1257
- const root = this.rootFor();
1258
- if (root === null) return;
1259
- for (const key of [...this.indexCache.keys()]) if (key.includes(path.join(String(deviceId), "strips") + path.sep)) this.indexCache.delete(key);
1260
- await promises.rm(path.join(root, String(deviceId), "strips"), {
1261
- recursive: true,
1262
- force: true
1263
- }).catch(() => {});
1264
- }
1265
- /** Eviction removed an hour's footage — its strip goes with it. */
1266
- async removeHour(deviceId, hourStartMs) {
1267
- const abs = this.absPathFor(deviceId, hourStartMs);
1268
- if (abs === null) return;
1269
- this.indexCache.delete(abs);
1270
- await promises.rm(abs, { force: true }).catch(() => {});
1271
- }
1272
- };
1273
- //#endregion
1274
- //#region src/recorder/strip-capture.ts
1275
- /**
1276
- * Keyframe thumbnails from a JUST-FINALIZED low segment — one short-lived
1277
- * ffmpeg per segment, `-skip_frame nokey`, so only the 2–4 keyframes are ever
1278
- * decoded (~15 ms of CPU on a 360p segment still sitting in page cache).
1279
- *
1280
- * WHY NOT A SECOND OUTPUT ON THE RECORDER'S FFMPEG
1281
- * ────────────────────────────────────────────────
1282
- * The write path is the one process that must never be destabilised, and an
1283
- * image pipe has no per-frame PTS — pairing frames to instants would need NUT
1284
- * parsing or stderr scraping. At finalize the mfra capture has ALREADY read
1285
- * the sync-sample table: `-skip_frame nokey` emits keyframes in decode order,
1286
- * so frame i IS sample i and the timestamps come for free. A failed capture
1287
- * costs one hour's drag a thumbnail, nothing else.
1288
- *
1289
- * Bounded: at most `MAX_CONCURRENT` ffmpegs, and a backlog beyond
1290
- * `MAX_QUEUED` drops the OLDEST waiting job — the strip is a best-effort
1291
- * accelerator, and a stampede of finalizes (20 cameras, one segment cadence)
1292
- * must never queue decode work behind the array.
1293
- */
1294
- var MAX_CONCURRENT = 2;
1295
- var MAX_QUEUED = 32;
1296
- /** A one-shot decode of 2–4 keyframes must never wedge a slot. */
1297
- var TIMEOUT_MS = 15e3;
1298
- /** Split an MJPEG byte stream into its JPEGs on SOI markers (FFD8 FFE0/DB…).
1299
- * ffmpeg's image2pipe emits baseline JPEGs back to back; scanning for the
1300
- * next SOI right after an EOI is unambiguous for its output. */
1301
- function splitJpegs(stream) {
1302
- const out = [];
1303
- let start = -1;
1304
- for (let i = 0; i + 1 < stream.length; i++) if (stream[i] === 255 && stream[i + 1] === 216 && (start === -1 || wasEoi(stream, i))) {
1305
- if (start !== -1) out.push(stream.subarray(start, i));
1306
- start = i;
1307
- }
1308
- if (start !== -1) out.push(stream.subarray(start));
1309
- return out;
1310
- }
1311
- /** Whether the two bytes immediately before `i` are an EOI — the only place a
1312
- * new SOI can legitimately begin inside a concatenated stream. */
1313
- function wasEoi(stream, i) {
1314
- return i >= 2 && stream[i - 2] === 255 && stream[i - 1] === 217;
1315
- }
1316
- var StripCapture = class {
1317
- deps;
1318
- queue = [];
1319
- running = 0;
1320
- constructor(deps = {}) {
1321
- this.deps = deps;
1322
- }
1323
- /**
1324
- * The segment's keyframe JPEGs in decode order, or null when the capture was
1325
- * dropped (backlog) or failed. Never throws — the strip is best-effort.
1326
- */
1327
- capture(absPath) {
1328
- return new Promise((resolve) => {
1329
- this.queue.push({
1330
- absPath,
1331
- resolve
1332
- });
1333
- while (this.queue.length > MAX_QUEUED) {
1334
- const dropped = this.queue.shift();
1335
- dropped?.resolve(null);
1336
- this.deps.onLog?.("strip:dropped", { path: dropped?.absPath ?? "" });
1337
- }
1338
- this.pump();
1339
- });
1340
- }
1341
- pump() {
1342
- while (this.running < MAX_CONCURRENT) {
1343
- const job = this.queue.shift();
1344
- if (job === void 0) return;
1345
- this.running++;
1346
- (this.deps.runFfmpeg ?? this.runFfmpeg)(job.absPath).then((stream) => job.resolve(splitJpegs(stream))).catch((err) => {
1347
- this.deps.onLog?.("strip:capture-failed", {
1348
- path: job.absPath,
1349
- error: err instanceof Error ? err.message : String(err)
1350
- });
1351
- job.resolve(null);
1352
- }).finally(() => {
1353
- this.running--;
1354
- this.pump();
1355
- });
1356
- }
1357
- }
1358
- runFfmpeg = (absPath) => {
1359
- return new Promise((resolve, reject) => {
1360
- const child = spawn(this.deps.ffmpegPath ?? "ffmpeg", [
1361
- "-hide_banner",
1362
- "-loglevel",
1363
- "error",
1364
- "-skip_frame",
1365
- "nokey",
1366
- "-i",
1367
- absPath,
1368
- "-c:v",
1369
- "mjpeg",
1370
- "-q:v",
1371
- "7",
1372
- "-f",
1373
- "image2pipe",
1374
- "-"
1375
- ], { stdio: [
1376
- "ignore",
1377
- "pipe",
1378
- "pipe"
1379
- ] });
1380
- const chunks = [];
1381
- const errChunks = [];
1382
- const timer = setTimeout(() => {
1383
- child.kill("SIGKILL");
1384
- reject(/* @__PURE__ */ new Error(`strip capture timed out after ${TIMEOUT_MS}ms`));
1385
- }, TIMEOUT_MS);
1386
- timer.unref?.();
1387
- child.stdout.on("data", (c) => chunks.push(c));
1388
- child.stderr.on("data", (c) => errChunks.push(c));
1389
- child.on("error", (err) => {
1390
- clearTimeout(timer);
1391
- reject(err);
1392
- });
1393
- child.on("close", (code) => {
1394
- clearTimeout(timer);
1395
- if (code === 0) resolve(new Uint8Array(Buffer.concat(chunks)));
1396
- else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${String(code)}: ${Buffer.concat(errChunks).toString("utf8").slice(0, 300)}`));
1397
- });
1398
- });
1399
- };
1400
- };
1401
- //#endregion
1402
- //#region src/recorder/addon/strip-routes.ts
1403
- /**
1404
- * The strip's two questions, answered on the playback plane:
1405
- *
1406
- * `GET /<deviceId>/strip-index?hour=<hourStartMs>` → `CSI1` (what instants
1407
- * the hour holds, and which bytes paint each one)
1408
- * `GET /<deviceId>/strip?hour=<hourStartMs>` + `Range` → the strip file's
1409
- * bytes (the client asks for exactly the records it needs)
1410
- *
1411
- * Same prefix, same auth, same fetch seam as the segments and the directory —
1412
- * one plane, one credential path. The strip files live under `<deviceId>/strips/`
1413
- * (the per-camera layout; the profile walkers whitelist high|mid|low so the
1414
- * availability pick never misreads it), and these routes are their only door.
1415
- */
1416
- var INDEX_PATH = /^\/(\d+)\/strip-index$/;
1417
- var FILE_PATH = /^\/(\d+)\/strip$/;
1418
- /** `bytes=a-b` → [a, b] clamped to `size`, or null (serve the whole file). */
1419
- function parseRange(header, size) {
1420
- if (!header) return null;
1421
- const m = /^bytes=(\d+)-(\d+)?$/.exec(header);
1422
- if (!m) return null;
1423
- const start = Number(m[1]);
1424
- const end = m[2] === void 0 ? size - 1 : Math.min(Number(m[2]), size - 1);
1425
- if (start > end || start >= size) return null;
1426
- return [start, end];
1427
- }
1428
- function withStripRoutes(deps, next) {
1429
- return async (req, res) => {
1430
- const raw = req.url ?? "/";
1431
- const qs = raw.indexOf("?");
1432
- const pathname = qs === -1 ? raw : raw.slice(0, qs);
1433
- const params = new URLSearchParams(qs === -1 ? "" : raw.slice(qs + 1));
1434
- const asIndex = INDEX_PATH.exec(pathname);
1435
- const asFile = asIndex === null ? FILE_PATH.exec(pathname) : null;
1436
- if (asIndex === null && asFile === null) {
1437
- await next(req, res);
1438
- return;
1439
- }
1440
- const deviceId = Number((asIndex ?? asFile)[1]);
1441
- const hour = Number(params.get("hour"));
1442
- if (!Number.isFinite(hour) || hour <= 0) {
1443
- res.statusCode = 400;
1444
- res.end("strip: hour required");
1445
- return;
1446
- }
1447
- try {
1448
- if (asIndex !== null) {
1449
- const entries = await deps.store.index(deviceId, hour);
1450
- if (entries === null) {
1451
- res.statusCode = 204;
1452
- res.end();
1453
- return;
1454
- }
1455
- const body = encodeStripIndex(entries);
1456
- res.statusCode = 200;
1457
- res.setHeader("content-type", "application/octet-stream");
1458
- res.setHeader("content-length", String(body.length));
1459
- res.setHeader("content-encoding", "identity");
1460
- const immutable = hour + 36e5 < Date.now();
1461
- res.setHeader("cache-control", immutable ? "private, max-age=31536000, immutable" : "private, max-age=2");
1462
- res.end(Buffer.from(body));
1463
- deps.onLog?.("strip:index-served", {
1464
- deviceId,
1465
- hour,
1466
- entries: entries.length
1467
- });
1468
- return;
1469
- }
1470
- const abs = deps.store.absPathFor(deviceId, hour);
1471
- if (abs === null) {
1472
- res.statusCode = 204;
1473
- res.end();
1474
- return;
1475
- }
1476
- let size;
1477
- try {
1478
- size = (await promises.stat(abs)).size;
1479
- } catch {
1480
- res.statusCode = 204;
1481
- res.end();
1482
- return;
1483
- }
1484
- const range = parseRange(req.headers.range, size);
1485
- const [start, end] = range ?? [0, size - 1];
1486
- res.statusCode = range === null ? 200 : 206;
1487
- res.setHeader("content-type", "application/octet-stream");
1488
- res.setHeader("content-length", String(end - start + 1));
1489
- res.setHeader("accept-ranges", "bytes");
1490
- res.setHeader("content-encoding", "identity");
1491
- if (range !== null) res.setHeader("content-range", `bytes ${start}-${end}/${size}`);
1492
- const stream = createReadStream(abs, {
1493
- start,
1494
- end
1495
- });
1496
- stream.on("error", () => res.destroy());
1497
- stream.pipe(res);
1498
- } catch (err) {
1499
- deps.onLog?.("strip:failed", {
1500
- deviceId,
1501
- hour,
1502
- error: err instanceof Error ? err.message : String(err)
1503
- });
1504
- res.statusCode = 500;
1505
- res.end("strip: failed");
1506
- }
1507
- };
1508
- }
1509
- //#endregion
1510
- //#region src/recorder/addon/strip-actions.ts
1511
- /**
1512
- * Recorder custom actions: strip regeneration.
1513
- *
1514
- * `regenerateStrips` clears a camera's thumbnail strips and rebuilds them from
1515
- * the low segments already on disk — which is also the BACKFILL for footage
1516
- * recorded before strips existed (or before the operator opted the camera in).
1517
- *
1518
- * A custom action, not a cap method: the generated AppRouter sits at
1519
- * TypeScript's expansion limit, so caps cannot gain methods (docs/roadmap.md).
1520
- */
1521
- var RegenerateStripsInputSchema = object({
1522
- deviceId: number().int().nonnegative(),
1523
- /** Days back from now to rebuild (default {@link REGEN_DEFAULT_DAYS}). */
1524
- days: number().int().positive().max(60).optional()
1525
- });
1526
- var recorderStripActions = defineCustomActions({ regenerateStrips: customAction(RegenerateStripsInputSchema, object({
1527
- /** False when a regeneration for this device is already running. */
1528
- started: boolean(),
1529
- /** Segments queued for thumbnail extraction (0 when not started). */
1530
- segments: number()
1531
- }), { kind: "mutation" }) });
1532
- //#endregion
1533
1057
  //#region src/recorder/segment-path.ts
1534
1058
  function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
1535
1059
  if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
@@ -4254,6 +3778,41 @@ var DEFAULT_WATCH_INTERVAL_MS = 2e3;
4254
3778
  /** Periodic re-evaluation to catch band boundaries crossed with no config change. */
4255
3779
  var TICK_MS = 3e4;
4256
3780
  /**
3781
+ * Bound on the broker lease-release RPC (`releaseStreamWithCodec`) during
3782
+ * teardown. The UDS request default is 60s, but the runner supervisor's
3783
+ * SIGTERM→SIGKILL grace (`$process.stop`/`restart` in the kernel
3784
+ * process-service) is 5s: one release hanging on a restarting broker — the
3785
+ * normal state during a redeploy — blew the grace and the supervisor
3786
+ * SIGKILLed the recorder mid-shutdown (observed 2026-08-01 04:08–04:24,
3787
+ * `signal=SIGKILL state=stopped` × 4). The SIGKILL orphans the ffmpeg
3788
+ * writers (they keep double-writing `.rec-tmp` against the respawned
3789
+ * runner's fresh writers) and releases NO lease at all — strictly worse
3790
+ * than dropping this one RPC. A timed-out release is safe to abandon:
3791
+ * killing the writer drops the source's only RTSP consumer, so the broker
3792
+ * idles the source out on its own.
3793
+ */
3794
+ var RELEASE_RPC_TIMEOUT_MS = 2e3;
3795
+ /**
3796
+ * Race `promise` against a deadline. Handlers stay attached to the losing
3797
+ * promise, so a post-deadline settlement can never surface as a process-level
3798
+ * `unhandledRejection`.
3799
+ */
3800
+ function withDeadline(promise, ms, label) {
3801
+ return new Promise((resolve, reject) => {
3802
+ const timer = setTimeout(() => {
3803
+ reject(/* @__PURE__ */ new Error(`${label} timed out after ${ms}ms`));
3804
+ }, ms);
3805
+ timer.unref?.();
3806
+ promise.then((value) => {
3807
+ clearTimeout(timer);
3808
+ resolve(value);
3809
+ }, (err) => {
3810
+ clearTimeout(timer);
3811
+ reject(err instanceof Error ? err : new Error(String(err)));
3812
+ });
3813
+ });
3814
+ }
3815
+ /**
4257
3816
  * Floor for "this writer is pinned but producing nothing".
4258
3817
  *
4259
3818
  * The give-up path only fires when ffmpeg EXITS. On 2026-07-29 the opposite
@@ -4463,7 +4022,18 @@ var RecordingController = class {
4463
4022
  lastMotionMs: source === "motion" ? atMs : prev.lastMotionMs,
4464
4023
  lastAudioMs: source === "audio" ? atMs : prev.lastAudioMs
4465
4024
  });
4466
- await this.evaluateDevice(deviceId);
4025
+ try {
4026
+ await this.evaluateDevice(deviceId);
4027
+ } catch (err) {
4028
+ this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/tick)", {
4029
+ tags: { deviceId },
4030
+ meta: {
4031
+ source,
4032
+ atMs,
4033
+ error: errMsg(err)
4034
+ }
4035
+ });
4036
+ }
4467
4037
  }
4468
4038
  /**
4469
4039
  * Decide-and-converge for one device: if its active band demands recording now
@@ -4632,9 +4202,9 @@ var RecordingController = class {
4632
4202
  r.writer.stop();
4633
4203
  } catch {}
4634
4204
  try {
4635
- await this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, nodePin(this.deps.ownerNodeId));
4205
+ await withDeadline(this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, nodePin(this.deps.ownerNodeId)), RELEASE_RPC_TIMEOUT_MS, "releaseStreamWithCodec");
4636
4206
  } catch (err) {
4637
- this.deps.logger.warn("recorder: releaseStreamWithCodec failed", {
4207
+ this.deps.logger.warn("recorder: releaseStreamWithCodec failed — lease dropped unreleased (broker idles the source once the writer is gone)", {
4638
4208
  tags: { deviceId },
4639
4209
  meta: {
4640
4210
  profile: r.profile,
@@ -4650,7 +4220,7 @@ var RecordingController = class {
4650
4220
  if (!recordings) return;
4651
4221
  this.active.delete(deviceId);
4652
4222
  for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
4653
- for (const r of recordings) await this.teardownProfile(deviceId, r);
4223
+ await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
4654
4224
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
4655
4225
  tags: { deviceId },
4656
4226
  meta: { pipelineKeys: recordings.map((r) => r.pipelineKey) }
@@ -4665,7 +4235,7 @@ var RecordingController = class {
4665
4235
  }
4666
4236
  this.restore?.stop();
4667
4237
  this.restore = null;
4668
- for (const deviceId of Array.from(this.active.keys())) await this.detachDevice(deviceId);
4238
+ await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
4669
4239
  }
4670
4240
  };
4671
4241
  /** Build a `stream-broker` readiness scope for one node. */
@@ -4886,7 +4456,7 @@ var RelocateEngine = class {
4886
4456
  fromLocationId: from.id,
4887
4457
  toLocationId: to.id,
4888
4458
  deviceId: input.deviceId ?? null,
4889
- entities: input.entities ? [...input.entities] : ["segments", "strips"],
4459
+ entities: input.entities ? [...input.entities] : ["segments"],
4890
4460
  filesMoved: 0,
4891
4461
  bytesMoved: 0,
4892
4462
  filesTotal: null,
@@ -4904,8 +4474,7 @@ var RelocateEngine = class {
4904
4474
  const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
4905
4475
  try {
4906
4476
  const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).sort((a, b) => a.startMs - b.startMs);
4907
- const stripFiles = job.entities.includes("strips") ? await listStripFiles(from.root, job.deviceId) : [];
4908
- job.filesTotal = segRows.length + stripFiles.length;
4477
+ job.filesTotal = segRows.length;
4909
4478
  for (const row of segRows) {
4910
4479
  if (job.cancelRequested) break;
4911
4480
  await this.moveSegment(row, from, to);
@@ -4914,15 +4483,6 @@ var RelocateEngine = class {
4914
4483
  if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
4915
4484
  await sleep(row.bytes / bytesPerMs);
4916
4485
  }
4917
- for (const rel of stripFiles) {
4918
- if (job.cancelRequested) break;
4919
- const src = path.join(from.root, rel);
4920
- const bytes = (await promises.stat(src)).size;
4921
- await moveFile(src, path.join(to.root, rel), bytes);
4922
- job.filesMoved++;
4923
- job.bytesMoved += bytes;
4924
- await sleep(bytes / bytesPerMs);
4925
- }
4926
4486
  job.state = job.cancelRequested ? "cancelled" : "done";
4927
4487
  } catch (err) {
4928
4488
  job.state = "failed";
@@ -4947,29 +4507,6 @@ var RelocateEngine = class {
4947
4507
  });
4948
4508
  }
4949
4509
  };
4950
- /** The location-relative strip files under `<root>/<dev>/strips/…`, optionally
4951
- * scoped to one device. Missing dirs are data (no strips), not failures. */
4952
- async function listStripFiles(root, deviceId) {
4953
- const devices = [];
4954
- if (deviceId !== null) devices.push(String(deviceId));
4955
- else try {
4956
- for (const e of await promises.readdir(root)) if (/^\d+$/.test(e)) devices.push(e);
4957
- } catch {
4958
- return [];
4959
- }
4960
- const out = [];
4961
- for (const dev of devices) {
4962
- const stripDir = path.join(root, dev, "strips");
4963
- let entries;
4964
- try {
4965
- entries = await promises.readdir(stripDir, { recursive: true });
4966
- } catch {
4967
- continue;
4968
- }
4969
- for (const e of entries) if (e.endsWith(".strip")) out.push(path.join(dev, "strips", e.split(path.sep).join("/")));
4970
- }
4971
- return out;
4972
- }
4973
4510
  //#endregion
4974
4511
  //#region src/recorder/addon/footage-render.ts
4975
4512
  /**
@@ -5422,14 +4959,6 @@ var RecorderV2Addon = class extends BaseAddon {
5422
4959
  /** Sync-sample tables captured at finalize, served by the directory so a
5423
4960
  * playback seek needs no mfra tail fetch. RAM-only, TTL-bounded. */
5424
4961
  mfraTables = new MfraTableStore();
5425
- /** Keyframe thumbnail strips (fast-drag scrub depth), one file per
5426
- * camera-hour under the LOW recordings root. */
5427
- stripStore = new StripStore(() => {
5428
- const writable = this.resolvedLocations.filter((l) => !l.readOnly);
5429
- return (writable.find((l) => l.type === "recordingsLow") ?? writable[0])?.root ?? null;
5430
- });
5431
- /** Bounded one-shot ffmpeg pool feeding the strip at finalize. */
5432
- stripCapture = new StripCapture({ onLog: (msg, data) => this.ctx.logger.debug(msg, { meta: data }) });
5433
4962
  /** Continuous + events band write-path controller (ffmpeg writers + watchers). */
5434
4963
  controller = null;
5435
4964
  /** Per-device motion/audio markers for playback (B3 events feed the controller). */
@@ -5503,23 +5032,12 @@ var RecorderV2Addon = class extends BaseAddon {
5503
5032
  },
5504
5033
  onEvicted: (deviceId, atMs) => {
5505
5034
  this.calendar.invalidate(deviceId, atMs);
5506
- this.stripStore.removeHour(deviceId, atMs);
5507
5035
  },
5508
5036
  onIndexed: (row, absPath) => {
5509
- readMfraTable(absPath, row.bytes).then(async (captured) => {
5510
- this.mfraTables.record(row.deviceId, row.profile, row.startMs, captured.table);
5511
- if (row.profile !== "low") return;
5512
- if ((await loadDeviceConfig(this.configStore(), row.deviceId)).stripsEnabled !== true) return;
5513
- const jpegs = await this.stripCapture.capture(absPath);
5514
- if (jpegs === null || jpegs.length === 0) return;
5515
- const instants = keyframeInstants(row.startMs, row.durMs, captured);
5516
- const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
5517
- tsMs: instants[i] ?? row.startMs,
5518
- jpeg
5519
- }));
5520
- await this.stripStore.append(row.deviceId, thumbs);
5037
+ readMfraTable(absPath, row.bytes).then((table) => {
5038
+ this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
5521
5039
  }).catch((err) => {
5522
- this.ctx.logger.debug("mfra/strip capture failed", {
5040
+ this.ctx.logger.debug("mfra capture failed", {
5523
5041
  tags: { deviceId: row.deviceId },
5524
5042
  meta: {
5525
5043
  path: row.path,
@@ -5711,14 +5229,11 @@ var RecorderV2Addon = class extends BaseAddon {
5711
5229
  downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
5712
5230
  });
5713
5231
  try {
5714
- const handler = withStripRoutes({
5715
- store: this.stripStore,
5716
- onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
5717
- }, withDirectoryRoute({
5232
+ const handler = withDirectoryRoute({
5718
5233
  source: this.calendar,
5719
5234
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
5720
5235
  onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
5721
- }, createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() })));
5236
+ }, createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() }));
5722
5237
  const served = await this.ctx.dataPlane?.serve({
5723
5238
  prefix: PLAYBACK_PREFIX,
5724
5239
  access: "authenticated",
@@ -5773,24 +5288,20 @@ var RecorderV2Addon = class extends BaseAddon {
5773
5288
  }
5774
5289
  });
5775
5290
  warnLostBands(this.configStore(), this.ctx.logger);
5776
- return {
5777
- providers: [
5778
- {
5779
- capability: recordingCapability,
5780
- provider: recordingProvider
5781
- },
5782
- {
5783
- capability: storageEvictableCapability,
5784
- provider: evictableProvider
5785
- },
5786
- {
5787
- capability: recordingExportCapability,
5788
- provider: exportProvider
5789
- }
5790
- ],
5791
- customActions: recorderStripActions,
5792
- actionHandlers: { regenerateStrips: async (input) => this.regenerateStrips(RegenerateStripsInputSchema.parse(input)) }
5793
- };
5291
+ return { providers: [
5292
+ {
5293
+ capability: recordingCapability,
5294
+ provider: recordingProvider
5295
+ },
5296
+ {
5297
+ capability: storageEvictableCapability,
5298
+ provider: evictableProvider
5299
+ },
5300
+ {
5301
+ capability: recordingExportCapability,
5302
+ provider: exportProvider
5303
+ }
5304
+ ] };
5794
5305
  }
5795
5306
  async onShutdown() {
5796
5307
  this.eventUnsub?.();
@@ -6159,73 +5670,6 @@ var RecorderV2Addon = class extends BaseAddon {
6159
5670
  if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
6160
5671
  return path.join(loc.root, row.path);
6161
5672
  }
6162
- /** Devices with a strip regeneration in flight — one at a time per device. */
6163
- stripRegenRunning = /* @__PURE__ */ new Set();
6164
- /**
6165
- * Clear one camera's strips and rebuild them from the low segments on disk —
6166
- * the backfill for footage recorded before strips were enabled. Walks the
6167
- * RECENT window (default {@link REGEN_DEFAULT_DAYS} days), newest hour
6168
- * first, so the part scrubbing actually visits is ready soonest. Background;
6169
- * the one-shot ffmpeg pool bounds the decode concurrency.
6170
- */
6171
- async regenerateStrips(input) {
6172
- const { deviceId } = input;
6173
- if (this.stripRegenRunning.has(deviceId)) return {
6174
- started: false,
6175
- segments: 0
6176
- };
6177
- const days = input.days ?? 3;
6178
- const cutoff = Date.now() - days * 864e5;
6179
- const rows = this.index.segments(deviceId, "low").filter((r) => r.startMs >= cutoff).toSorted((a, b) => b.startMs - a.startMs);
6180
- this.stripRegenRunning.add(deviceId);
6181
- await this.stripStore.removeDevice(deviceId);
6182
- (async () => {
6183
- let done = 0;
6184
- let failed = 0;
6185
- for (const row of rows) {
6186
- try {
6187
- const abs = this.resolveSegmentAbsPath(row);
6188
- const captured = await readMfraTable(abs, row.bytes);
6189
- const jpegs = await this.stripCapture.capture(abs);
6190
- if (jpegs === null || jpegs.length === 0) {
6191
- failed++;
6192
- continue;
6193
- }
6194
- const instants = keyframeInstants(row.startMs, row.durMs, captured);
6195
- const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
6196
- tsMs: instants[i] ?? row.startMs,
6197
- jpeg
6198
- }));
6199
- await this.stripStore.append(deviceId, thumbs);
6200
- done++;
6201
- } catch {
6202
- failed++;
6203
- }
6204
- if ((done + failed) % 500 === 0) this.ctx.logger.info("strip regeneration progress", {
6205
- tags: { deviceId },
6206
- meta: {
6207
- done,
6208
- failed,
6209
- total: rows.length
6210
- }
6211
- });
6212
- }
6213
- this.stripRegenRunning.delete(deviceId);
6214
- this.ctx.logger.info("strip regeneration complete", {
6215
- tags: { deviceId },
6216
- meta: {
6217
- done,
6218
- failed,
6219
- total: rows.length,
6220
- days
6221
- }
6222
- });
6223
- })();
6224
- return {
6225
- started: true,
6226
- segments: rows.length
6227
- };
6228
- }
6229
5673
  /**
6230
5674
  * Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
6231
5675
  * row's file (traversal-guarded by the id regex + the exact stored path),
@@ -6312,4 +5756,4 @@ var RecorderV2Addon = class extends BaseAddon {
6312
5756
  }
6313
5757
  };
6314
5758
  //#endregion
6315
- export { recorderStripActions as customActions, RecorderV2Addon as default };
5759
+ export { RecorderV2Addon as default };