@camstack/addon-pipeline 1.2.257 → 1.2.258

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 (33) 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-BTzxxm-z.mjs → default-detection-model-BZ54sUY2.mjs} +1 -1
  4. package/dist/{default-detection-model-C8CHpxQl.js → default-detection-model-vVWcdv0g.js} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-BMARLLlX.js → dist-CsJJPDQW.js} +154 -1
  8. package/dist/{dist-xsaGDCOu.mjs → dist-D3w2fwD4.mjs} +154 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-Ca-vE0xX.js → node-1vbEWzPZ.js} +1 -1
  12. package/dist/{node-Fv4ByT36.mjs → node-IHGNrWH9.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +4 -4
  15. package/dist/{process-memory-C3Wjmvm0.js → process-memory-DynU4177.js} +1 -1
  16. package/dist/{process-memory-BgtQADAY.mjs → process-memory-jhOBJEil.mjs} +1 -1
  17. package/dist/recorder/index.js +293 -19
  18. package/dist/recorder/index.mjs +293 -19
  19. package/dist/{restream-intent-VVJiUAkh.js → restream-intent-B0GOuR-F.js} +90 -0
  20. package/dist/{restream-intent-D2VT97-U.mjs → restream-intent-CgyZI565.mjs} +85 -1
  21. package/dist/{segment-demux-js-C7C6c-g2.mjs → segment-demux-js-BZAiu_2h.mjs} +1 -1
  22. package/dist/{segment-demux-js-DHV7EOQf.js → segment-demux-js-OQT6wqXH.js} +1 -1
  23. package/dist/stream-broker/_stub.js +1 -1
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CJWLoj3Z.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CFcO9OoY.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CTHexfai.mjs +26 -0
  26. package/dist/stream-broker/demux-worker-child.js +1 -1
  27. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  28. package/dist/stream-broker/{hostInit-BzBcvawL.mjs → hostInit-rU23F6nl.mjs} +3 -3
  29. package/dist/stream-broker/index.js +46 -7
  30. package/dist/stream-broker/index.mjs +46 -7
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/package.json +1 -1
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ReCGmeOG.mjs +0 -26
@@ -1,7 +1,7 @@
1
- import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-xsaGDCOu.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-Fv4ByT36.mjs";
1
+ import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-D3w2fwD4.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-IHGNrWH9.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
- import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-D2VT97-U.mjs";
4
+ import { l as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-CgyZI565.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { createReadStream, promises, realpathSync } from "node:fs";
@@ -814,14 +814,22 @@ function parseSegmentPath(relPath) {
814
814
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
815
815
  * already gives us — so "have I walked this?" costs one Set lookup. */
816
816
  var HOUR_MS$9 = 36e5;
817
- /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
818
- * the retention window into the set. */
819
- var ALL_HOURS = Symbol("all-hours");
820
817
  /** The hour buckets a half-open span touches. */
821
818
  function* hourBuckets(fromMs, toMs) {
822
819
  const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
823
820
  for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
824
821
  }
822
+ /** The hour bucket a segment belongs to — the same one its path encodes. */
823
+ function hourOf$1(startMs) {
824
+ return Math.floor(startMs / HOUR_MS$9) * HOUR_MS$9;
825
+ }
826
+ /** The production bound. See {@link RecordingIndexBudget} for the measurement. */
827
+ var DEFAULT_RECORDING_INDEX_BUDGET = {
828
+ residentRowBudget: 25e4,
829
+ residentHourBudget: 2e4,
830
+ evictToFraction: .8,
831
+ pinnedTailHours: 2
832
+ };
825
833
  /**
826
834
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
827
835
  * bound). Inserting there keeps equal-start rows in arrival order — exactly
@@ -869,10 +877,35 @@ var RecordingIndex = class {
869
877
  /** See {@link HydrateReuseStats}. */
870
878
  rowsReused = 0;
871
879
  rowsAllocated = 0;
872
- /** Hour buckets this device has been walked for; `ALL_HOURS` after a full
873
- * hydrate. Empty = nothing has been looked at, which is NOT the same as
874
- * "there is nothing" — see {@link hydrationOf}. */
880
+ /**
881
+ * Hour buckets this device has been walked for → when anything last ASKED
882
+ * about that hour ({@link useTick}, 0 = never since the walk). Empty =
883
+ * nothing has been looked at, which is NOT the same as "there is nothing" —
884
+ * see {@link hydrationOf}.
885
+ *
886
+ * The tick is the eviction order, and it lives here rather than in a side
887
+ * map because an evicted hour must lose its marker in the SAME operation
888
+ * that loses its rows: a walked marker outliving its rows would report
889
+ * `hydrated` over an hour the index no longer holds, which is the one
890
+ * failure a forgetting index must not have.
891
+ *
892
+ * There used to be an `ALL_HOURS` sentinel here for a device walked
893
+ * end-to-end. It had no production caller (2026-08-30 census) and a bounded
894
+ * index can never truthfully claim it, so it is gone.
895
+ */
875
896
  walked = /* @__PURE__ */ new Map();
897
+ /** Monotonic "somebody asked" counter. Only ever compared, never read out. */
898
+ useTick = 0;
899
+ /** `null` until {@link armResidency}. An unarmed index never evicts. */
900
+ residencyDeps = null;
901
+ evictedRows = 0;
902
+ evictedHours = 0;
903
+ evictionPasses = 0;
904
+ /** Hours dropped per device, for the life of the process. One number per
905
+ * camera — the input to {@link droppedDuringWalk}, nothing else. */
906
+ evictedHoursByDevice = /* @__PURE__ */ new Map();
907
+ /** {@link evictedHoursByDevice} as it stood when a whole-device walk began. */
908
+ walkBaseline = /* @__PURE__ */ new Map();
876
909
  /**
877
910
  * Upper bound on the `durMs` of any row the index holds — the ONLY thing that
878
911
  * turns "walk the whole sorted view" into "walk backwards a bounded number of
@@ -1046,7 +1079,9 @@ var RecordingIndex = class {
1046
1079
  * Deliberately NOT delete-all-then-reinsert: the archive walk re-reports rows
1047
1080
  * this index already holds, with freshly built path strings, and dropping the
1048
1081
  * held rows first would make those fresh strings the permanent ones — a
1049
- * second copy of the whole archive's paths. See {@link retainedPathStrings}.
1082
+ * second copy of the whole archive's paths. See {@link RecordingIndex.rowFor}
1083
+ * for the measurement (`retainedPathStrings`, which this used to point at,
1084
+ * never existed — a leftover that read as a verification, removed 2026-09-17).
1050
1085
  * @residency mutation
1051
1086
  */
1052
1087
  hydrateDevice(deviceId, locationId, relPaths) {
@@ -1060,6 +1095,7 @@ var RecordingIndex = class {
1060
1095
  }
1061
1096
  for (const [path, existing] of m) if (existing.locationId === locationId && !seen.has(path)) m.delete(path);
1062
1097
  this.invalidateSorted(deviceId);
1098
+ this.enforceResidency();
1063
1099
  }
1064
1100
  /**
1065
1101
  * Whether the index has WALKED `[fromMs, toMs)` for a device.
@@ -1073,7 +1109,6 @@ var RecordingIndex = class {
1073
1109
  hydrationOf(deviceId, fromMs, toMs) {
1074
1110
  const w = this.walked.get(deviceId);
1075
1111
  if (w === void 0) return "unknown";
1076
- if (w === ALL_HOURS) return "hydrated";
1077
1112
  let seen = 0;
1078
1113
  let total = 0;
1079
1114
  for (const h of hourBuckets(fromMs, toMs)) {
@@ -1085,15 +1120,225 @@ var RecordingIndex = class {
1085
1120
  }
1086
1121
  /** Record that `[fromMs, toMs)` has been walked, whatever it contained. @residency mutation */
1087
1122
  markHydrated(deviceId, fromMs, toMs) {
1123
+ let w = this.walked.get(deviceId);
1124
+ if (w === void 0) {
1125
+ w = /* @__PURE__ */ new Map();
1126
+ this.walked.set(deviceId, w);
1127
+ }
1128
+ const tick = ++this.useTick;
1129
+ for (const h of hourBuckets(fromMs, toMs)) w.set(h, tick);
1130
+ }
1131
+ /**
1132
+ * Bound this index, and give it somewhere to SAY what it dropped.
1133
+ *
1134
+ * Called once, from the recorder addon's initialize — `ctx.logger` does not
1135
+ * exist when the field is constructed. Until it runs the index never evicts,
1136
+ * which is honest for the pre-init window (it holds nothing) and is the one
1137
+ * state in which {@link ResidencyCensus.budget} is `null`.
1138
+ * @residency mutation
1139
+ */
1140
+ armResidency(deps) {
1141
+ this.residencyDeps = deps;
1142
+ this.enforceResidency();
1143
+ }
1144
+ /**
1145
+ * A whole-device walk is starting: remember how much this index had already
1146
+ * forgotten about that camera, so {@link droppedDuringWalk} can say whether
1147
+ * the walk's result is a COMPLETE view of what it read.
1148
+ * @residency mutation
1149
+ */
1150
+ beginWalk(deviceId) {
1151
+ this.walkBaseline.set(deviceId, this.evictedHoursByDevice.get(deviceId) ?? 0);
1152
+ }
1153
+ /**
1154
+ * Did this index drop anything for `deviceId` since {@link beginWalk}?
1155
+ *
1156
+ * The one question a PRUNE has to ask. An hour the index dropped is
1157
+ * indistinguishable, from the outside, from an hour disk no longer has — so
1158
+ * a consumer that deletes durable state on the index's silence must refuse
1159
+ * when the answer is `true`. `false` when nobody called `beginWalk`: an
1160
+ * unarmed index never drops, and a caller that never announced a walk is
1161
+ * asking about one that did not happen.
1162
+ * @residency resident-scoped
1163
+ */
1164
+ droppedDuringWalk(deviceId) {
1165
+ const baseline = this.walkBaseline.get(deviceId);
1166
+ if (baseline === void 0) return false;
1167
+ return (this.evictedHoursByDevice.get(deviceId) ?? 0) > baseline;
1168
+ }
1169
+ /** What is held, what was let go, and under which bound. @residency resident-scoped */
1170
+ residency() {
1171
+ return {
1172
+ residentRows: this.residentRowCount(),
1173
+ residentHours: this.residentHourCount(),
1174
+ evictedRows: this.evictedRows,
1175
+ evictedHours: this.evictedHours,
1176
+ evictionPasses: this.evictionPasses,
1177
+ budget: this.residencyDeps?.budget ?? null
1178
+ };
1179
+ }
1180
+ residentRowCount() {
1181
+ let n = 0;
1182
+ for (const m of this.byDevice.values()) n += m.size;
1183
+ return n;
1184
+ }
1185
+ residentHourCount() {
1186
+ let n = 0;
1187
+ for (const w of this.walked.values()) n += w.size;
1188
+ return n;
1189
+ }
1190
+ /**
1191
+ * Note that something ASKED about `[fromMs, toMs)` — the only input to the
1192
+ * eviction order.
1193
+ *
1194
+ * Walks whichever side is smaller: a read whose span is wider than the
1195
+ * device's whole walked map (a month-wide `ranges`, an availability sweep)
1196
+ * would otherwise iterate one hour at a time across the span.
1197
+ */
1198
+ touch(deviceId, fromMs, toMs) {
1088
1199
  const w = this.walked.get(deviceId);
1089
- if (w === ALL_HOURS) return;
1090
- const set = w ?? /* @__PURE__ */ new Set();
1091
- for (const h of hourBuckets(fromMs, toMs)) set.add(h);
1092
- this.walked.set(deviceId, set);
1200
+ if (w === void 0 || w.size === 0) return;
1201
+ const tick = ++this.useTick;
1202
+ if (Math.ceil(Math.max(0, toMs - fromMs) / HOUR_MS$9) > w.size) {
1203
+ for (const h of w.keys()) if (h < toMs && h + HOUR_MS$9 > fromMs) w.set(h, tick);
1204
+ return;
1205
+ }
1206
+ for (const h of hourBuckets(fromMs, toMs)) if (w.has(h)) w.set(h, tick);
1093
1207
  }
1094
- /** Record that the device was walked end-to-end (the background full walk). @residency mutation */
1095
- markHydratedAll(deviceId) {
1096
- this.walked.set(deviceId, ALL_HOURS);
1208
+ /**
1209
+ * Drop the least-recently-asked-about hours until the index is back inside
1210
+ * its budget.
1211
+ *
1212
+ * ## What a dropped hour costs, and why that is safe
1213
+ *
1214
+ * The directory is the authority for which segments exist — every row here
1215
+ * is derived from a path the walk read, and `hydrateHour` /
1216
+ * `hydrateWindowFromStorage` rebuild it from one shallow `readdir` (or one
1217
+ * ledger point read). So a drop costs a re-read, never a segment. The
1218
+ * property that makes it costs-a-read rather than loses-a-segment is that
1219
+ * the walked marker goes with the rows: `hydrationOf` then answers `unknown`
1220
+ * and the reader walks. A marker left behind would answer `hydrated` over an
1221
+ * hour holding nothing, which is exactly the silent gap this class spent
1222
+ * D295 learning not to report.
1223
+ *
1224
+ * ## What is never a candidate
1225
+ *
1226
+ * The live tail ({@link RecordingIndexBudget.pinnedTailHours}). Those hours
1227
+ * are write-through from the writer (`addSegment`), not walked, and a
1228
+ * `readdir` of an hour a writer is still filling cannot give back what it
1229
+ * had — D445's lost tail, from the other direction.
1230
+ */
1231
+ enforceResidency() {
1232
+ const deps = this.residencyDeps;
1233
+ if (deps === null) return;
1234
+ const { budget } = deps;
1235
+ let rows = this.residentRowCount();
1236
+ let hours = this.residentHourCount();
1237
+ if (rows <= budget.residentRowBudget && hours <= budget.residentHourBudget) return;
1238
+ const rowTarget = Math.floor(budget.residentRowBudget * budget.evictToFraction);
1239
+ const hourTarget = Math.floor(budget.residentHourBudget * budget.evictToFraction);
1240
+ const pinnedFrom = hourOf$1(deps.now()) - Math.max(0, budget.pinnedTailHours - 1) * HOUR_MS$9;
1241
+ const counts = this.hourHistogram();
1242
+ const candidates = [];
1243
+ for (const [deviceId, byHour] of counts) {
1244
+ const walkedHours = this.walked.get(deviceId);
1245
+ for (const [hourStartMs, count] of byHour) {
1246
+ if (hourStartMs >= pinnedFrom) continue;
1247
+ candidates.push({
1248
+ deviceId,
1249
+ hourStartMs,
1250
+ rows: count,
1251
+ lastUse: walkedHours?.get(hourStartMs) ?? 0
1252
+ });
1253
+ }
1254
+ }
1255
+ candidates.sort((a, b) => a.lastUse - b.lastUse || a.hourStartMs - b.hourStartMs);
1256
+ const victims = /* @__PURE__ */ new Map();
1257
+ let droppedRows = 0;
1258
+ let droppedHours = 0;
1259
+ for (const c of candidates) {
1260
+ if (rows <= rowTarget && hours <= hourTarget) break;
1261
+ let forDevice = victims.get(c.deviceId);
1262
+ if (!forDevice) {
1263
+ forDevice = /* @__PURE__ */ new Set();
1264
+ victims.set(c.deviceId, forDevice);
1265
+ }
1266
+ forDevice.add(c.hourStartMs);
1267
+ rows -= c.rows;
1268
+ droppedRows += c.rows;
1269
+ if (this.walked.get(c.deviceId)?.has(c.hourStartMs) === true) {
1270
+ hours -= 1;
1271
+ droppedHours += 1;
1272
+ }
1273
+ }
1274
+ if (victims.size === 0) return;
1275
+ this.evictionPasses += 1;
1276
+ this.evictedRows += droppedRows;
1277
+ this.evictedHours += droppedHours;
1278
+ for (const [deviceId, hoursToDrop] of victims) this.dropHours(deviceId, hoursToDrop, rows, budget, deps.logger);
1279
+ }
1280
+ /** `deviceId → hourStartMs → resident rows`, including hours walked and found
1281
+ * empty (count 0) — those hold no rows and would otherwise never be dropped,
1282
+ * leaving the walked map to grow without bound on its own. */
1283
+ hourHistogram() {
1284
+ const out = /* @__PURE__ */ new Map();
1285
+ for (const [deviceId, m] of this.byDevice) {
1286
+ const byHour = /* @__PURE__ */ new Map();
1287
+ for (const s of m.values()) {
1288
+ const h = hourOf$1(s.startMs);
1289
+ byHour.set(h, (byHour.get(h) ?? 0) + 1);
1290
+ }
1291
+ out.set(deviceId, byHour);
1292
+ }
1293
+ for (const [deviceId, w] of this.walked) {
1294
+ let byHour = out.get(deviceId);
1295
+ if (!byHour) {
1296
+ byHour = /* @__PURE__ */ new Map();
1297
+ out.set(deviceId, byHour);
1298
+ }
1299
+ for (const h of w.keys()) if (!byHour.has(h)) byHour.set(h, 0);
1300
+ }
1301
+ return out;
1302
+ }
1303
+ /** Delete one device's victim hours — rows and walked markers together — and
1304
+ * say so. A structure that silently forgets is how this class of bug hides. */
1305
+ dropHours(deviceId, hoursToDrop, residentRowsAfter, budget, logger) {
1306
+ const m = this.byDevice.get(deviceId);
1307
+ let droppedRows = 0;
1308
+ if (m) {
1309
+ for (const [path, s] of m) {
1310
+ if (!hoursToDrop.has(hourOf$1(s.startMs))) continue;
1311
+ m.delete(path);
1312
+ droppedRows += 1;
1313
+ }
1314
+ if (m.size === 0) this.byDevice.delete(deviceId);
1315
+ }
1316
+ const w = this.walked.get(deviceId);
1317
+ if (w) {
1318
+ for (const h of hoursToDrop) w.delete(h);
1319
+ if (w.size === 0) this.walked.delete(deviceId);
1320
+ }
1321
+ this.invalidateSorted(deviceId);
1322
+ this.evictedHoursByDevice.set(deviceId, (this.evictedHoursByDevice.get(deviceId) ?? 0) + hoursToDrop.size);
1323
+ let oldestHourMs = Infinity;
1324
+ let newestHourMs = -Infinity;
1325
+ for (const h of hoursToDrop) {
1326
+ if (h < oldestHourMs) oldestHourMs = h;
1327
+ if (h > newestHourMs) newestHourMs = h;
1328
+ }
1329
+ logger.info("recorder: index dropped resident hours — over the residency budget", {
1330
+ tags: { deviceId },
1331
+ meta: {
1332
+ hours: hoursToDrop.size,
1333
+ rows: droppedRows,
1334
+ oldestHourMs,
1335
+ newestHourMs,
1336
+ residentRowsAfter,
1337
+ rowBudget: budget.residentRowBudget,
1338
+ hourBudget: budget.residentHourBudget,
1339
+ costOfRecovery: "readdir-per-hour"
1340
+ }
1341
+ });
1097
1342
  }
1098
1343
  /**
1099
1344
  * Merge one hour's segments for a location, and mark that hour walked.
@@ -1115,6 +1360,7 @@ var RecordingIndex = class {
1115
1360
  }
1116
1361
  if (changed) this.invalidateSorted(deviceId);
1117
1362
  this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1363
+ this.enforceResidency();
1118
1364
  }
1119
1365
  /**
1120
1366
  * Make one hour of ONE location exactly `relPaths` — merge what is there and
@@ -1145,6 +1391,7 @@ var RecordingIndex = class {
1145
1391
  for (const path of dropped) m.delete(path);
1146
1392
  if (dropped.length > 0) this.invalidateSorted(deviceId);
1147
1393
  this.hydrateHour(deviceId, locationId, hourStartMs, relPaths);
1394
+ this.enforceResidency();
1148
1395
  return dropped;
1149
1396
  }
1150
1397
  /**
@@ -1169,6 +1416,7 @@ var RecordingIndex = class {
1169
1416
  return;
1170
1417
  }
1171
1418
  this.insertIntoSortedViews(s);
1419
+ this.enforceResidency();
1172
1420
  }
1173
1421
  /** Remove segments by relative path, routing each path to the correct device map via its encoded deviceId. @residency mutation */
1174
1422
  removeSegments(paths) {
@@ -1196,6 +1444,7 @@ var RecordingIndex = class {
1196
1444
  * @residency resident-scoped
1197
1445
  */
1198
1446
  segmentsStartingIn(deviceId, profile, fromMs, toMs) {
1447
+ this.touch(deviceId, fromMs, toMs);
1199
1448
  const segs = this.sortedSegments(deviceId, profile);
1200
1449
  let lo = 0;
1201
1450
  let hi = segs.length;
@@ -1224,6 +1473,7 @@ var RecordingIndex = class {
1224
1473
  * @residency resident-scoped
1225
1474
  */
1226
1475
  segmentStartingAt(deviceId, profile, startMs) {
1476
+ this.touch(deviceId, startMs, startMs + 1);
1227
1477
  const segs = this.sortedSegments(deviceId, profile);
1228
1478
  const at = segs[lowerBoundByStart(segs, startMs)];
1229
1479
  return at !== void 0 && at.startMs === startMs ? at : null;
@@ -1247,6 +1497,7 @@ var RecordingIndex = class {
1247
1497
  * @residency resident-scoped
1248
1498
  */
1249
1499
  segmentsOverlapping(deviceId, profile, fromMs, toMs) {
1500
+ this.touch(deviceId, fromMs - this.maxDurMs, toMs);
1250
1501
  const segs = this.sortedSegments(deviceId, profile);
1251
1502
  const end = lowerBoundByStart(segs, toMs);
1252
1503
  let head = lowerBoundByStart(segs, fromMs);
@@ -1408,6 +1659,7 @@ var RecordingIndex = class {
1408
1659
  }
1409
1660
  /** The segment whose [startMs, startMs+durMs) window contains `epochMs`, or null if `epochMs` falls in a gap / there is no footage. @residency resident-scoped */
1410
1661
  segmentAt(deviceId, profile, epochMs) {
1662
+ this.touch(deviceId, epochMs - this.maxDurMs, epochMs + 1);
1411
1663
  return this.segmentAtIn(this.sortedSegments(deviceId, profile), epochMs);
1412
1664
  }
1413
1665
  /**
@@ -1417,6 +1669,7 @@ var RecordingIndex = class {
1417
1669
  * @residency resident-scoped
1418
1670
  */
1419
1671
  nearestCoveredEdge(deviceId, profile, epochMs) {
1672
+ this.touch(deviceId, epochMs - this.maxDurMs, epochMs + 1);
1420
1673
  const segs = this.sortedSegments(deviceId, profile);
1421
1674
  if (segs.length === 0) return null;
1422
1675
  if (this.segmentAtIn(segs, epochMs) !== null) return epochMs;
@@ -1470,6 +1723,7 @@ var RecordingIndex = class {
1470
1723
  * @residency resident-scoped
1471
1724
  */
1472
1725
  coveredEdgeBefore(deviceId, profile, epochMs) {
1726
+ this.touch(deviceId, epochMs - this.maxDurMs, epochMs + 1);
1473
1727
  const segs = this.sortedSegments(deviceId, profile);
1474
1728
  let best = null;
1475
1729
  for (let i = upperBoundByStart(segs, epochMs) - 1; i >= 0; i--) {
@@ -1482,6 +1736,7 @@ var RecordingIndex = class {
1482
1736
  }
1483
1737
  /** @residency resident-scoped */
1484
1738
  ranges(deviceId, profile, fromMs, toMs, gapToleranceMs) {
1739
+ this.touch(deviceId, fromMs, toMs);
1485
1740
  return mergeRanges(this.sortedSegments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
1486
1741
  startMs: Math.max(r.startMs, fromMs),
1487
1742
  endMs: Math.min(r.endMs, toMs)
@@ -1716,6 +1971,7 @@ async function resolveRecordingsLocations(api, logger) {
1716
1971
  async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
1717
1972
  const yieldBetween = options.yieldBetween ?? macrotask$3;
1718
1973
  const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
1974
+ index.beginWalk(deviceId);
1719
1975
  for (const [root, aliases] of aliasesByRoot(locations)) {
1720
1976
  const deviceDir = path.join(root, String(deviceId));
1721
1977
  const startedMs = Date.now();
@@ -10049,10 +10305,16 @@ var SegmentHourLedger = class SegmentHourLedger {
10049
10305
  else fromIndex.set(key, appendSegmentPath(existing, seg.path));
10050
10306
  }
10051
10307
  const refused = this.refusedPrunes(walked, fromIndex);
10308
+ const trimmed = /* @__PURE__ */ new Set();
10309
+ for (const deviceId of walked) {
10310
+ if (!index.droppedDuringWalk(deviceId)) continue;
10311
+ trimmed.add(deviceId);
10312
+ this.logger.warn("recorder: ledger prune REFUSED — the RAM index dropped hours during this walk, so its silence is not evidence", { tags: { deviceId } });
10313
+ }
10052
10314
  for (const row of this.ledger.snapshot()) {
10053
10315
  if (!walked.has(row.deviceId)) continue;
10054
10316
  const disk = fromIndex.get(row.key);
10055
- const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
10317
+ const additiveOnly = row.hourStartMs === currentHour || trimmed.has(row.deviceId) || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
10056
10318
  if (disk === void 0) {
10057
10319
  if (additiveOnly) continue;
10058
10320
  await this.ledger.forget(row.key);
@@ -17790,6 +18052,11 @@ var RecorderV2Addon = class extends BaseAddon {
17790
18052
  }
17791
18053
  async onInitialize() {
17792
18054
  this.stopStallMonitor = startEventLoopStallMonitor(this.ctx.logger);
18055
+ this.index.armResidency({
18056
+ logger: this.ctx.logger,
18057
+ now: () => Date.now(),
18058
+ budget: DEFAULT_RECORDING_INDEX_BUDGET
18059
+ });
17793
18060
  const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
17794
18061
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
17795
18062
  const configuredNode = this.config.recordingNodeId;
@@ -18785,9 +19052,16 @@ var RecorderV2Addon = class extends BaseAddon {
18785
19052
  const census = this.index.sortedViewCensus();
18786
19053
  const views = this.index.sortedViewStats();
18787
19054
  const reuse = this.index.hydrateReuseStats();
19055
+ const residency = this.index.residency();
18788
19056
  this.ctx.logger.info("recorder: index census", {
18789
19057
  ...census.largestDeviceId > 0 ? { tags: { deviceId: census.largestDeviceId } } : {},
18790
19058
  meta: {
19059
+ residentRows: residency.residentRows,
19060
+ residentHours: residency.residentHours,
19061
+ rowBudget: residency.budget?.residentRowBudget ?? null,
19062
+ evictedRows: residency.evictedRows,
19063
+ evictedHours: residency.evictedHours,
19064
+ evictionPasses: residency.evictionPasses,
18791
19065
  sortedViews: census.views,
18792
19066
  rowSlots: census.rowSlots,
18793
19067
  largestViewRows: census.largestRows,
@@ -66,6 +66,45 @@ function capPreRollRequestMs(requestedMs, retainedMs) {
66
66
  if (!Number.isFinite(requestedMs) || requestedMs <= 0) return 0;
67
67
  return Math.min(requestedMs, retained);
68
68
  }
69
+ //#endregion
70
+ //#region src/stream-broker/rtsp/restream-intent.ts
71
+ /**
72
+ * The dial DECLARATION a restream consumer makes on the RTSP path: what it is,
73
+ * and how much retained history it asks for.
74
+ *
75
+ * The restreamer's join rules key on what kind of consumer is joining, and the
76
+ * only thing an RTSP dial can carry before a session exists is its path. Today
77
+ * that path already encodes one bit — `/muted`, "video only, and I am a local
78
+ * decoder loopback" — and `RtspRestreamer.servePendingSessionsRtp` uses it to
79
+ * withhold the catch-up burst from anything muted whose burst is older than
80
+ * `LIVE_EDGE_MAX_SPAN_MS`.
81
+ *
82
+ * That rule was measured for re-clocked consumers (a HomeKit leg that is an
83
+ * ffmpeg copy of this restream) which have ~zero decode headroom and never
84
+ * drain a 2-3 s backlog. A per-session DETECTION decode is the opposite: it
85
+ * decodes at source rate and publishes throttled by the FrameSlot, so it drains
86
+ * the burst and starts producing frames immediately — and on a ~2 s-GOP camera
87
+ * the withhold instead makes ~3 dials in 4 wait for the next live IDR, which is
88
+ * the dominant term in the blind ramp between motion and the first inferenced
89
+ * frame.
90
+ *
91
+ * So detection says so, in a SECOND path suffix rather than by widening the
92
+ * meaning of `isMuted()`. Every other muted consumer keeps today's behaviour
93
+ * byte-for-byte, because none of them dials this path.
94
+ *
95
+ * Writer and reader live in this one module on purpose: a suffix written by the
96
+ * decode dial and parsed by the listen server has no test that catches them
97
+ * drifting apart if they are declared in two files.
98
+ *
99
+ * The AMOUNT is the second half of the declaration, and it is a D382 addition.
100
+ * D381 made the broker the only thing that decides how much history exists and
101
+ * left the consumer's ask implicit — so declaring an intent, which detection
102
+ * does for the live-edge exemption above, meant being handed the whole retained
103
+ * window. An unstated amount is ZERO here: history reaches a consumer that
104
+ * named a number and nobody else.
105
+ */
106
+ /** Path suffix for the audio-free variant of a restream token. */
107
+ var MUTED_PATH_SUFFIX = "/muted";
69
108
  /**
70
109
  * Path suffix for the audio-free variant dialed by a DETECTION decode session.
71
110
  * Muted in every respect (no audio, still `isMuted()`), plus exempt from the
@@ -84,6 +123,23 @@ var DETECTION_MUTED_PATH_SUFFIX = "/muted-detection";
84
123
  */
85
124
  var RECORDING_PATH_SUFFIX = "/recording";
86
125
  /**
126
+ * The SENTRY counterpart of a profile's restream.
127
+ *
128
+ * Every profile gets one, exactly as every profile gets a `/muted`: a consumer
129
+ * dials `<token>/sentry` instead of `<token>` and is asking for the stream
130
+ * WITHOUT asking for the camera. It is fed the last picture while the camera
131
+ * sleeps, and the real media the moment something wakes it.
132
+ *
133
+ * A separate path and not a flag, for the reason `/muted` is one: the choice
134
+ * belongs to the URL a consumer was given, so a Frigate configured once keeps
135
+ * asking for the same thing without a per-session negotiation, and a consumer
136
+ * that was given the ordinary URL cannot accidentally be served a still image.
137
+ *
138
+ * D382 in one line: the ask is explicit and absent means zero. A path with no
139
+ * suffix is live intent and behaves exactly as it always has.
140
+ */
141
+ var SENTRY_PATH_SUFFIX = "/sentry";
142
+ /**
87
143
  * Path segment prefix carrying the AMOUNT of retained history a dial asks for.
88
144
  *
89
145
  * A path segment and not a query string: the listen server matches the request
@@ -249,6 +305,34 @@ function withRecordingIntent(restreamUrl, requestMs) {
249
305
  if (rest.endsWith("/recording")) return `${rest}${requestSegment(requestMs)}`;
250
306
  return `${rest}${RECORDING_PATH_SUFFIX}${requestSegment(requestMs)}`;
251
307
  }
308
+ /**
309
+ * Rewrite an acquired restream URL into the SENTRY dial — the counterpart of
310
+ * {@link withDetectionIntent} and {@link withRecordingIntent}, and the one the
311
+ * sentry path shipped without.
312
+ *
313
+ * "sentryMuted", which is what the operator asked a composite camera to take
314
+ * from a battery source, is not a second intent: {@link SENTRY_PATH_SUFFIX} is
315
+ * already muted by construction (`parseRestreamPath` answers `muted: true` for
316
+ * it, because an `m=audio` nobody fills stalls a consumer for the hours a
317
+ * camera may sit asleep). So this REPLACES whatever the url declared rather
318
+ * than appending to it — `<token>/muted/sentry` would parse back to the token
319
+ * `<token>/muted`, a lookup that finds nothing and says nothing.
320
+ *
321
+ * The pre-roll request is dropped for the same reason the sentry declares none:
322
+ * there is no history in a still picture, and D382 says the ask is explicit.
323
+ *
324
+ * Idempotent. Safe on any of the url shapes the broker hands out.
325
+ */
326
+ function withSentryIntent(restreamUrl) {
327
+ const { rest } = splitRequest(restreamUrl);
328
+ for (const suffix of [
329
+ SENTRY_PATH_SUFFIX,
330
+ RECORDING_PATH_SUFFIX,
331
+ DETECTION_MUTED_PATH_SUFFIX,
332
+ MUTED_PATH_SUFFIX
333
+ ]) if (rest.endsWith(suffix)) return `${rest.slice(0, -suffix.length)}${SENTRY_PATH_SUFFIX}`;
334
+ return `${rest}${SENTRY_PATH_SUFFIX}`;
335
+ }
252
336
  //#endregion
253
337
  Object.defineProperty(exports, "DETECTION_PRE_ROLL_REQUEST_MS", {
254
338
  enumerable: true,
@@ -316,3 +400,9 @@ Object.defineProperty(exports, "withRecordingIntent", {
316
400
  return withRecordingIntent;
317
401
  }
318
402
  });
403
+ Object.defineProperty(exports, "withSentryIntent", {
404
+ enumerable: true,
405
+ get: function() {
406
+ return withSentryIntent;
407
+ }
408
+ });