@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.
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-BTzxxm-z.mjs → default-detection-model-BZ54sUY2.mjs} +1 -1
- package/dist/{default-detection-model-C8CHpxQl.js → default-detection-model-vVWcdv0g.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-BMARLLlX.js → dist-CsJJPDQW.js} +154 -1
- package/dist/{dist-xsaGDCOu.mjs → dist-D3w2fwD4.mjs} +154 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Ca-vE0xX.js → node-1vbEWzPZ.js} +1 -1
- package/dist/{node-Fv4ByT36.mjs → node-IHGNrWH9.mjs} +1 -1
- package/dist/pipeline-runner/index.js +4 -4
- package/dist/pipeline-runner/index.mjs +4 -4
- package/dist/{process-memory-C3Wjmvm0.js → process-memory-DynU4177.js} +1 -1
- package/dist/{process-memory-BgtQADAY.mjs → process-memory-jhOBJEil.mjs} +1 -1
- package/dist/recorder/index.js +293 -19
- package/dist/recorder/index.mjs +293 -19
- package/dist/{restream-intent-VVJiUAkh.js → restream-intent-B0GOuR-F.js} +90 -0
- package/dist/{restream-intent-D2VT97-U.mjs → restream-intent-CgyZI565.mjs} +85 -1
- package/dist/{segment-demux-js-C7C6c-g2.mjs → segment-demux-js-BZAiu_2h.mjs} +1 -1
- package/dist/{segment-demux-js-DHV7EOQf.js → segment-demux-js-OQT6wqXH.js} +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-BzBcvawL.mjs → hostInit-rU23F6nl.mjs} +3 -3
- package/dist/stream-broker/index.js +46 -7
- package/dist/stream-broker/index.mjs +46 -7
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- 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
package/dist/recorder/index.mjs
CHANGED
|
@@ -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-
|
|
2
|
-
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
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 {
|
|
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
|
-
/**
|
|
873
|
-
*
|
|
874
|
-
*
|
|
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
|
|
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 ===
|
|
1090
|
-
const
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
/**
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
+
});
|