@camstack/addon-pipeline 1.1.57 → 1.1.59
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 +4 -4
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +9 -9
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
- package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
- package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
- package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +147 -12
- package/dist/pipeline-runner/index.mjs +146 -11
- package/dist/recorder/index.js +993 -7
- package/dist/recorder/index.mjs +990 -5
- package/dist/session-decode/decode-worker-child.js +110 -2
- package/dist/session-decode/decode-worker-child.mjs +110 -2
- package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
- package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
- package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
- package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXctoaw.mjs +26 -0
- package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
- package/dist/stream-broker/index.js +626 -152
- package/dist/stream-broker/index.mjs +626 -152
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
- package/embed-dist/assets/index-W9-H5LTj.js +81 -0
- package/embed-dist/index.html +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-Ba7Eyd47.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs +0 -26
- package/embed-dist/assets/index-DOsK8CZb.js +0 -81
package/dist/recorder/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
const require_chunk = require("../chunk-D6vf50IK.js");
|
|
2
|
-
const require_dist = require("../dist-
|
|
3
|
-
const
|
|
4
|
-
const
|
|
2
|
+
const require_dist = require("../dist-B1u-QN05.js");
|
|
3
|
+
const require_sheet_geometry = require("../sheet-geometry-BN80zpCa.js");
|
|
4
|
+
const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-Cp_GTKTw.js");
|
|
5
5
|
let node_child_process = require("node:child_process");
|
|
6
|
+
let sharp = require("sharp");
|
|
7
|
+
sharp = require_chunk.__toESM(sharp);
|
|
6
8
|
let node_fs = require("node:fs");
|
|
7
9
|
let node_path = require("node:path");
|
|
8
10
|
node_path = require_chunk.__toESM(node_path);
|
|
11
|
+
let node_os = require("node:os");
|
|
9
12
|
//#region src/recorder/segment-path.ts
|
|
10
13
|
function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
|
|
11
14
|
if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
|
|
@@ -199,6 +202,22 @@ var RecordingIndex = class {
|
|
|
199
202
|
const next = segs.find((s) => s.startMs >= epochMs);
|
|
200
203
|
return next ? next.startMs : null;
|
|
201
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The exclusive END of the nearest footage ending at-or-before `epochMs`
|
|
207
|
+
* (the backward covered edge), or null when no footage ends at or before it.
|
|
208
|
+
* Backward counterpart of `nearestCoveredEdge`: consecutive segments leave
|
|
209
|
+
* small cracks (`startMs + durMs` < next `startMs` by ~11-17 ms), so a
|
|
210
|
+
* backward frame-step probing `startMs − 1` lands in a crack — this edge
|
|
211
|
+
* lets the caller hop to the true previous segment.
|
|
212
|
+
*/
|
|
213
|
+
coveredEdgeBefore(deviceId, profile, epochMs) {
|
|
214
|
+
let best = null;
|
|
215
|
+
for (const s of this.segments(deviceId, profile)) {
|
|
216
|
+
const end = s.startMs + s.durMs;
|
|
217
|
+
if (end <= epochMs && (best === null || end > best)) best = end;
|
|
218
|
+
}
|
|
219
|
+
return best;
|
|
220
|
+
}
|
|
202
221
|
ranges(deviceId, profile, fromMs, toMs, gapToleranceMs) {
|
|
203
222
|
return mergeRanges(this.segments(deviceId, profile).filter((s) => s.startMs + s.durMs > fromMs && s.startMs < toMs), gapToleranceMs).map((r) => ({
|
|
204
223
|
startMs: Math.max(r.startMs, fromMs),
|
|
@@ -1045,7 +1064,8 @@ function buildRecordingProvider(deps) {
|
|
|
1045
1064
|
};
|
|
1046
1065
|
return {
|
|
1047
1066
|
kind: "gap",
|
|
1048
|
-
nearestEdgeMs: deps.index.nearestCoveredEdge(deviceId, profile, epochMs)
|
|
1067
|
+
nearestEdgeMs: deps.index.nearestCoveredEdge(deviceId, profile, epochMs),
|
|
1068
|
+
prevEndMs: deps.index.coveredEdgeBefore(deviceId, profile, epochMs)
|
|
1049
1069
|
};
|
|
1050
1070
|
},
|
|
1051
1071
|
readSegmentBytes: async ({ deviceId, profile, startMs }) => {
|
|
@@ -2045,7 +2065,924 @@ function makeBrokerCall(handle) {
|
|
|
2045
2065
|
* resolution order and semantics are unchanged (operator override first, then
|
|
2046
2066
|
* the host extracted from `CAMSTACK_HUB_URL`).
|
|
2047
2067
|
*/
|
|
2048
|
-
var resolveRecordingHubHostname =
|
|
2068
|
+
var resolveRecordingHubHostname = require_sheet_geometry.resolveHubHostname;
|
|
2069
|
+
//#endregion
|
|
2070
|
+
//#region src/thumbnails/thumb-index.ts
|
|
2071
|
+
/**
|
|
2072
|
+
* Build the index + geometry for a window's samples. Samples are sorted by
|
|
2073
|
+
* capture time; each gets a row-major placement. Throws when there are no
|
|
2074
|
+
* samples (an empty window is never written).
|
|
2075
|
+
*/
|
|
2076
|
+
function buildThumbnailIndex(input) {
|
|
2077
|
+
if (input.samples.length === 0) throw new Error("buildThumbnailIndex: no samples");
|
|
2078
|
+
const tileWidth = input.tileWidth ?? 320;
|
|
2079
|
+
const tileHeight = input.tileHeight ?? 180;
|
|
2080
|
+
const maxCols = input.maxCols ?? 10;
|
|
2081
|
+
const ordered = [...input.samples].sort((a, b) => a.t - b.t);
|
|
2082
|
+
const geometry = require_sheet_geometry.computeSheetGeometry(ordered.length, {
|
|
2083
|
+
tileWidth,
|
|
2084
|
+
tileHeight,
|
|
2085
|
+
maxCols
|
|
2086
|
+
});
|
|
2087
|
+
const placements = require_sheet_geometry.allTilePlacements(ordered.length, geometry);
|
|
2088
|
+
const tiles = ordered.map((s, i) => {
|
|
2089
|
+
const p = placements[i];
|
|
2090
|
+
return {
|
|
2091
|
+
t: s.t,
|
|
2092
|
+
x: p.x,
|
|
2093
|
+
y: p.y
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
return {
|
|
2097
|
+
index: {
|
|
2098
|
+
version: 1,
|
|
2099
|
+
deviceId: input.deviceId,
|
|
2100
|
+
windowStartMs: input.windowStartMs,
|
|
2101
|
+
windowMs: input.windowMs,
|
|
2102
|
+
intervalMs: input.intervalMs,
|
|
2103
|
+
tileWidth,
|
|
2104
|
+
tileHeight,
|
|
2105
|
+
cols: geometry.cols,
|
|
2106
|
+
rows: geometry.rows,
|
|
2107
|
+
sheet: `sheet-${input.windowStartMs}.jpg`,
|
|
2108
|
+
source: input.source,
|
|
2109
|
+
tiles
|
|
2110
|
+
},
|
|
2111
|
+
geometry,
|
|
2112
|
+
orderedSamples: ordered
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
//#endregion
|
|
2116
|
+
//#region src/thumbnails/thumb-window.ts
|
|
2117
|
+
/**
|
|
2118
|
+
* Thumbnail window + on-disk path math — pure, UTC-aligned, deterministic.
|
|
2119
|
+
*
|
|
2120
|
+
* A thumbnail "window" is a fixed UTC-aligned span (default 5 min). Every
|
|
2121
|
+
* timestamp maps to exactly one window; the window start, its UTC bucket
|
|
2122
|
+
* (Y/M/D/H like the segment store), and the sheet/index filenames are all
|
|
2123
|
+
* derived from it with NO state — so the recorder writes and the viewer reads
|
|
2124
|
+
* the SAME deterministic path from a bare timestamp (no directory listing, no
|
|
2125
|
+
* cap round-trip).
|
|
2126
|
+
*/
|
|
2127
|
+
/** Default window span — 5 minutes. */
|
|
2128
|
+
var DEFAULT_WINDOW_MS = 3e5;
|
|
2129
|
+
/** Zero-padded 2-digit. */
|
|
2130
|
+
function p2(n) {
|
|
2131
|
+
return String(n).padStart(2, "0");
|
|
2132
|
+
}
|
|
2133
|
+
/** UTC-aligned window start (epoch ms) containing `t`. */
|
|
2134
|
+
function windowStartFor(t, windowMs = DEFAULT_WINDOW_MS) {
|
|
2135
|
+
if (!Number.isFinite(t)) throw new Error(`windowStartFor: t must be finite, got ${t}`);
|
|
2136
|
+
if (!(windowMs > 0)) throw new Error(`windowStartFor: windowMs must be > 0, got ${windowMs}`);
|
|
2137
|
+
return Math.floor(t / windowMs) * windowMs;
|
|
2138
|
+
}
|
|
2139
|
+
/** UTC `Y/M/D/H` bucket dir (relative) for a window start — mirrors
|
|
2140
|
+
* `segmentRelPath`'s bucketing so thumbs co-locate with the same hour tree. */
|
|
2141
|
+
function bucketDir(windowStartMs) {
|
|
2142
|
+
const d = new Date(windowStartMs);
|
|
2143
|
+
return `${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
2144
|
+
}
|
|
2145
|
+
/** Sheet image filename for a window start. */
|
|
2146
|
+
function sheetFileName(windowStartMs) {
|
|
2147
|
+
return `sheet-${windowStartMs}.jpg`;
|
|
2148
|
+
}
|
|
2149
|
+
/** Index sidecar filename for a window start. */
|
|
2150
|
+
function indexFileName(windowStartMs) {
|
|
2151
|
+
return `index-${windowStartMs}.json`;
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Device-relative directory (under the thumbs root) for a window:
|
|
2155
|
+
* `<deviceId>/<Y>/<M>/<D>/<H>`. The device is the top segment (thumbs are
|
|
2156
|
+
* profile-agnostic, unlike segments which nest under a profile).
|
|
2157
|
+
*/
|
|
2158
|
+
function windowRelDir(deviceId, windowStartMs) {
|
|
2159
|
+
return `${deviceId}/${bucketDir(windowStartMs)}`;
|
|
2160
|
+
}
|
|
2161
|
+
//#endregion
|
|
2162
|
+
//#region src/thumbnails/thumbnail-sink.ts
|
|
2163
|
+
var ThumbnailSink = class {
|
|
2164
|
+
deps;
|
|
2165
|
+
now;
|
|
2166
|
+
open = /* @__PURE__ */ new Map();
|
|
2167
|
+
lastFlushed = /* @__PURE__ */ new Map();
|
|
2168
|
+
/** In-flight flush promises per device — chained so a device never composes
|
|
2169
|
+
* two windows concurrently (ordering + bounded work). */
|
|
2170
|
+
flushChain = /* @__PURE__ */ new Map();
|
|
2171
|
+
constructor(deps) {
|
|
2172
|
+
this.deps = deps;
|
|
2173
|
+
this.now = deps.now ?? (() => Date.now());
|
|
2174
|
+
}
|
|
2175
|
+
/** Accept a sampled thumb. Rolls over (flushes) the device's open window when
|
|
2176
|
+
* a strictly-later window's sample arrives. */
|
|
2177
|
+
add(deviceId, sample) {
|
|
2178
|
+
const windowStartMs = windowStartFor(sample.capturedAt, this.deps.windowMs);
|
|
2179
|
+
const lastFlushed = this.lastFlushed.get(deviceId);
|
|
2180
|
+
if (lastFlushed !== void 0 && windowStartMs <= lastFlushed) return;
|
|
2181
|
+
let cur = this.open.get(deviceId);
|
|
2182
|
+
if (cur && windowStartMs > cur.windowStartMs) {
|
|
2183
|
+
this.scheduleFlush(deviceId, cur);
|
|
2184
|
+
this.open.delete(deviceId);
|
|
2185
|
+
cur = void 0;
|
|
2186
|
+
}
|
|
2187
|
+
if (cur && windowStartMs < cur.windowStartMs) return;
|
|
2188
|
+
if (!cur) {
|
|
2189
|
+
cur = {
|
|
2190
|
+
windowStartMs,
|
|
2191
|
+
samples: []
|
|
2192
|
+
};
|
|
2193
|
+
this.open.set(deviceId, cur);
|
|
2194
|
+
}
|
|
2195
|
+
if (cur.samples.length < this.deps.maxTilesPerWindow) cur.samples.push(sample);
|
|
2196
|
+
}
|
|
2197
|
+
/** Flush every open window whose end + grace has passed (idle cameras). */
|
|
2198
|
+
flushExpired() {
|
|
2199
|
+
const now = this.now();
|
|
2200
|
+
for (const [deviceId, win] of [...this.open]) if (now - (win.windowStartMs + this.deps.windowMs) >= this.deps.idleGraceMs) {
|
|
2201
|
+
this.scheduleFlush(deviceId, win);
|
|
2202
|
+
this.open.delete(deviceId);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
/** Flush all open windows (shutdown). Returns when every flush settles. */
|
|
2206
|
+
async flushAll() {
|
|
2207
|
+
for (const [deviceId, win] of [...this.open]) {
|
|
2208
|
+
this.scheduleFlush(deviceId, win);
|
|
2209
|
+
this.open.delete(deviceId);
|
|
2210
|
+
}
|
|
2211
|
+
await Promise.all([...this.flushChain.values()]);
|
|
2212
|
+
}
|
|
2213
|
+
/** Test/diagnostic: number of open windows. */
|
|
2214
|
+
get openWindowCount() {
|
|
2215
|
+
return this.open.size;
|
|
2216
|
+
}
|
|
2217
|
+
scheduleFlush(deviceId, win) {
|
|
2218
|
+
const next = (this.flushChain.get(deviceId) ?? Promise.resolve()).then(() => this.doFlush(deviceId, win)).catch((err) => {
|
|
2219
|
+
this.deps.logger.warn("thumbnail sink: window flush failed", {
|
|
2220
|
+
tags: { deviceId },
|
|
2221
|
+
meta: {
|
|
2222
|
+
windowStartMs: win.windowStartMs,
|
|
2223
|
+
error: err instanceof Error ? err.message : err
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
});
|
|
2227
|
+
this.flushChain.set(deviceId, next);
|
|
2228
|
+
this.lastFlushed.set(deviceId, Math.max(this.lastFlushed.get(deviceId) ?? -1, win.windowStartMs));
|
|
2229
|
+
}
|
|
2230
|
+
async doFlush(deviceId, win) {
|
|
2231
|
+
if (win.samples.length === 0) return;
|
|
2232
|
+
const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
|
|
2233
|
+
const built = buildThumbnailIndex({
|
|
2234
|
+
deviceId,
|
|
2235
|
+
windowStartMs: win.windowStartMs,
|
|
2236
|
+
windowMs: this.deps.windowMs,
|
|
2237
|
+
intervalMs: this.deps.intervalMs,
|
|
2238
|
+
source: this.deps.source,
|
|
2239
|
+
tileWidth: this.deps.tileWidth,
|
|
2240
|
+
tileHeight: this.deps.tileHeight,
|
|
2241
|
+
maxCols: this.deps.maxCols,
|
|
2242
|
+
samples: ordered.map((s) => ({ t: s.capturedAt }))
|
|
2243
|
+
});
|
|
2244
|
+
const placements = require_sheet_geometry.allTilePlacements(ordered.length, built.geometry);
|
|
2245
|
+
const sheetBytes = await this.deps.compose({
|
|
2246
|
+
tiles: ordered.map((s) => s.jpeg),
|
|
2247
|
+
placements,
|
|
2248
|
+
geometry: built.geometry
|
|
2249
|
+
});
|
|
2250
|
+
await this.deps.persist({
|
|
2251
|
+
deviceId,
|
|
2252
|
+
windowStartMs: win.windowStartMs,
|
|
2253
|
+
relDir: windowRelDir(deviceId, win.windowStartMs),
|
|
2254
|
+
sheetName: sheetFileName(win.windowStartMs),
|
|
2255
|
+
sheetBytes,
|
|
2256
|
+
indexName: indexFileName(win.windowStartMs),
|
|
2257
|
+
index: built.index
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
//#endregion
|
|
2262
|
+
//#region src/thumbnails/sheet-composer.ts
|
|
2263
|
+
/**
|
|
2264
|
+
* Resize each tile to the uniform tile size and composite onto a black canvas
|
|
2265
|
+
* at its placement. Returns the sheet JPEG bytes. Throws if tiles/placements
|
|
2266
|
+
* lengths disagree.
|
|
2267
|
+
*/
|
|
2268
|
+
async function composeSheet(sharp, input) {
|
|
2269
|
+
const { tiles, placements, geometry } = input;
|
|
2270
|
+
if (tiles.length !== placements.length) throw new Error(`composeSheet: tiles(${tiles.length}) != placements(${placements.length})`);
|
|
2271
|
+
if (tiles.length === 0) throw new Error("composeSheet: no tiles");
|
|
2272
|
+
const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
|
|
2273
|
+
fit: "cover",
|
|
2274
|
+
position: "centre"
|
|
2275
|
+
}).jpeg({ quality: input.quality ?? 60 }).toBuffer()))).map((buf, i) => {
|
|
2276
|
+
const p = placements[i];
|
|
2277
|
+
return {
|
|
2278
|
+
input: buf,
|
|
2279
|
+
left: p.x,
|
|
2280
|
+
top: p.y
|
|
2281
|
+
};
|
|
2282
|
+
});
|
|
2283
|
+
return sharp({ create: {
|
|
2284
|
+
width: geometry.sheetWidth,
|
|
2285
|
+
height: geometry.sheetHeight,
|
|
2286
|
+
channels: 3,
|
|
2287
|
+
background: {
|
|
2288
|
+
r: 0,
|
|
2289
|
+
g: 0,
|
|
2290
|
+
b: 0
|
|
2291
|
+
}
|
|
2292
|
+
} }).composite(entries).jpeg({ quality: input.quality ?? 60 }).toBuffer();
|
|
2293
|
+
}
|
|
2294
|
+
//#endregion
|
|
2295
|
+
//#region src/thumbnails/thumbnail-persister.ts
|
|
2296
|
+
/**
|
|
2297
|
+
* Thumbnail disk persister — writes a window's sheet JPEG + index JSON
|
|
2298
|
+
* write-once under `<thumbsRoot>/<dev>/Y/M/D/H/`. Small fs wrapper the recorder
|
|
2299
|
+
* wires with a real root; kept separate so the sink stays fs-free and testable.
|
|
2300
|
+
*/
|
|
2301
|
+
/** Root dir thumbs are written under for a location (`<locationRoot>/.thumbs`). */
|
|
2302
|
+
var THUMBS_SUBDIR = ".thumbs";
|
|
2303
|
+
/**
|
|
2304
|
+
* Persist one window. Writes are best-effort atomic (write to a `.tmp` then
|
|
2305
|
+
* rename) so a crash mid-write never leaves a truncated sheet a viewer would
|
|
2306
|
+
* cache as `immutable`. Idempotent: an existing sheet is left as-is (write-once).
|
|
2307
|
+
*/
|
|
2308
|
+
async function persistThumbnailWindow(thumbsRoot, req) {
|
|
2309
|
+
const dir = node_path.default.join(thumbsRoot, req.relDir);
|
|
2310
|
+
await node_fs.promises.mkdir(dir, { recursive: true });
|
|
2311
|
+
const sheetPath = node_path.default.join(dir, req.sheetName);
|
|
2312
|
+
const indexPath = node_path.default.join(dir, req.indexName);
|
|
2313
|
+
await Promise.all([writeOnce(sheetPath, req.sheetBytes), writeOnce(indexPath, Buffer.from(JSON.stringify(req.index)))]);
|
|
2314
|
+
}
|
|
2315
|
+
async function writeOnce(absPath, bytes) {
|
|
2316
|
+
try {
|
|
2317
|
+
await node_fs.promises.access(absPath);
|
|
2318
|
+
return;
|
|
2319
|
+
} catch {}
|
|
2320
|
+
const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
|
|
2321
|
+
await node_fs.promises.writeFile(tmp, bytes);
|
|
2322
|
+
try {
|
|
2323
|
+
await node_fs.promises.rename(tmp, absPath);
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
await node_fs.promises.rm(tmp, { force: true }).catch(() => {});
|
|
2326
|
+
throw err;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
var HOUR_MS = 36e5;
|
|
2330
|
+
/**
|
|
2331
|
+
* Buckets whose whole hour ends before the retention cutoff. Cutoff = the
|
|
2332
|
+
* device's oldest indexed segment (thumbs live exactly as long as footage),
|
|
2333
|
+
* or `now - fallback` when the device has no segments.
|
|
2334
|
+
*/
|
|
2335
|
+
function planThumbPrune(input) {
|
|
2336
|
+
const cutoffMs = input.oldestSegmentStartMs ?? input.nowMs - 2592e6;
|
|
2337
|
+
return input.buckets.filter((b) => b.hourStartMs + HOUR_MS <= cutoffMs);
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Parse a `Y/M/D/H` relative bucket path (as walked under a device dir) into
|
|
2341
|
+
* its UTC hour start. Returns null for anything that isn't a valid bucket
|
|
2342
|
+
* (staging debris, foreign files) so the janitor never deletes what it does
|
|
2343
|
+
* not understand.
|
|
2344
|
+
*/
|
|
2345
|
+
function parseHourBucket(relYmdh) {
|
|
2346
|
+
const m = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{2})$/.exec(relYmdh);
|
|
2347
|
+
if (!m) return null;
|
|
2348
|
+
const [, y, mo, d, h] = m;
|
|
2349
|
+
const year = Number(y);
|
|
2350
|
+
const month = Number(mo);
|
|
2351
|
+
const day = Number(d);
|
|
2352
|
+
const hour = Number(h);
|
|
2353
|
+
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
|
|
2354
|
+
const t = Date.UTC(year, month - 1, day, hour);
|
|
2355
|
+
const dt = new Date(t);
|
|
2356
|
+
if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day || dt.getUTCHours() !== hour) return null;
|
|
2357
|
+
return t;
|
|
2358
|
+
}
|
|
2359
|
+
//#endregion
|
|
2360
|
+
//#region src/thumbnails/thumbs-media-handler.ts
|
|
2361
|
+
var IMMUTABLE = "public, max-age=31536000, immutable";
|
|
2362
|
+
/** Reject traversal / absolute / NUL and normalise leading slashes. Returns the
|
|
2363
|
+
* safe rel path or null. Exported for unit coverage. */
|
|
2364
|
+
function sanitizeThumbsPath(url) {
|
|
2365
|
+
const qIdx = url.indexOf("?");
|
|
2366
|
+
const raw = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
2367
|
+
let rel;
|
|
2368
|
+
try {
|
|
2369
|
+
rel = decodeURIComponent(raw.replace(/^\/+/, ""));
|
|
2370
|
+
} catch {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
if (rel.length === 0) return null;
|
|
2374
|
+
if (rel.includes("\0")) return null;
|
|
2375
|
+
if (rel.startsWith("/")) return null;
|
|
2376
|
+
if (rel.split("/").some((seg) => seg === ".." || seg === ".")) return null;
|
|
2377
|
+
if (!/\.(jpe?g|json)$/i.test(rel)) return null;
|
|
2378
|
+
return rel;
|
|
2379
|
+
}
|
|
2380
|
+
/** Content type for a thumbs file by extension. */
|
|
2381
|
+
function thumbsContentType(relPath) {
|
|
2382
|
+
if (/\.json$/i.test(relPath)) return "application/json";
|
|
2383
|
+
return "image/jpeg";
|
|
2384
|
+
}
|
|
2385
|
+
function createThumbsMediaHandler(deps) {
|
|
2386
|
+
return async (req, res) => {
|
|
2387
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2388
|
+
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
const rel = sanitizeThumbsPath(req.url ?? "/");
|
|
2392
|
+
if (rel === null) {
|
|
2393
|
+
res.writeHead(404).end();
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const etag = `"${rel}"`;
|
|
2397
|
+
if (req.headers["if-none-match"] === etag) {
|
|
2398
|
+
res.writeHead(304, {
|
|
2399
|
+
etag,
|
|
2400
|
+
"cache-control": IMMUTABLE
|
|
2401
|
+
}).end();
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
let file;
|
|
2405
|
+
try {
|
|
2406
|
+
file = await deps.getFile(rel);
|
|
2407
|
+
} catch {
|
|
2408
|
+
const body = "Internal server error";
|
|
2409
|
+
res.writeHead(500, {
|
|
2410
|
+
"content-type": "text/plain",
|
|
2411
|
+
"content-length": String(Buffer.byteLength(body))
|
|
2412
|
+
});
|
|
2413
|
+
if (req.method === "HEAD") res.end();
|
|
2414
|
+
else res.end(body);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
if (file === null) {
|
|
2418
|
+
res.writeHead(404).end();
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
res.writeHead(200, {
|
|
2422
|
+
"content-type": file.contentType,
|
|
2423
|
+
"cache-control": IMMUTABLE,
|
|
2424
|
+
etag,
|
|
2425
|
+
"content-length": String(file.bytes.byteLength)
|
|
2426
|
+
});
|
|
2427
|
+
if (req.method === "HEAD") res.end();
|
|
2428
|
+
else res.end(Buffer.from(file.bytes));
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
//#endregion
|
|
2432
|
+
//#region src/thumbnails/thumb-backfill-plan.ts
|
|
2433
|
+
/**
|
|
2434
|
+
* Backfill PLANNER — pure (no ffmpeg, no I/O). Given a device's low segments
|
|
2435
|
+
* and the window starts already covered by a sampled/backfilled index, decide
|
|
2436
|
+
* which windows still need a keyframe-only ffmpeg pass and which segments cover
|
|
2437
|
+
* each. The executor (recorder) runs at most ONE ffmpeg per planned window,
|
|
2438
|
+
* single-flighted; this module owns only the "what to do" decision so it is
|
|
2439
|
+
* fully unit-testable.
|
|
2440
|
+
*
|
|
2441
|
+
* A window is a backfill candidate when:
|
|
2442
|
+
* - at least one low segment overlaps it (there is footage to sample), AND
|
|
2443
|
+
* - no index already covers it (sampling didn't reach it), AND
|
|
2444
|
+
* - the window is fully in the past by `settleMs` (no new segment can still
|
|
2445
|
+
* land in it — mirrors the segment store's current-hour eviction guard).
|
|
2446
|
+
*/
|
|
2447
|
+
/** True when `[startMs, startMs+durMs)` overlaps `[winStart, winEnd)`. */
|
|
2448
|
+
function overlaps(startMs, durMs, winStart, winEnd) {
|
|
2449
|
+
return startMs < winEnd && startMs + durMs > winStart;
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Compute the ordered (ascending window) backfill plan. Deterministic and
|
|
2453
|
+
* bounded: returns at most `maxWindows` window plans (oldest first), each with
|
|
2454
|
+
* its overlapping segments.
|
|
2455
|
+
*/
|
|
2456
|
+
function planBackfill(input) {
|
|
2457
|
+
const { segments, coveredWindows, windowMs, now, settleMs } = input;
|
|
2458
|
+
const maxWindows = input.maxWindows ?? 500;
|
|
2459
|
+
if (!(windowMs > 0)) return [];
|
|
2460
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
2461
|
+
for (const seg of segments) {
|
|
2462
|
+
if (seg.durMs <= 0) continue;
|
|
2463
|
+
const firstW = windowStartFor(seg.startMs, windowMs);
|
|
2464
|
+
const lastW = windowStartFor(seg.startMs + seg.durMs - 1, windowMs);
|
|
2465
|
+
for (let w = firstW; w <= lastW; w += windowMs) candidates.add(w);
|
|
2466
|
+
}
|
|
2467
|
+
const plans = [];
|
|
2468
|
+
for (const windowStartMs of [...candidates].sort((a, b) => a - b)) {
|
|
2469
|
+
if (coveredWindows.has(windowStartMs)) continue;
|
|
2470
|
+
const windowEndMs = windowStartMs + windowMs;
|
|
2471
|
+
if (now - windowEndMs < settleMs) continue;
|
|
2472
|
+
const covering = segments.filter((s) => s.durMs > 0 && overlaps(s.startMs, s.durMs, windowStartMs, windowEndMs)).sort((a, b) => a.startMs - b.startMs);
|
|
2473
|
+
if (covering.length === 0) continue;
|
|
2474
|
+
plans.push({
|
|
2475
|
+
windowStartMs,
|
|
2476
|
+
windowEndMs,
|
|
2477
|
+
segments: covering
|
|
2478
|
+
});
|
|
2479
|
+
if (plans.length >= maxWindows) break;
|
|
2480
|
+
}
|
|
2481
|
+
return plans;
|
|
2482
|
+
}
|
|
2483
|
+
//#endregion
|
|
2484
|
+
//#region src/thumbnails/backfill-ffmpeg.ts
|
|
2485
|
+
/**
|
|
2486
|
+
* ffmpeg args to extract keyframes from one segment as scaled JPEGs. `-skip_frame
|
|
2487
|
+
* nokey` + `-vsync 0` (a.k.a. passthrough) keeps only I-frames without
|
|
2488
|
+
* duplicating; `-an` drops audio. The caller resolves timestamps from the
|
|
2489
|
+
* segment start + the extracted frame order (keyframes are ~GOP-spaced).
|
|
2490
|
+
*/
|
|
2491
|
+
function buildKeyframeExtractArgs(input) {
|
|
2492
|
+
const q = input.quality ?? 4;
|
|
2493
|
+
return [
|
|
2494
|
+
"-hide_banner",
|
|
2495
|
+
"-loglevel",
|
|
2496
|
+
"error",
|
|
2497
|
+
"-skip_frame",
|
|
2498
|
+
"nokey",
|
|
2499
|
+
"-i",
|
|
2500
|
+
input.inputPath,
|
|
2501
|
+
"-an",
|
|
2502
|
+
"-vsync",
|
|
2503
|
+
"0",
|
|
2504
|
+
"-vf",
|
|
2505
|
+
`scale=${input.tileWidth}:-2`,
|
|
2506
|
+
"-q:v",
|
|
2507
|
+
String(q),
|
|
2508
|
+
input.outputPattern
|
|
2509
|
+
];
|
|
2510
|
+
}
|
|
2511
|
+
//#endregion
|
|
2512
|
+
//#region src/recorder/addon/thumbnail-service.ts
|
|
2513
|
+
/**
|
|
2514
|
+
* Recorder-side scrub-thumbnail SERVICE — the wiring that turns
|
|
2515
|
+
* `recording.thumb-sampled` telemetry into served sprite sheets.
|
|
2516
|
+
*
|
|
2517
|
+
* runner (any node) --recording.thumb-sampled event--> THIS (recording node)
|
|
2518
|
+
* -> ThumbnailSink accumulates a 5-min window
|
|
2519
|
+
* -> composeSheet (sharp) + persistThumbnailWindow (write-once)
|
|
2520
|
+
* -> data-plane `/addon/recorder/thumbs/<dev>/Y/M/D/H/{sheet,index}`
|
|
2521
|
+
*
|
|
2522
|
+
* Cross-node decision (documented, simplest per the analysis §3 open
|
|
2523
|
+
* questions): the thumb rides the EVENT BUS. The recorder is a SINGLE
|
|
2524
|
+
* designated node, so its data-plane prefix (`/addon/recorder/thumbs`) is
|
|
2525
|
+
* unambiguous — serving from any decode node's own prefix would be
|
|
2526
|
+
* multi-node-ambiguous. Sampling is compressed (~10 KB) at 1/5 Hz — well inside
|
|
2527
|
+
* D9 — and telemetry-grade (a lost thumb is a scrub gap the keyframe backfill
|
|
2528
|
+
* fills), so no new cap method and no RPC hop are needed.
|
|
2529
|
+
*
|
|
2530
|
+
* Backfill (phase 2): hourly, for windows with recorded LOW footage but no
|
|
2531
|
+
* sampled coverage, ONE keyframe-only ffmpeg pass per window fills quiet-period
|
|
2532
|
+
* heartbeat thumbs. Bounded concurrency + single-flight so a burst never spawns
|
|
2533
|
+
* a storm.
|
|
2534
|
+
*/
|
|
2535
|
+
var THUMBS_PREFIX = "thumbs";
|
|
2536
|
+
var RECORDER_ADDON_ID$1 = "recorder";
|
|
2537
|
+
/** Idle window flush grace — a window whose end passed this long ago is
|
|
2538
|
+
* composed even if no later sample rolled it over (idle camera). */
|
|
2539
|
+
var IDLE_GRACE_MS = 3e4;
|
|
2540
|
+
/** Flush-timer cadence. */
|
|
2541
|
+
var FLUSH_TICK_MS = 15e3;
|
|
2542
|
+
/** Backfill scan cadence — 15 min so scrub coverage holes close within the
|
|
2543
|
+
* hour instead of lingering (spawns stay bounded: keyframe-only ffmpeg over
|
|
2544
|
+
* low segments, concurrency-gated, per-device window budget). */
|
|
2545
|
+
var BACKFILL_TICK_MS = 15 * 6e4;
|
|
2546
|
+
/** A window must have ended this long ago before backfill considers it. */
|
|
2547
|
+
var BACKFILL_SETTLE_MS = 5 * DEFAULT_WINDOW_MS;
|
|
2548
|
+
/** Max concurrent backfill ffmpeg passes across all cameras. */
|
|
2549
|
+
var BACKFILL_MAX_CONCURRENT = 2;
|
|
2550
|
+
/** Max windows filled PER DEVICE per backfill pass. A recording camera accrues
|
|
2551
|
+
* ≤12 uncovered 5-min windows/hour, so 24 lets a device also DRAIN backlog.
|
|
2552
|
+
* (This was a GLOBAL cap: 24/pass vs ~12×N windows/hour needed cluster-wide —
|
|
2553
|
+
* devices later in the list starved and coverage grew permanent holes,
|
|
2554
|
+
* live-measured 2026-07-17 on 590: gaps at −55…−110 min.) */
|
|
2555
|
+
var BACKFILL_MAX_WINDOWS_PER_DEVICE = 24;
|
|
2556
|
+
/** Global safety cap per pass (runaway bound across all cameras). */
|
|
2557
|
+
var BACKFILL_MAX_WINDOWS_PER_PASS = 240;
|
|
2558
|
+
/** Hard cap on tiles per window (bounds sheet size + compose cost). */
|
|
2559
|
+
var MAX_TILES_PER_WINDOW = 90;
|
|
2560
|
+
/** Retention-prune cadence (sheets are small — a slow sweep is plenty). */
|
|
2561
|
+
var PRUNE_TICK_MS = 6 * 36e5;
|
|
2562
|
+
/** First prune runs this long after start (off the boot churn). */
|
|
2563
|
+
var PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
|
|
2564
|
+
var SHARP = sharp.default;
|
|
2565
|
+
var ThumbnailService = class {
|
|
2566
|
+
deps;
|
|
2567
|
+
sink;
|
|
2568
|
+
unsub = null;
|
|
2569
|
+
dataPlaneDispose = null;
|
|
2570
|
+
flushTimer = null;
|
|
2571
|
+
backfillTimer = null;
|
|
2572
|
+
pruneTimer = null;
|
|
2573
|
+
pruneInitialTimer = null;
|
|
2574
|
+
backfillActive = 0;
|
|
2575
|
+
backfillInFlight = /* @__PURE__ */ new Set();
|
|
2576
|
+
stopped = false;
|
|
2577
|
+
constructor(deps) {
|
|
2578
|
+
this.deps = deps;
|
|
2579
|
+
this.sink = new ThumbnailSink({
|
|
2580
|
+
windowMs: deps.config.windowMs,
|
|
2581
|
+
intervalMs: deps.config.intervalMs,
|
|
2582
|
+
tileWidth: 320,
|
|
2583
|
+
tileHeight: 180,
|
|
2584
|
+
maxCols: 10,
|
|
2585
|
+
maxTilesPerWindow: MAX_TILES_PER_WINDOW,
|
|
2586
|
+
idleGraceMs: IDLE_GRACE_MS,
|
|
2587
|
+
source: "sampled",
|
|
2588
|
+
compose: (req) => composeSheet(SHARP, {
|
|
2589
|
+
tiles: [...req.tiles],
|
|
2590
|
+
placements: req.placements,
|
|
2591
|
+
geometry: req.geometry
|
|
2592
|
+
}),
|
|
2593
|
+
persist: (req) => this.persist(req),
|
|
2594
|
+
logger: deps.logger.child("thumbnail-sink")
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
/** First writable thumbs root (`<locationRoot>/.thumbs`), or null when no
|
|
2598
|
+
* recordings location is resolved yet. */
|
|
2599
|
+
writeRoot() {
|
|
2600
|
+
const loc = this.deps.locations()[0];
|
|
2601
|
+
return loc ? node_path.default.join(loc.root, THUMBS_SUBDIR) : null;
|
|
2602
|
+
}
|
|
2603
|
+
/** Every thumbs root (for the data-plane getFile search). */
|
|
2604
|
+
thumbsRoots() {
|
|
2605
|
+
return this.deps.locations().map((l) => node_path.default.join(l.root, THUMBS_SUBDIR));
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Serve ONLY the `thumbs` data-plane endpoint, without starting the sink /
|
|
2609
|
+
* timers. Called from the recorder's `onInitialize` — BEFORE the
|
|
2610
|
+
* node-registration handshake — because the hub pulls a forked addon's
|
|
2611
|
+
* data-plane endpoints once, off the caps-registered signal: an endpoint
|
|
2612
|
+
* served later (the `start()` in `onHubReachable` lands minutes after boot)
|
|
2613
|
+
* never reaches the reverse-proxy and `/addon/recorder/thumbs/*` 404s to the
|
|
2614
|
+
* SPA (live-diagnosed 2026-07-17). Safe to serve early: the handler resolves
|
|
2615
|
+
* roots lazily per request (mirrors the playback data-plane pattern).
|
|
2616
|
+
* Idempotent — `start()` re-invokes it harmlessly.
|
|
2617
|
+
*/
|
|
2618
|
+
async serveEarly() {
|
|
2619
|
+
if (!this.deps.config.enabled) return;
|
|
2620
|
+
if (this.dataPlaneDispose !== null) return;
|
|
2621
|
+
await this.serveDataPlane();
|
|
2622
|
+
}
|
|
2623
|
+
async start() {
|
|
2624
|
+
if (!this.deps.config.enabled) {
|
|
2625
|
+
this.deps.logger.info("thumbnails disabled — service inert");
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
if (this.unsub !== null) return;
|
|
2629
|
+
this.unsub = this.deps.eventBus.subscribe({ category: require_dist.EventCategory.RecordingThumbSampled }, (event) => {
|
|
2630
|
+
if (!require_dist.isEvent(event, require_dist.EventCategory.RecordingThumbSampled)) return;
|
|
2631
|
+
const d = event.data;
|
|
2632
|
+
try {
|
|
2633
|
+
this.sink.add(d.deviceId, {
|
|
2634
|
+
capturedAt: d.capturedAt,
|
|
2635
|
+
jpeg: Buffer.from(d.jpeg)
|
|
2636
|
+
});
|
|
2637
|
+
} catch (err) {
|
|
2638
|
+
this.deps.logger.debug("thumbnail add failed", {
|
|
2639
|
+
tags: { deviceId: d.deviceId },
|
|
2640
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
if (this.dataPlaneDispose === null) await this.serveDataPlane();
|
|
2645
|
+
this.flushTimer = setInterval(() => this.sink.flushExpired(), FLUSH_TICK_MS);
|
|
2646
|
+
if (typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref?.();
|
|
2647
|
+
if (this.deps.config.backfillEnabled) {
|
|
2648
|
+
this.backfillTimer = setInterval(() => {
|
|
2649
|
+
this.runBackfillPass();
|
|
2650
|
+
}, BACKFILL_TICK_MS);
|
|
2651
|
+
this.backfillTimer.unref?.();
|
|
2652
|
+
}
|
|
2653
|
+
this.pruneInitialTimer = setTimeout(() => {
|
|
2654
|
+
this.runPrunePass();
|
|
2655
|
+
}, PRUNE_INITIAL_DELAY_MS);
|
|
2656
|
+
this.pruneInitialTimer.unref?.();
|
|
2657
|
+
this.pruneTimer = setInterval(() => {
|
|
2658
|
+
this.runPrunePass();
|
|
2659
|
+
}, PRUNE_TICK_MS);
|
|
2660
|
+
this.pruneTimer.unref?.();
|
|
2661
|
+
this.deps.logger.info("thumbnail service started", { meta: {
|
|
2662
|
+
windowMs: this.deps.config.windowMs,
|
|
2663
|
+
intervalMs: this.deps.config.intervalMs,
|
|
2664
|
+
backfill: this.deps.config.backfillEnabled
|
|
2665
|
+
} });
|
|
2666
|
+
}
|
|
2667
|
+
async stop() {
|
|
2668
|
+
this.stopped = true;
|
|
2669
|
+
this.unsub?.();
|
|
2670
|
+
this.unsub = null;
|
|
2671
|
+
if (this.flushTimer) clearInterval(this.flushTimer);
|
|
2672
|
+
if (this.backfillTimer) clearInterval(this.backfillTimer);
|
|
2673
|
+
if (this.pruneTimer) clearInterval(this.pruneTimer);
|
|
2674
|
+
if (this.pruneInitialTimer) clearTimeout(this.pruneInitialTimer);
|
|
2675
|
+
this.flushTimer = null;
|
|
2676
|
+
this.backfillTimer = null;
|
|
2677
|
+
this.pruneTimer = null;
|
|
2678
|
+
this.pruneInitialTimer = null;
|
|
2679
|
+
try {
|
|
2680
|
+
await this.sink.flushAll();
|
|
2681
|
+
} catch {}
|
|
2682
|
+
await this.dataPlaneDispose?.().catch(() => {});
|
|
2683
|
+
this.dataPlaneDispose = null;
|
|
2684
|
+
}
|
|
2685
|
+
async serveDataPlane() {
|
|
2686
|
+
const handler = createThumbsMediaHandler({ getFile: async (rel) => {
|
|
2687
|
+
for (const root of this.thumbsRoots()) {
|
|
2688
|
+
const abs = node_path.default.join(root, rel);
|
|
2689
|
+
if (!abs.startsWith(root + node_path.default.sep)) continue;
|
|
2690
|
+
try {
|
|
2691
|
+
return {
|
|
2692
|
+
bytes: await node_fs.promises.readFile(abs),
|
|
2693
|
+
contentType: thumbsContentType(rel)
|
|
2694
|
+
};
|
|
2695
|
+
} catch {}
|
|
2696
|
+
}
|
|
2697
|
+
return null;
|
|
2698
|
+
} });
|
|
2699
|
+
try {
|
|
2700
|
+
const served = await this.deps.dataPlane?.serve({
|
|
2701
|
+
prefix: THUMBS_PREFIX,
|
|
2702
|
+
access: "authenticated",
|
|
2703
|
+
handler
|
|
2704
|
+
});
|
|
2705
|
+
if (served) {
|
|
2706
|
+
this.dataPlaneDispose = () => served.dispose();
|
|
2707
|
+
this.deps.logger.info("thumbnail data-plane served", { meta: { clientBase: `/addon/${RECORDER_ADDON_ID$1}/${THUMBS_PREFIX}` } });
|
|
2708
|
+
}
|
|
2709
|
+
} catch (err) {
|
|
2710
|
+
this.deps.logger.warn("thumbnail data-plane failed to serve", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* One retention sweep: for every device dir under every thumbs root, drop
|
|
2715
|
+
* the hour buckets fully older than the device's oldest indexed segment
|
|
2716
|
+
* (thumbs live exactly as long as footage; fixed fallback age when the
|
|
2717
|
+
* device has no segments). Pure decision in `planThumbPrune`; only valid
|
|
2718
|
+
* `Y/M/D/H` buckets are ever deleted. Best-effort — a failed rm retries on
|
|
2719
|
+
* the next sweep.
|
|
2720
|
+
*/
|
|
2721
|
+
async runPrunePass() {
|
|
2722
|
+
if (this.stopped) return;
|
|
2723
|
+
let pruned = 0;
|
|
2724
|
+
for (const root of this.thumbsRoots()) {
|
|
2725
|
+
let deviceDirs;
|
|
2726
|
+
try {
|
|
2727
|
+
deviceDirs = (await node_fs.promises.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && /^\d+$/.test(e.name)).map((e) => e.name);
|
|
2728
|
+
} catch {
|
|
2729
|
+
continue;
|
|
2730
|
+
}
|
|
2731
|
+
for (const dev of deviceDirs) {
|
|
2732
|
+
if (this.stopped) return;
|
|
2733
|
+
const deviceId = Number(dev);
|
|
2734
|
+
const deviceDir = node_path.default.join(root, dev);
|
|
2735
|
+
const buckets = [];
|
|
2736
|
+
try {
|
|
2737
|
+
for (const rel of await node_fs.promises.readdir(deviceDir, { recursive: true })) {
|
|
2738
|
+
const relPosix = String(rel).split(node_path.default.sep).join("/");
|
|
2739
|
+
const hourStartMs = parseHourBucket(relPosix);
|
|
2740
|
+
if (hourStartMs !== null) buckets.push({
|
|
2741
|
+
relDir: relPosix,
|
|
2742
|
+
hourStartMs
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2745
|
+
} catch {
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
const segments = this.deps.lowSegments(deviceId);
|
|
2749
|
+
const plan = planThumbPrune({
|
|
2750
|
+
buckets,
|
|
2751
|
+
oldestSegmentStartMs: segments.length > 0 ? Math.min(...segments.map((s) => s.startMs)) : null,
|
|
2752
|
+
nowMs: Date.now()
|
|
2753
|
+
});
|
|
2754
|
+
for (const b of plan) try {
|
|
2755
|
+
await node_fs.promises.rm(node_path.default.join(deviceDir, b.relDir), {
|
|
2756
|
+
recursive: true,
|
|
2757
|
+
force: true
|
|
2758
|
+
});
|
|
2759
|
+
pruned += 1;
|
|
2760
|
+
} catch {}
|
|
2761
|
+
if (plan.length > 0) await removeEmptyParents(deviceDir);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
if (pruned > 0) this.deps.logger.info("thumbnail retention prune removed buckets", { meta: { pruned } });
|
|
2765
|
+
}
|
|
2766
|
+
async persist(req) {
|
|
2767
|
+
const root = this.writeRoot();
|
|
2768
|
+
if (!root) {
|
|
2769
|
+
this.deps.logger.warn("thumbnail persist skipped — no recordings location resolved", { tags: { deviceId: req.deviceId } });
|
|
2770
|
+
return;
|
|
2771
|
+
}
|
|
2772
|
+
await persistThumbnailWindow(root, req);
|
|
2773
|
+
}
|
|
2774
|
+
/** One backfill scan: fill uncovered, settled windows with recorded low
|
|
2775
|
+
* footage, bounded per pass + per concurrency. Best-effort. */
|
|
2776
|
+
async runBackfillPass() {
|
|
2777
|
+
if (this.stopped || !this.deps.config.backfillEnabled) return;
|
|
2778
|
+
const root = this.writeRoot();
|
|
2779
|
+
if (!root) return;
|
|
2780
|
+
let deviceIds;
|
|
2781
|
+
try {
|
|
2782
|
+
deviceIds = await this.deps.recordingDeviceIds();
|
|
2783
|
+
} catch {
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
let filled = 0;
|
|
2787
|
+
for (const deviceId of deviceIds) {
|
|
2788
|
+
if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || this.stopped) break;
|
|
2789
|
+
const segments = this.deps.lowSegments(deviceId).map((s) => ({
|
|
2790
|
+
startMs: s.startMs,
|
|
2791
|
+
durMs: s.durMs,
|
|
2792
|
+
path: s.path
|
|
2793
|
+
}));
|
|
2794
|
+
if (segments.length === 0) continue;
|
|
2795
|
+
const plans = planBackfill({
|
|
2796
|
+
segments,
|
|
2797
|
+
coveredWindows: await this.readCoveredWindows(root, deviceId),
|
|
2798
|
+
windowMs: this.deps.config.windowMs,
|
|
2799
|
+
now: Date.now(),
|
|
2800
|
+
settleMs: BACKFILL_SETTLE_MS,
|
|
2801
|
+
maxWindows: Math.min(BACKFILL_MAX_WINDOWS_PER_DEVICE, BACKFILL_MAX_WINDOWS_PER_PASS - filled)
|
|
2802
|
+
});
|
|
2803
|
+
let filledForDevice = 0;
|
|
2804
|
+
for (const plan of plans) {
|
|
2805
|
+
if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || filledForDevice >= BACKFILL_MAX_WINDOWS_PER_DEVICE || this.stopped) break;
|
|
2806
|
+
const key = `${deviceId}:${plan.windowStartMs}`;
|
|
2807
|
+
if (this.backfillInFlight.has(key)) continue;
|
|
2808
|
+
this.backfillInFlight.add(key);
|
|
2809
|
+
try {
|
|
2810
|
+
await this.gateBackfill(() => this.backfillWindow(deviceId, plan.windowStartMs, plan.segments, root));
|
|
2811
|
+
filled += 1;
|
|
2812
|
+
filledForDevice += 1;
|
|
2813
|
+
} catch (err) {
|
|
2814
|
+
this.deps.logger.debug("backfill window failed", {
|
|
2815
|
+
tags: { deviceId },
|
|
2816
|
+
meta: {
|
|
2817
|
+
windowStartMs: plan.windowStartMs,
|
|
2818
|
+
error: err instanceof Error ? err.message : err
|
|
2819
|
+
}
|
|
2820
|
+
});
|
|
2821
|
+
} finally {
|
|
2822
|
+
this.backfillInFlight.delete(key);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (filled > 0) this.deps.logger.info("thumbnail backfill pass filled windows", { meta: { filled } });
|
|
2827
|
+
}
|
|
2828
|
+
/** Bounded-concurrency gate (spin-wait, coarse — backfill is rare/hourly). */
|
|
2829
|
+
async gateBackfill(fn) {
|
|
2830
|
+
while (this.backfillActive >= BACKFILL_MAX_CONCURRENT && !this.stopped) await new Promise((r) => setTimeout(r, 200));
|
|
2831
|
+
this.backfillActive += 1;
|
|
2832
|
+
try {
|
|
2833
|
+
return await fn();
|
|
2834
|
+
} finally {
|
|
2835
|
+
this.backfillActive -= 1;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
/** Which windows already have an index sidecar on disk (sampled or prior backfill). */
|
|
2839
|
+
async readCoveredWindows(root, deviceId) {
|
|
2840
|
+
const covered = /* @__PURE__ */ new Set();
|
|
2841
|
+
const deviceDir = node_path.default.join(root, String(deviceId));
|
|
2842
|
+
let entries;
|
|
2843
|
+
try {
|
|
2844
|
+
entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
|
|
2845
|
+
} catch {
|
|
2846
|
+
return covered;
|
|
2847
|
+
}
|
|
2848
|
+
for (const e of entries) {
|
|
2849
|
+
const m = /index-(\d+)\.json$/.exec(e);
|
|
2850
|
+
if (m) covered.add(Number(m[1]));
|
|
2851
|
+
}
|
|
2852
|
+
return covered;
|
|
2853
|
+
}
|
|
2854
|
+
/** Fill ONE window: extract keyframes from its covering low segments, compose
|
|
2855
|
+
* a sheet, persist with `source:'backfill'`. */
|
|
2856
|
+
async backfillWindow(deviceId, windowStartMs, segments, root) {
|
|
2857
|
+
const workDir = await node_fs.promises.mkdtemp(node_path.default.join((0, node_os.tmpdir)(), `csthumb-${deviceId}-`));
|
|
2858
|
+
try {
|
|
2859
|
+
const samples = [];
|
|
2860
|
+
for (const seg of segments) {
|
|
2861
|
+
const absSeg = this.resolveSegmentAbs(deviceId, seg.path);
|
|
2862
|
+
if (!absSeg) continue;
|
|
2863
|
+
const jpegs = await this.extractKeyframes(absSeg, workDir);
|
|
2864
|
+
const n = jpegs.length;
|
|
2865
|
+
for (let i = 0; i < n; i += 1) {
|
|
2866
|
+
const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
|
|
2867
|
+
if (t >= windowStartMs && t < windowStartMs + this.deps.config.windowMs) samples.push({
|
|
2868
|
+
t,
|
|
2869
|
+
jpeg: jpegs[i]
|
|
2870
|
+
});
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
if (samples.length === 0) return;
|
|
2874
|
+
if (samples.length > MAX_TILES_PER_WINDOW) samples.length = MAX_TILES_PER_WINDOW;
|
|
2875
|
+
const ordered = samples.sort((a, b) => a.t - b.t);
|
|
2876
|
+
const built = buildThumbnailIndex({
|
|
2877
|
+
deviceId,
|
|
2878
|
+
windowStartMs,
|
|
2879
|
+
windowMs: this.deps.config.windowMs,
|
|
2880
|
+
intervalMs: this.deps.config.intervalMs,
|
|
2881
|
+
source: "backfill",
|
|
2882
|
+
tileWidth: 320,
|
|
2883
|
+
tileHeight: 180,
|
|
2884
|
+
maxCols: 10,
|
|
2885
|
+
samples: ordered.map((s) => ({ t: s.t }))
|
|
2886
|
+
});
|
|
2887
|
+
const geometry = require_sheet_geometry.computeSheetGeometry(ordered.length, {
|
|
2888
|
+
tileWidth: 320,
|
|
2889
|
+
tileHeight: 180,
|
|
2890
|
+
maxCols: 10
|
|
2891
|
+
});
|
|
2892
|
+
const placements = require_sheet_geometry.allTilePlacements(ordered.length, geometry);
|
|
2893
|
+
const sheetBytes = await composeSheet(SHARP, {
|
|
2894
|
+
tiles: ordered.map((s) => s.jpeg),
|
|
2895
|
+
placements,
|
|
2896
|
+
geometry
|
|
2897
|
+
});
|
|
2898
|
+
await persistThumbnailWindow(root, {
|
|
2899
|
+
deviceId,
|
|
2900
|
+
windowStartMs,
|
|
2901
|
+
relDir: `${deviceId}/${bucketDir(windowStartMs)}`,
|
|
2902
|
+
sheetName: sheetFileName(windowStartMs),
|
|
2903
|
+
sheetBytes,
|
|
2904
|
+
indexName: indexFileName(windowStartMs),
|
|
2905
|
+
index: built.index
|
|
2906
|
+
});
|
|
2907
|
+
} finally {
|
|
2908
|
+
await node_fs.promises.rm(workDir, {
|
|
2909
|
+
recursive: true,
|
|
2910
|
+
force: true
|
|
2911
|
+
}).catch(() => {});
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
/** Resolve a low segment's absolute path from its location root + rel path. */
|
|
2915
|
+
resolveSegmentAbs(deviceId, relPath) {
|
|
2916
|
+
const row = this.deps.lowSegments(deviceId).find((r) => r.path === relPath);
|
|
2917
|
+
if (!row) return null;
|
|
2918
|
+
const loc = this.deps.locations().find((l) => l.id === row.locationId);
|
|
2919
|
+
if (!loc) return null;
|
|
2920
|
+
return node_path.default.join(loc.root, relPath);
|
|
2921
|
+
}
|
|
2922
|
+
/** Run ONE keyframe-only ffmpeg pass over a segment; return the extracted
|
|
2923
|
+
* JPEG buffers in order. */
|
|
2924
|
+
async extractKeyframes(absSeg, workDir) {
|
|
2925
|
+
const args = buildKeyframeExtractArgs({
|
|
2926
|
+
inputPath: absSeg,
|
|
2927
|
+
outputPattern: node_path.default.join(workDir, "kf-%04d.jpg"),
|
|
2928
|
+
tileWidth: 320
|
|
2929
|
+
});
|
|
2930
|
+
await this.spawnFfmpeg(args);
|
|
2931
|
+
const files = (await node_fs.promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
|
|
2932
|
+
const out = [];
|
|
2933
|
+
for (const f of files) {
|
|
2934
|
+
out.push(await node_fs.promises.readFile(node_path.default.join(workDir, f)));
|
|
2935
|
+
await node_fs.promises.rm(node_path.default.join(workDir, f), { force: true }).catch(() => {});
|
|
2936
|
+
}
|
|
2937
|
+
return out;
|
|
2938
|
+
}
|
|
2939
|
+
spawnFfmpeg(args) {
|
|
2940
|
+
return new Promise((resolve, reject) => {
|
|
2941
|
+
let child;
|
|
2942
|
+
try {
|
|
2943
|
+
child = (0, node_child_process.spawn)("ffmpeg", [...args], { stdio: [
|
|
2944
|
+
"ignore",
|
|
2945
|
+
"ignore",
|
|
2946
|
+
"ignore"
|
|
2947
|
+
] });
|
|
2948
|
+
} catch (err) {
|
|
2949
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
const timer = setTimeout(() => {
|
|
2953
|
+
child.kill("SIGKILL");
|
|
2954
|
+
reject(/* @__PURE__ */ new Error("ffmpeg keyframe extract timed out"));
|
|
2955
|
+
}, 3e4);
|
|
2956
|
+
timer.unref?.();
|
|
2957
|
+
child.on("error", (err) => {
|
|
2958
|
+
clearTimeout(timer);
|
|
2959
|
+
reject(err);
|
|
2960
|
+
});
|
|
2961
|
+
child.on("close", (code) => {
|
|
2962
|
+
clearTimeout(timer);
|
|
2963
|
+
if (code === 0) resolve();
|
|
2964
|
+
else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${code}`));
|
|
2965
|
+
});
|
|
2966
|
+
});
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
/**
|
|
2970
|
+
* Best-effort removal of now-empty `Y/M/D` parent dirs after a prune (never
|
|
2971
|
+
* the device dir itself). `rmdir` refuses non-empty dirs, so this can never
|
|
2972
|
+
* delete content — failures are simply left for the next sweep.
|
|
2973
|
+
*/
|
|
2974
|
+
async function removeEmptyParents(deviceDir) {
|
|
2975
|
+
let entries;
|
|
2976
|
+
try {
|
|
2977
|
+
entries = (await node_fs.promises.readdir(deviceDir, { recursive: true })).map(String);
|
|
2978
|
+
} catch {
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const dirs = entries.map((rel) => rel.split(node_path.default.sep).join("/")).filter((rel) => /^\d{4}(\/\d{2}){0,3}$/.test(rel)).sort((a, b) => b.length - a.length);
|
|
2982
|
+
for (const rel of dirs) try {
|
|
2983
|
+
await node_fs.promises.rmdir(node_path.default.join(deviceDir, rel));
|
|
2984
|
+
} catch {}
|
|
2985
|
+
}
|
|
2049
2986
|
//#endregion
|
|
2050
2987
|
//#region src/recorder/addon/index.ts
|
|
2051
2988
|
/**
|
|
@@ -2080,7 +3017,9 @@ var DEFAULT_CONFIG = {
|
|
|
2080
3017
|
segmentSeconds: 10,
|
|
2081
3018
|
watchIntervalMs: 2e3,
|
|
2082
3019
|
recordingNodeId: RECORDING_NODE_DEFAULT,
|
|
2083
|
-
recordingHubHostname: ""
|
|
3020
|
+
recordingHubHostname: "",
|
|
3021
|
+
thumbnailsEnabled: true,
|
|
3022
|
+
thumbnailBackfillEnabled: true
|
|
2084
3023
|
};
|
|
2085
3024
|
/** Playback-marker window padding around a motion event (EventMap only; the
|
|
2086
3025
|
* controller's events-band gating uses the raw trigger ms, not this pad).
|
|
@@ -2120,6 +3059,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2120
3059
|
resolvedLocations = [];
|
|
2121
3060
|
/** Base URL of the playback HTTP data-plane, null until served in onHubReachable. */
|
|
2122
3061
|
playbackBaseUrl = null;
|
|
3062
|
+
/** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
|
|
3063
|
+
thumbnailService = null;
|
|
2123
3064
|
constructor() {
|
|
2124
3065
|
super({ ...DEFAULT_CONFIG });
|
|
2125
3066
|
}
|
|
@@ -2224,7 +3165,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2224
3165
|
store: this.segmentStore
|
|
2225
3166
|
});
|
|
2226
3167
|
try {
|
|
2227
|
-
const handler =
|
|
3168
|
+
const handler = require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
|
|
2228
3169
|
const served = await this.ctx.dataPlane?.serve({
|
|
2229
3170
|
prefix: PLAYBACK_PREFIX,
|
|
2230
3171
|
access: "authenticated",
|
|
@@ -2238,6 +3179,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2238
3179
|
} catch (err) {
|
|
2239
3180
|
this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
2240
3181
|
}
|
|
3182
|
+
await this.ensureThumbnailService().serveEarly();
|
|
2241
3183
|
this.ctx.logger.info("recorder addon started (continuous write path active)", {
|
|
2242
3184
|
tags: { nodeId: this.nodeId },
|
|
2243
3185
|
meta: {
|
|
@@ -2259,6 +3201,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2259
3201
|
await this.controller?.stop();
|
|
2260
3202
|
this.controller = null;
|
|
2261
3203
|
this.segmentStore = null;
|
|
3204
|
+
await this.thumbnailService?.stop();
|
|
3205
|
+
this.thumbnailService = null;
|
|
2262
3206
|
}
|
|
2263
3207
|
/**
|
|
2264
3208
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
@@ -2311,6 +3255,30 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2311
3255
|
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
2312
3256
|
}
|
|
2313
3257
|
});
|
|
3258
|
+
try {
|
|
3259
|
+
await this.ensureThumbnailService().start();
|
|
3260
|
+
} catch (err) {
|
|
3261
|
+
this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
/** Construct the thumbnail service once — every dep is a lazy closure, so
|
|
3265
|
+
* construction is safe in `onInitialize` (before locations resolve). */
|
|
3266
|
+
ensureThumbnailService() {
|
|
3267
|
+
if (this.thumbnailService === null) this.thumbnailService = new ThumbnailService({
|
|
3268
|
+
logger: this.ctx.logger.child("thumbnails"),
|
|
3269
|
+
eventBus: this.ctx.eventBus,
|
|
3270
|
+
...this.ctx.dataPlane !== void 0 ? { dataPlane: this.ctx.dataPlane } : {},
|
|
3271
|
+
locations: () => this.resolvedLocations,
|
|
3272
|
+
lowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
3273
|
+
recordingDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
|
|
3274
|
+
config: {
|
|
3275
|
+
enabled: this.config.thumbnailsEnabled,
|
|
3276
|
+
backfillEnabled: this.config.thumbnailBackfillEnabled,
|
|
3277
|
+
windowMs: DEFAULT_WINDOW_MS,
|
|
3278
|
+
intervalMs: 5e3
|
|
3279
|
+
}
|
|
3280
|
+
});
|
|
3281
|
+
return this.thumbnailService;
|
|
2314
3282
|
}
|
|
2315
3283
|
/** Re-resolve recordings locations (rescanStorage picks up newly-added disks). */
|
|
2316
3284
|
async refreshLocations() {
|
|
@@ -2387,6 +3355,24 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
2387
3355
|
description: "Advanced: only used when the recording node is an AGENT. The hub's host the agent dials to pull broker streams (the RTSP restream port must be reachable there). Leave empty to auto-derive from the agent's hub connection.",
|
|
2388
3356
|
default: ""
|
|
2389
3357
|
}]
|
|
3358
|
+
}, {
|
|
3359
|
+
id: "scrub-thumbnails",
|
|
3360
|
+
title: "Scrub thumbnails",
|
|
3361
|
+
description: "Timeline scrub previews. Small JPEG sprite sheets packed from the runner-sampled detection frames (zero extra decode) + an hourly keyframe-only backfill for quiet windows. Served write-once over the authenticated data-plane; works on every viewer platform (no client video decode).",
|
|
3362
|
+
columns: 1,
|
|
3363
|
+
fields: [{
|
|
3364
|
+
type: "boolean",
|
|
3365
|
+
key: "thumbnailsEnabled",
|
|
3366
|
+
label: "Serve scrub thumbnails",
|
|
3367
|
+
description: "Accumulate + serve sprite sheets. Disable to stop packing/serving thumbnails.",
|
|
3368
|
+
default: true
|
|
3369
|
+
}, {
|
|
3370
|
+
type: "boolean",
|
|
3371
|
+
key: "thumbnailBackfillEnabled",
|
|
3372
|
+
label: "Keyframe backfill",
|
|
3373
|
+
description: "Hourly, fill windows that have recorded low footage but no sampled thumbs with a single keyframe-only ffmpeg pass per window (≤24 spawns/day/camera worst case).",
|
|
3374
|
+
default: true
|
|
3375
|
+
}]
|
|
2390
3376
|
}] });
|
|
2391
3377
|
}
|
|
2392
3378
|
/** Durable handle config-store reads/writes the deviceId→config blob on —
|