@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.
Files changed (37) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +9 -9
  4. package/dist/detection-pipeline/index.mjs +3 -3
  5. package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
  6. package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
  7. package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
  8. package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +147 -12
  12. package/dist/pipeline-runner/index.mjs +146 -11
  13. package/dist/recorder/index.js +993 -7
  14. package/dist/recorder/index.mjs +990 -5
  15. package/dist/session-decode/decode-worker-child.js +110 -2
  16. package/dist/session-decode/decode-worker-child.mjs +110 -2
  17. package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
  18. package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
  19. package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
  20. package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. 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
  23. 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
  24. 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
  25. package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
  26. package/dist/stream-broker/index.js +626 -152
  27. package/dist/stream-broker/index.mjs +626 -152
  28. package/dist/stream-broker/remoteEntry.js +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
  32. package/embed-dist/assets/index-W9-H5LTj.js +81 -0
  33. package/embed-dist/index.html +1 -1
  34. package/package.json +1 -1
  35. 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
  36. 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
  37. package/embed-dist/assets/index-DOsK8CZb.js +0 -81
@@ -1,11 +1,14 @@
1
1
  const require_chunk = require("../chunk-D6vf50IK.js");
2
- const require_dist = require("../dist-CK8Ur-OS.js");
3
- const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
- const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-D326YNnt.js");
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 = require_hub_hostname.resolveHubHostname;
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 = require_model_download_service_C_IHWnXx.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
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 —