@camstack/addon-pipeline 1.1.58 → 1.1.60

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 +48 -10
  4. package/dist/detection-pipeline/index.mjs +42 -4
  5. package/dist/{dist-CnwwDzX3.mjs → dist-CcqqznPf.mjs} +244 -14
  6. package/dist/{dist-Bn6Uq4vq.js → dist-DynD1EC-.js} +249 -13
  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 +123 -4
  12. package/dist/pipeline-runner/index.mjs +122 -3
  13. package/dist/recorder/index.js +1021 -7
  14. package/dist/recorder/index.mjs +1018 -5
  15. package/dist/session-decode/decode-worker-child.js +14 -1
  16. package/dist/session-decode/decode-worker-child.mjs +14 -1
  17. package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BR-ip0CP.js} +56 -1
  18. package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-DcpuGTCm.mjs} +39 -6
  19. package/dist/{step-definitions-DhEe57Wn.mjs → step-definitions-CvwB_Otk.mjs} +1 -1
  20. package/dist/{step-definitions-D7NOrnzz.js → step-definitions-DhhwklAL.js} +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-B8FQ1e0a.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B07QYb7Q.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-DYQ6RHcb.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-D1Vdyu-4.mjs → hostInit-C02YjaoP.mjs} +3 -3
  26. package/dist/stream-broker/index.js +622 -149
  27. package/dist/stream-broker/index.mjs +622 -149
  28. package/dist/stream-broker/remoteEntry.js +1 -1
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CG5EE0W4.js → MaskShapeCanvas-DI4BY7W2-ColkZGA1.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BFeLUNOl.js → MotionZonesSettings-NcxxQN8r-BIzNJmu0.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-pQQunq1F.js → PrivacyMaskSettings-APgPLF7p-BHuYOg7Q.js} +1 -1
  32. package/embed-dist/assets/index-n1_ZSmy0.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-BxqQ931j.js +0 -81
@@ -1,11 +1,14 @@
1
1
  const require_chunk = require("../chunk-D6vf50IK.js");
2
- const require_dist = require("../dist-Bn6Uq4vq.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-DynD1EC-.js");
3
+ const require_sheet_geometry = require("../sheet-geometry-BR-ip0CP.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,945 @@ 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 ?? 480;
2079
+ const tileHeight = input.tileHeight ?? 270;
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 preset = await this.deps.resolvePreset(deviceId);
2233
+ const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
2234
+ const built = buildThumbnailIndex({
2235
+ deviceId,
2236
+ windowStartMs: win.windowStartMs,
2237
+ windowMs: this.deps.windowMs,
2238
+ intervalMs: this.deps.intervalMs,
2239
+ source: this.deps.source,
2240
+ tileWidth: preset.tileWidth,
2241
+ tileHeight: preset.tileHeight,
2242
+ maxCols: this.deps.maxCols,
2243
+ samples: ordered.map((s) => ({ t: s.capturedAt }))
2244
+ });
2245
+ const placements = require_sheet_geometry.allTilePlacements(ordered.length, built.geometry);
2246
+ const sheetBytes = await this.deps.compose({
2247
+ tiles: ordered.map((s) => s.jpeg),
2248
+ placements,
2249
+ geometry: built.geometry,
2250
+ quality: preset.quality
2251
+ });
2252
+ await this.deps.persist({
2253
+ deviceId,
2254
+ windowStartMs: win.windowStartMs,
2255
+ relDir: windowRelDir(deviceId, win.windowStartMs),
2256
+ sheetName: sheetFileName(win.windowStartMs),
2257
+ sheetBytes,
2258
+ indexName: indexFileName(win.windowStartMs),
2259
+ index: built.index
2260
+ });
2261
+ }
2262
+ };
2263
+ //#endregion
2264
+ //#region src/thumbnails/sheet-composer.ts
2265
+ /**
2266
+ * Resize each tile to the uniform tile size and composite onto a black canvas
2267
+ * at its placement. Returns the sheet JPEG bytes. Throws if tiles/placements
2268
+ * lengths disagree.
2269
+ */
2270
+ async function composeSheet(sharp, input) {
2271
+ const { tiles, placements, geometry } = input;
2272
+ if (tiles.length !== placements.length) throw new Error(`composeSheet: tiles(${tiles.length}) != placements(${placements.length})`);
2273
+ if (tiles.length === 0) throw new Error("composeSheet: no tiles");
2274
+ const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
2275
+ fit: "cover",
2276
+ position: "centre"
2277
+ }).jpeg({ quality: input.quality ?? 72 }).toBuffer()))).map((buf, i) => {
2278
+ const p = placements[i];
2279
+ return {
2280
+ input: buf,
2281
+ left: p.x,
2282
+ top: p.y
2283
+ };
2284
+ });
2285
+ return sharp({ create: {
2286
+ width: geometry.sheetWidth,
2287
+ height: geometry.sheetHeight,
2288
+ channels: 3,
2289
+ background: {
2290
+ r: 0,
2291
+ g: 0,
2292
+ b: 0
2293
+ }
2294
+ } }).composite(entries).jpeg({ quality: input.quality ?? 72 }).toBuffer();
2295
+ }
2296
+ //#endregion
2297
+ //#region src/thumbnails/thumbnail-persister.ts
2298
+ /**
2299
+ * Thumbnail disk persister — writes a window's sheet JPEG + index JSON
2300
+ * write-once under `<thumbsRoot>/<dev>/Y/M/D/H/`. Small fs wrapper the recorder
2301
+ * wires with a real root; kept separate so the sink stays fs-free and testable.
2302
+ */
2303
+ /** Root dir thumbs are written under for a location (`<locationRoot>/.thumbs`). */
2304
+ var THUMBS_SUBDIR = ".thumbs";
2305
+ /**
2306
+ * Persist one window. Writes are best-effort atomic (write to a `.tmp` then
2307
+ * rename) so a crash mid-write never leaves a truncated sheet a viewer would
2308
+ * cache as `immutable`. Idempotent: an existing sheet is left as-is (write-once).
2309
+ */
2310
+ async function persistThumbnailWindow(thumbsRoot, req) {
2311
+ const dir = node_path.default.join(thumbsRoot, req.relDir);
2312
+ await node_fs.promises.mkdir(dir, { recursive: true });
2313
+ const sheetPath = node_path.default.join(dir, req.sheetName);
2314
+ const indexPath = node_path.default.join(dir, req.indexName);
2315
+ await Promise.all([writeOnce(sheetPath, req.sheetBytes), writeOnce(indexPath, Buffer.from(JSON.stringify(req.index)))]);
2316
+ }
2317
+ async function writeOnce(absPath, bytes) {
2318
+ try {
2319
+ await node_fs.promises.access(absPath);
2320
+ return;
2321
+ } catch {}
2322
+ const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
2323
+ await node_fs.promises.writeFile(tmp, bytes);
2324
+ try {
2325
+ await node_fs.promises.rename(tmp, absPath);
2326
+ } catch (err) {
2327
+ await node_fs.promises.rm(tmp, { force: true }).catch(() => {});
2328
+ throw err;
2329
+ }
2330
+ }
2331
+ var HOUR_MS = 36e5;
2332
+ /**
2333
+ * Buckets whose whole hour ends before the retention cutoff. Cutoff = the
2334
+ * device's oldest indexed segment (thumbs live exactly as long as footage),
2335
+ * or `now - fallback` when the device has no segments.
2336
+ */
2337
+ function planThumbPrune(input) {
2338
+ const cutoffMs = input.oldestSegmentStartMs ?? input.nowMs - 2592e6;
2339
+ return input.buckets.filter((b) => b.hourStartMs + HOUR_MS <= cutoffMs);
2340
+ }
2341
+ /**
2342
+ * Parse a `Y/M/D/H` relative bucket path (as walked under a device dir) into
2343
+ * its UTC hour start. Returns null for anything that isn't a valid bucket
2344
+ * (staging debris, foreign files) so the janitor never deletes what it does
2345
+ * not understand.
2346
+ */
2347
+ function parseHourBucket(relYmdh) {
2348
+ const m = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{2})$/.exec(relYmdh);
2349
+ if (!m) return null;
2350
+ const [, y, mo, d, h] = m;
2351
+ const year = Number(y);
2352
+ const month = Number(mo);
2353
+ const day = Number(d);
2354
+ const hour = Number(h);
2355
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
2356
+ const t = Date.UTC(year, month - 1, day, hour);
2357
+ const dt = new Date(t);
2358
+ if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day || dt.getUTCHours() !== hour) return null;
2359
+ return t;
2360
+ }
2361
+ //#endregion
2362
+ //#region src/thumbnails/thumbs-media-handler.ts
2363
+ var IMMUTABLE = "public, max-age=31536000, immutable";
2364
+ /** Reject traversal / absolute / NUL and normalise leading slashes. Returns the
2365
+ * safe rel path or null. Exported for unit coverage. */
2366
+ function sanitizeThumbsPath(url) {
2367
+ const qIdx = url.indexOf("?");
2368
+ const raw = qIdx === -1 ? url : url.slice(0, qIdx);
2369
+ let rel;
2370
+ try {
2371
+ rel = decodeURIComponent(raw.replace(/^\/+/, ""));
2372
+ } catch {
2373
+ return null;
2374
+ }
2375
+ if (rel.length === 0) return null;
2376
+ if (rel.includes("\0")) return null;
2377
+ if (rel.startsWith("/")) return null;
2378
+ if (rel.split("/").some((seg) => seg === ".." || seg === ".")) return null;
2379
+ if (!/\.(jpe?g|json)$/i.test(rel)) return null;
2380
+ return rel;
2381
+ }
2382
+ /** Content type for a thumbs file by extension. */
2383
+ function thumbsContentType(relPath) {
2384
+ if (/\.json$/i.test(relPath)) return "application/json";
2385
+ return "image/jpeg";
2386
+ }
2387
+ function createThumbsMediaHandler(deps) {
2388
+ return async (req, res) => {
2389
+ if (req.method !== "GET" && req.method !== "HEAD") {
2390
+ res.writeHead(405, { allow: "GET, HEAD" }).end();
2391
+ return;
2392
+ }
2393
+ const rel = sanitizeThumbsPath(req.url ?? "/");
2394
+ if (rel === null) {
2395
+ res.writeHead(404).end();
2396
+ return;
2397
+ }
2398
+ const etag = `"${rel}"`;
2399
+ if (req.headers["if-none-match"] === etag) {
2400
+ res.writeHead(304, {
2401
+ etag,
2402
+ "cache-control": IMMUTABLE
2403
+ }).end();
2404
+ return;
2405
+ }
2406
+ let file;
2407
+ try {
2408
+ file = await deps.getFile(rel);
2409
+ } catch {
2410
+ const body = "Internal server error";
2411
+ res.writeHead(500, {
2412
+ "content-type": "text/plain",
2413
+ "content-length": String(Buffer.byteLength(body))
2414
+ });
2415
+ if (req.method === "HEAD") res.end();
2416
+ else res.end(body);
2417
+ return;
2418
+ }
2419
+ if (file === null) {
2420
+ res.writeHead(404).end();
2421
+ return;
2422
+ }
2423
+ res.writeHead(200, {
2424
+ "content-type": file.contentType,
2425
+ "cache-control": IMMUTABLE,
2426
+ etag,
2427
+ "content-length": String(file.bytes.byteLength)
2428
+ });
2429
+ if (req.method === "HEAD") res.end();
2430
+ else res.end(Buffer.from(file.bytes));
2431
+ };
2432
+ }
2433
+ //#endregion
2434
+ //#region src/thumbnails/thumb-backfill-plan.ts
2435
+ /**
2436
+ * Backfill PLANNER — pure (no ffmpeg, no I/O). Given a device's low segments
2437
+ * and the window starts already covered by a sampled/backfilled index, decide
2438
+ * which windows still need a keyframe-only ffmpeg pass and which segments cover
2439
+ * each. The executor (recorder) runs at most ONE ffmpeg per planned window,
2440
+ * single-flighted; this module owns only the "what to do" decision so it is
2441
+ * fully unit-testable.
2442
+ *
2443
+ * A window is a backfill candidate when:
2444
+ * - at least one low segment overlaps it (there is footage to sample), AND
2445
+ * - no index already covers it (sampling didn't reach it), AND
2446
+ * - the window is fully in the past by `settleMs` (no new segment can still
2447
+ * land in it — mirrors the segment store's current-hour eviction guard).
2448
+ */
2449
+ /** True when `[startMs, startMs+durMs)` overlaps `[winStart, winEnd)`. */
2450
+ function overlaps(startMs, durMs, winStart, winEnd) {
2451
+ return startMs < winEnd && startMs + durMs > winStart;
2452
+ }
2453
+ /**
2454
+ * Compute the ordered (ascending window) backfill plan. Deterministic and
2455
+ * bounded: returns at most `maxWindows` window plans (oldest first), each with
2456
+ * its overlapping segments.
2457
+ */
2458
+ function planBackfill(input) {
2459
+ const { segments, coveredWindows, windowMs, now, settleMs } = input;
2460
+ const maxWindows = input.maxWindows ?? 500;
2461
+ if (!(windowMs > 0)) return [];
2462
+ const candidates = /* @__PURE__ */ new Set();
2463
+ for (const seg of segments) {
2464
+ if (seg.durMs <= 0) continue;
2465
+ const firstW = windowStartFor(seg.startMs, windowMs);
2466
+ const lastW = windowStartFor(seg.startMs + seg.durMs - 1, windowMs);
2467
+ for (let w = firstW; w <= lastW; w += windowMs) candidates.add(w);
2468
+ }
2469
+ const plans = [];
2470
+ for (const windowStartMs of [...candidates].sort((a, b) => a - b)) {
2471
+ if (coveredWindows.has(windowStartMs)) continue;
2472
+ const windowEndMs = windowStartMs + windowMs;
2473
+ if (now - windowEndMs < settleMs) continue;
2474
+ const covering = segments.filter((s) => s.durMs > 0 && overlaps(s.startMs, s.durMs, windowStartMs, windowEndMs)).sort((a, b) => a.startMs - b.startMs);
2475
+ if (covering.length === 0) continue;
2476
+ plans.push({
2477
+ windowStartMs,
2478
+ windowEndMs,
2479
+ segments: covering
2480
+ });
2481
+ if (plans.length >= maxWindows) break;
2482
+ }
2483
+ return plans;
2484
+ }
2485
+ //#endregion
2486
+ //#region src/thumbnails/backfill-ffmpeg.ts
2487
+ /**
2488
+ * Map a JPEG quality (higher = better; the scrub presets span ~55..85) onto
2489
+ * ffmpeg's INVERSE `-q:v` scale (2 = best .. 31 = worst). Inverse-linear over
2490
+ * the full JPEG 1..100 domain, monotonic-decreasing (a higher JPEG quality
2491
+ * yields a lower/better ffmpeg q), clamped to [2, 31] for any input.
2492
+ *
2493
+ * These extracted keyframes are INTERMEDIATE — `composeSheet` re-encodes them
2494
+ * into the sheet at the preset's final JPEG quality — so this only governs the
2495
+ * extract's crispness. Anchoring the extract to the same preset keeps a
2496
+ * high-preset camera's backfill from being the bottleneck while a low-preset
2497
+ * camera spends fewer bytes on throwaway intermediates.
2498
+ */
2499
+ function jpegQualityToFfmpegQ(jpegQuality) {
2500
+ const q = Math.round(31 - (Math.min(100, Math.max(1, jpegQuality)) - 1) / 99 * 29);
2501
+ return Math.min(31, Math.max(2, q));
2502
+ }
2503
+ /**
2504
+ * ffmpeg args to extract keyframes from one segment as scaled JPEGs. `-skip_frame
2505
+ * nokey` + `-vsync 0` (a.k.a. passthrough) keeps only I-frames without
2506
+ * duplicating; `-an` drops audio. The caller resolves timestamps from the
2507
+ * segment start + the extracted frame order (keyframes are ~GOP-spaced).
2508
+ */
2509
+ function buildKeyframeExtractArgs(input) {
2510
+ const q = input.quality ?? 4;
2511
+ return [
2512
+ "-hide_banner",
2513
+ "-loglevel",
2514
+ "error",
2515
+ "-skip_frame",
2516
+ "nokey",
2517
+ "-i",
2518
+ input.inputPath,
2519
+ "-an",
2520
+ "-vsync",
2521
+ "0",
2522
+ "-vf",
2523
+ `scale=${input.tileWidth}:-2`,
2524
+ "-q:v",
2525
+ String(q),
2526
+ input.outputPattern
2527
+ ];
2528
+ }
2529
+ //#endregion
2530
+ //#region src/recorder/addon/thumbnail-service.ts
2531
+ /**
2532
+ * Recorder-side scrub-thumbnail SERVICE — the wiring that turns
2533
+ * `recording.thumb-sampled` telemetry into served sprite sheets.
2534
+ *
2535
+ * runner (any node) --recording.thumb-sampled event--> THIS (recording node)
2536
+ * -> ThumbnailSink accumulates a 5-min window
2537
+ * -> composeSheet (sharp) + persistThumbnailWindow (write-once)
2538
+ * -> data-plane `/addon/recorder/thumbs/<dev>/Y/M/D/H/{sheet,index}`
2539
+ *
2540
+ * Cross-node decision (documented, simplest per the analysis §3 open
2541
+ * questions): the thumb rides the EVENT BUS. The recorder is a SINGLE
2542
+ * designated node, so its data-plane prefix (`/addon/recorder/thumbs`) is
2543
+ * unambiguous — serving from any decode node's own prefix would be
2544
+ * multi-node-ambiguous. Sampling is compressed (~10 KB) at 1/5 Hz — well inside
2545
+ * D9 — and telemetry-grade (a lost thumb is a scrub gap the keyframe backfill
2546
+ * fills), so no new cap method and no RPC hop are needed.
2547
+ *
2548
+ * Backfill (phase 2): hourly, for windows with recorded LOW footage but no
2549
+ * sampled coverage, ONE keyframe-only ffmpeg pass per window fills quiet-period
2550
+ * heartbeat thumbs. Bounded concurrency + single-flight so a burst never spawns
2551
+ * a storm.
2552
+ */
2553
+ var THUMBS_PREFIX = "thumbs";
2554
+ var RECORDER_ADDON_ID$1 = "recorder";
2555
+ /** Idle window flush grace — a window whose end passed this long ago is
2556
+ * composed even if no later sample rolled it over (idle camera). */
2557
+ var IDLE_GRACE_MS = 3e4;
2558
+ /** Flush-timer cadence. */
2559
+ var FLUSH_TICK_MS = 15e3;
2560
+ /** Backfill scan cadence — 15 min so scrub coverage holes close within the
2561
+ * hour instead of lingering (spawns stay bounded: keyframe-only ffmpeg over
2562
+ * low segments, concurrency-gated, per-device window budget). */
2563
+ var BACKFILL_TICK_MS = 15 * 6e4;
2564
+ /** A window must have ended this long ago before backfill considers it. */
2565
+ var BACKFILL_SETTLE_MS = 5 * DEFAULT_WINDOW_MS;
2566
+ /** Max concurrent backfill ffmpeg passes across all cameras. */
2567
+ var BACKFILL_MAX_CONCURRENT = 2;
2568
+ /** Max windows filled PER DEVICE per backfill pass. A recording camera accrues
2569
+ * ≤12 uncovered 5-min windows/hour, so 24 lets a device also DRAIN backlog.
2570
+ * (This was a GLOBAL cap: 24/pass vs ~12×N windows/hour needed cluster-wide —
2571
+ * devices later in the list starved and coverage grew permanent holes,
2572
+ * live-measured 2026-07-17 on 590: gaps at −55…−110 min.) */
2573
+ var BACKFILL_MAX_WINDOWS_PER_DEVICE = 24;
2574
+ /** Global safety cap per pass (runaway bound across all cameras). */
2575
+ var BACKFILL_MAX_WINDOWS_PER_PASS = 240;
2576
+ /** Hard cap on tiles per window (bounds sheet size + compose cost). */
2577
+ var MAX_TILES_PER_WINDOW = 90;
2578
+ /** Retention-prune cadence (sheets are small — a slow sweep is plenty). */
2579
+ var PRUNE_TICK_MS = 6 * 36e5;
2580
+ /** First prune runs this long after start (off the boot churn). */
2581
+ var PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
2582
+ var SHARP = sharp.default;
2583
+ var ThumbnailService = class {
2584
+ deps;
2585
+ sink;
2586
+ unsub = null;
2587
+ dataPlaneDispose = null;
2588
+ flushTimer = null;
2589
+ backfillTimer = null;
2590
+ pruneTimer = null;
2591
+ pruneInitialTimer = null;
2592
+ backfillActive = 0;
2593
+ backfillInFlight = /* @__PURE__ */ new Set();
2594
+ stopped = false;
2595
+ constructor(deps) {
2596
+ this.deps = deps;
2597
+ this.sink = new ThumbnailSink({
2598
+ windowMs: deps.config.windowMs,
2599
+ intervalMs: deps.config.intervalMs,
2600
+ maxCols: 10,
2601
+ resolvePreset: (deviceId) => deps.resolvePreset(deviceId),
2602
+ maxTilesPerWindow: MAX_TILES_PER_WINDOW,
2603
+ idleGraceMs: IDLE_GRACE_MS,
2604
+ source: "sampled",
2605
+ compose: (req) => composeSheet(SHARP, {
2606
+ tiles: [...req.tiles],
2607
+ placements: req.placements,
2608
+ geometry: req.geometry,
2609
+ quality: req.quality
2610
+ }),
2611
+ persist: (req) => this.persist(req),
2612
+ logger: deps.logger.child("thumbnail-sink")
2613
+ });
2614
+ }
2615
+ /** First writable thumbs root (`<locationRoot>/.thumbs`), or null when no
2616
+ * recordings location is resolved yet. */
2617
+ writeRoot() {
2618
+ const loc = this.deps.locations()[0];
2619
+ return loc ? node_path.default.join(loc.root, THUMBS_SUBDIR) : null;
2620
+ }
2621
+ /** Every thumbs root (for the data-plane getFile search). */
2622
+ thumbsRoots() {
2623
+ return this.deps.locations().map((l) => node_path.default.join(l.root, THUMBS_SUBDIR));
2624
+ }
2625
+ /**
2626
+ * Serve ONLY the `thumbs` data-plane endpoint, without starting the sink /
2627
+ * timers. Called from the recorder's `onInitialize` — BEFORE the
2628
+ * node-registration handshake — because the hub pulls a forked addon's
2629
+ * data-plane endpoints once, off the caps-registered signal: an endpoint
2630
+ * served later (the `start()` in `onHubReachable` lands minutes after boot)
2631
+ * never reaches the reverse-proxy and `/addon/recorder/thumbs/*` 404s to the
2632
+ * SPA (live-diagnosed 2026-07-17). Safe to serve early: the handler resolves
2633
+ * roots lazily per request (mirrors the playback data-plane pattern).
2634
+ * Idempotent — `start()` re-invokes it harmlessly.
2635
+ */
2636
+ async serveEarly() {
2637
+ if (!this.deps.config.enabled) return;
2638
+ if (this.dataPlaneDispose !== null) return;
2639
+ await this.serveDataPlane();
2640
+ }
2641
+ async start() {
2642
+ if (!this.deps.config.enabled) {
2643
+ this.deps.logger.info("thumbnails disabled — service inert");
2644
+ return;
2645
+ }
2646
+ if (this.unsub !== null) return;
2647
+ this.unsub = this.deps.eventBus.subscribe({ category: require_dist.EventCategory.RecordingThumbSampled }, (event) => {
2648
+ if (!require_dist.isEvent(event, require_dist.EventCategory.RecordingThumbSampled)) return;
2649
+ const d = event.data;
2650
+ try {
2651
+ this.sink.add(d.deviceId, {
2652
+ capturedAt: d.capturedAt,
2653
+ jpeg: Buffer.from(d.jpeg)
2654
+ });
2655
+ } catch (err) {
2656
+ this.deps.logger.debug("thumbnail add failed", {
2657
+ tags: { deviceId: d.deviceId },
2658
+ meta: { error: err instanceof Error ? err.message : String(err) }
2659
+ });
2660
+ }
2661
+ });
2662
+ if (this.dataPlaneDispose === null) await this.serveDataPlane();
2663
+ this.flushTimer = setInterval(() => this.sink.flushExpired(), FLUSH_TICK_MS);
2664
+ if (typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref?.();
2665
+ if (this.deps.config.backfillEnabled) {
2666
+ this.backfillTimer = setInterval(() => {
2667
+ this.runBackfillPass();
2668
+ }, BACKFILL_TICK_MS);
2669
+ this.backfillTimer.unref?.();
2670
+ }
2671
+ this.pruneInitialTimer = setTimeout(() => {
2672
+ this.runPrunePass();
2673
+ }, PRUNE_INITIAL_DELAY_MS);
2674
+ this.pruneInitialTimer.unref?.();
2675
+ this.pruneTimer = setInterval(() => {
2676
+ this.runPrunePass();
2677
+ }, PRUNE_TICK_MS);
2678
+ this.pruneTimer.unref?.();
2679
+ this.deps.logger.info("thumbnail service started", { meta: {
2680
+ windowMs: this.deps.config.windowMs,
2681
+ intervalMs: this.deps.config.intervalMs,
2682
+ backfill: this.deps.config.backfillEnabled
2683
+ } });
2684
+ }
2685
+ async stop() {
2686
+ this.stopped = true;
2687
+ this.unsub?.();
2688
+ this.unsub = null;
2689
+ if (this.flushTimer) clearInterval(this.flushTimer);
2690
+ if (this.backfillTimer) clearInterval(this.backfillTimer);
2691
+ if (this.pruneTimer) clearInterval(this.pruneTimer);
2692
+ if (this.pruneInitialTimer) clearTimeout(this.pruneInitialTimer);
2693
+ this.flushTimer = null;
2694
+ this.backfillTimer = null;
2695
+ this.pruneTimer = null;
2696
+ this.pruneInitialTimer = null;
2697
+ try {
2698
+ await this.sink.flushAll();
2699
+ } catch {}
2700
+ await this.dataPlaneDispose?.().catch(() => {});
2701
+ this.dataPlaneDispose = null;
2702
+ }
2703
+ async serveDataPlane() {
2704
+ const handler = createThumbsMediaHandler({ getFile: async (rel) => {
2705
+ for (const root of this.thumbsRoots()) {
2706
+ const abs = node_path.default.join(root, rel);
2707
+ if (!abs.startsWith(root + node_path.default.sep)) continue;
2708
+ try {
2709
+ return {
2710
+ bytes: await node_fs.promises.readFile(abs),
2711
+ contentType: thumbsContentType(rel)
2712
+ };
2713
+ } catch {}
2714
+ }
2715
+ return null;
2716
+ } });
2717
+ try {
2718
+ const served = await this.deps.dataPlane?.serve({
2719
+ prefix: THUMBS_PREFIX,
2720
+ access: "authenticated",
2721
+ handler
2722
+ });
2723
+ if (served) {
2724
+ this.dataPlaneDispose = () => served.dispose();
2725
+ this.deps.logger.info("thumbnail data-plane served", { meta: { clientBase: `/addon/${RECORDER_ADDON_ID$1}/${THUMBS_PREFIX}` } });
2726
+ }
2727
+ } catch (err) {
2728
+ this.deps.logger.warn("thumbnail data-plane failed to serve", { meta: { error: err instanceof Error ? err.message : String(err) } });
2729
+ }
2730
+ }
2731
+ /**
2732
+ * One retention sweep: for every device dir under every thumbs root, drop
2733
+ * the hour buckets fully older than the device's oldest indexed segment
2734
+ * (thumbs live exactly as long as footage; fixed fallback age when the
2735
+ * device has no segments). Pure decision in `planThumbPrune`; only valid
2736
+ * `Y/M/D/H` buckets are ever deleted. Best-effort — a failed rm retries on
2737
+ * the next sweep.
2738
+ */
2739
+ async runPrunePass() {
2740
+ if (this.stopped) return;
2741
+ let pruned = 0;
2742
+ for (const root of this.thumbsRoots()) {
2743
+ let deviceDirs;
2744
+ try {
2745
+ deviceDirs = (await node_fs.promises.readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory() && /^\d+$/.test(e.name)).map((e) => e.name);
2746
+ } catch {
2747
+ continue;
2748
+ }
2749
+ for (const dev of deviceDirs) {
2750
+ if (this.stopped) return;
2751
+ const deviceId = Number(dev);
2752
+ const deviceDir = node_path.default.join(root, dev);
2753
+ const buckets = [];
2754
+ try {
2755
+ for (const rel of await node_fs.promises.readdir(deviceDir, { recursive: true })) {
2756
+ const relPosix = String(rel).split(node_path.default.sep).join("/");
2757
+ const hourStartMs = parseHourBucket(relPosix);
2758
+ if (hourStartMs !== null) buckets.push({
2759
+ relDir: relPosix,
2760
+ hourStartMs
2761
+ });
2762
+ }
2763
+ } catch {
2764
+ continue;
2765
+ }
2766
+ const segments = this.deps.lowSegments(deviceId);
2767
+ const plan = planThumbPrune({
2768
+ buckets,
2769
+ oldestSegmentStartMs: segments.length > 0 ? Math.min(...segments.map((s) => s.startMs)) : null,
2770
+ nowMs: Date.now()
2771
+ });
2772
+ for (const b of plan) try {
2773
+ await node_fs.promises.rm(node_path.default.join(deviceDir, b.relDir), {
2774
+ recursive: true,
2775
+ force: true
2776
+ });
2777
+ pruned += 1;
2778
+ } catch {}
2779
+ if (plan.length > 0) await removeEmptyParents(deviceDir);
2780
+ }
2781
+ }
2782
+ if (pruned > 0) this.deps.logger.info("thumbnail retention prune removed buckets", { meta: { pruned } });
2783
+ }
2784
+ async persist(req) {
2785
+ const root = this.writeRoot();
2786
+ if (!root) {
2787
+ this.deps.logger.warn("thumbnail persist skipped — no recordings location resolved", { tags: { deviceId: req.deviceId } });
2788
+ return;
2789
+ }
2790
+ await persistThumbnailWindow(root, req);
2791
+ }
2792
+ /** One backfill scan: fill uncovered, settled windows with recorded low
2793
+ * footage, bounded per pass + per concurrency. Best-effort. */
2794
+ async runBackfillPass() {
2795
+ if (this.stopped || !this.deps.config.backfillEnabled) return;
2796
+ const root = this.writeRoot();
2797
+ if (!root) return;
2798
+ let deviceIds;
2799
+ try {
2800
+ deviceIds = await this.deps.recordingDeviceIds();
2801
+ } catch {
2802
+ return;
2803
+ }
2804
+ let filled = 0;
2805
+ for (const deviceId of deviceIds) {
2806
+ if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || this.stopped) break;
2807
+ const segments = this.deps.lowSegments(deviceId).map((s) => ({
2808
+ startMs: s.startMs,
2809
+ durMs: s.durMs,
2810
+ path: s.path
2811
+ }));
2812
+ if (segments.length === 0) continue;
2813
+ const plans = planBackfill({
2814
+ segments,
2815
+ coveredWindows: await this.readCoveredWindows(root, deviceId),
2816
+ windowMs: this.deps.config.windowMs,
2817
+ now: Date.now(),
2818
+ settleMs: BACKFILL_SETTLE_MS,
2819
+ maxWindows: Math.min(BACKFILL_MAX_WINDOWS_PER_DEVICE, BACKFILL_MAX_WINDOWS_PER_PASS - filled)
2820
+ });
2821
+ let filledForDevice = 0;
2822
+ for (const plan of plans) {
2823
+ if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || filledForDevice >= BACKFILL_MAX_WINDOWS_PER_DEVICE || this.stopped) break;
2824
+ const key = `${deviceId}:${plan.windowStartMs}`;
2825
+ if (this.backfillInFlight.has(key)) continue;
2826
+ this.backfillInFlight.add(key);
2827
+ try {
2828
+ await this.gateBackfill(() => this.backfillWindow(deviceId, plan.windowStartMs, plan.segments, root));
2829
+ filled += 1;
2830
+ filledForDevice += 1;
2831
+ } catch (err) {
2832
+ this.deps.logger.debug("backfill window failed", {
2833
+ tags: { deviceId },
2834
+ meta: {
2835
+ windowStartMs: plan.windowStartMs,
2836
+ error: err instanceof Error ? err.message : err
2837
+ }
2838
+ });
2839
+ } finally {
2840
+ this.backfillInFlight.delete(key);
2841
+ }
2842
+ }
2843
+ }
2844
+ if (filled > 0) this.deps.logger.info("thumbnail backfill pass filled windows", { meta: { filled } });
2845
+ }
2846
+ /** Bounded-concurrency gate (spin-wait, coarse — backfill is rare/hourly). */
2847
+ async gateBackfill(fn) {
2848
+ while (this.backfillActive >= BACKFILL_MAX_CONCURRENT && !this.stopped) await new Promise((r) => setTimeout(r, 200));
2849
+ this.backfillActive += 1;
2850
+ try {
2851
+ return await fn();
2852
+ } finally {
2853
+ this.backfillActive -= 1;
2854
+ }
2855
+ }
2856
+ /** Which windows already have an index sidecar on disk (sampled or prior backfill). */
2857
+ async readCoveredWindows(root, deviceId) {
2858
+ const covered = /* @__PURE__ */ new Set();
2859
+ const deviceDir = node_path.default.join(root, String(deviceId));
2860
+ let entries;
2861
+ try {
2862
+ entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
2863
+ } catch {
2864
+ return covered;
2865
+ }
2866
+ for (const e of entries) {
2867
+ const m = /index-(\d+)\.json$/.exec(e);
2868
+ if (m) covered.add(Number(m[1]));
2869
+ }
2870
+ return covered;
2871
+ }
2872
+ /** Fill ONE window: extract keyframes from its covering low segments, compose
2873
+ * a sheet, persist with `source:'backfill'`. */
2874
+ async backfillWindow(deviceId, windowStartMs, segments, root) {
2875
+ const preset = await this.deps.resolvePreset(deviceId);
2876
+ const workDir = await node_fs.promises.mkdtemp(node_path.default.join((0, node_os.tmpdir)(), `csthumb-${deviceId}-`));
2877
+ try {
2878
+ const samples = [];
2879
+ for (const seg of segments) {
2880
+ const absSeg = this.resolveSegmentAbs(deviceId, seg.path);
2881
+ if (!absSeg) continue;
2882
+ const jpegs = await this.extractKeyframes(absSeg, workDir, preset);
2883
+ const n = jpegs.length;
2884
+ for (let i = 0; i < n; i += 1) {
2885
+ const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
2886
+ if (t >= windowStartMs && t < windowStartMs + this.deps.config.windowMs) samples.push({
2887
+ t,
2888
+ jpeg: jpegs[i]
2889
+ });
2890
+ }
2891
+ }
2892
+ if (samples.length === 0) return;
2893
+ if (samples.length > MAX_TILES_PER_WINDOW) samples.length = MAX_TILES_PER_WINDOW;
2894
+ const ordered = samples.sort((a, b) => a.t - b.t);
2895
+ const built = buildThumbnailIndex({
2896
+ deviceId,
2897
+ windowStartMs,
2898
+ windowMs: this.deps.config.windowMs,
2899
+ intervalMs: this.deps.config.intervalMs,
2900
+ source: "backfill",
2901
+ tileWidth: preset.tileWidth,
2902
+ tileHeight: preset.tileHeight,
2903
+ maxCols: 10,
2904
+ samples: ordered.map((s) => ({ t: s.t }))
2905
+ });
2906
+ const geometry = require_sheet_geometry.computeSheetGeometry(ordered.length, {
2907
+ tileWidth: preset.tileWidth,
2908
+ tileHeight: preset.tileHeight,
2909
+ maxCols: 10
2910
+ });
2911
+ const placements = require_sheet_geometry.allTilePlacements(ordered.length, geometry);
2912
+ const sheetBytes = await composeSheet(SHARP, {
2913
+ tiles: ordered.map((s) => s.jpeg),
2914
+ placements,
2915
+ geometry,
2916
+ quality: preset.quality
2917
+ });
2918
+ await persistThumbnailWindow(root, {
2919
+ deviceId,
2920
+ windowStartMs,
2921
+ relDir: `${deviceId}/${bucketDir(windowStartMs)}`,
2922
+ sheetName: sheetFileName(windowStartMs),
2923
+ sheetBytes,
2924
+ indexName: indexFileName(windowStartMs),
2925
+ index: built.index
2926
+ });
2927
+ } finally {
2928
+ await node_fs.promises.rm(workDir, {
2929
+ recursive: true,
2930
+ force: true
2931
+ }).catch(() => {});
2932
+ }
2933
+ }
2934
+ /** Resolve a low segment's absolute path from its location root + rel path. */
2935
+ resolveSegmentAbs(deviceId, relPath) {
2936
+ const row = this.deps.lowSegments(deviceId).find((r) => r.path === relPath);
2937
+ if (!row) return null;
2938
+ const loc = this.deps.locations().find((l) => l.id === row.locationId);
2939
+ if (!loc) return null;
2940
+ return node_path.default.join(loc.root, relPath);
2941
+ }
2942
+ /** Run ONE keyframe-only ffmpeg pass over a segment; return the extracted
2943
+ * JPEG buffers in order. */
2944
+ async extractKeyframes(absSeg, workDir, preset) {
2945
+ const args = buildKeyframeExtractArgs({
2946
+ inputPath: absSeg,
2947
+ outputPattern: node_path.default.join(workDir, "kf-%04d.jpg"),
2948
+ tileWidth: preset.tileWidth,
2949
+ quality: jpegQualityToFfmpegQ(preset.quality)
2950
+ });
2951
+ await this.spawnFfmpeg(args);
2952
+ const files = (await node_fs.promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
2953
+ const out = [];
2954
+ for (const f of files) {
2955
+ out.push(await node_fs.promises.readFile(node_path.default.join(workDir, f)));
2956
+ await node_fs.promises.rm(node_path.default.join(workDir, f), { force: true }).catch(() => {});
2957
+ }
2958
+ return out;
2959
+ }
2960
+ spawnFfmpeg(args) {
2961
+ return new Promise((resolve, reject) => {
2962
+ let child;
2963
+ try {
2964
+ child = (0, node_child_process.spawn)("ffmpeg", [...args], { stdio: [
2965
+ "ignore",
2966
+ "ignore",
2967
+ "ignore"
2968
+ ] });
2969
+ } catch (err) {
2970
+ reject(err instanceof Error ? err : new Error(String(err)));
2971
+ return;
2972
+ }
2973
+ const timer = setTimeout(() => {
2974
+ child.kill("SIGKILL");
2975
+ reject(/* @__PURE__ */ new Error("ffmpeg keyframe extract timed out"));
2976
+ }, 3e4);
2977
+ timer.unref?.();
2978
+ child.on("error", (err) => {
2979
+ clearTimeout(timer);
2980
+ reject(err);
2981
+ });
2982
+ child.on("close", (code) => {
2983
+ clearTimeout(timer);
2984
+ if (code === 0) resolve();
2985
+ else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${code}`));
2986
+ });
2987
+ });
2988
+ }
2989
+ };
2990
+ /**
2991
+ * Best-effort removal of now-empty `Y/M/D` parent dirs after a prune (never
2992
+ * the device dir itself). `rmdir` refuses non-empty dirs, so this can never
2993
+ * delete content — failures are simply left for the next sweep.
2994
+ */
2995
+ async function removeEmptyParents(deviceDir) {
2996
+ let entries;
2997
+ try {
2998
+ entries = (await node_fs.promises.readdir(deviceDir, { recursive: true })).map(String);
2999
+ } catch {
3000
+ return;
3001
+ }
3002
+ 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);
3003
+ for (const rel of dirs) try {
3004
+ await node_fs.promises.rmdir(node_path.default.join(deviceDir, rel));
3005
+ } catch {}
3006
+ }
2049
3007
  //#endregion
2050
3008
  //#region src/recorder/addon/index.ts
2051
3009
  /**
@@ -2080,7 +3038,9 @@ var DEFAULT_CONFIG = {
2080
3038
  segmentSeconds: 10,
2081
3039
  watchIntervalMs: 2e3,
2082
3040
  recordingNodeId: RECORDING_NODE_DEFAULT,
2083
- recordingHubHostname: ""
3041
+ recordingHubHostname: "",
3042
+ thumbnailsEnabled: true,
3043
+ thumbnailBackfillEnabled: true
2084
3044
  };
2085
3045
  /** Playback-marker window padding around a motion event (EventMap only; the
2086
3046
  * controller's events-band gating uses the raw trigger ms, not this pad).
@@ -2120,6 +3080,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2120
3080
  resolvedLocations = [];
2121
3081
  /** Base URL of the playback HTTP data-plane, null until served in onHubReachable. */
2122
3082
  playbackBaseUrl = null;
3083
+ /** Scrub-thumbnail service (sink + data-plane + backfill). Null until started. */
3084
+ thumbnailService = null;
2123
3085
  constructor() {
2124
3086
  super({ ...DEFAULT_CONFIG });
2125
3087
  }
@@ -2224,7 +3186,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2224
3186
  store: this.segmentStore
2225
3187
  });
2226
3188
  try {
2227
- const handler = require_model_download_service_C_IHWnXx.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
3189
+ const handler = require_model_download_service_Cp9f4dk6.createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() });
2228
3190
  const served = await this.ctx.dataPlane?.serve({
2229
3191
  prefix: PLAYBACK_PREFIX,
2230
3192
  access: "authenticated",
@@ -2238,6 +3200,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2238
3200
  } catch (err) {
2239
3201
  this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
2240
3202
  }
3203
+ await this.ensureThumbnailService().serveEarly();
2241
3204
  this.ctx.logger.info("recorder addon started (continuous write path active)", {
2242
3205
  tags: { nodeId: this.nodeId },
2243
3206
  meta: {
@@ -2259,6 +3222,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2259
3222
  await this.controller?.stop();
2260
3223
  this.controller = null;
2261
3224
  this.segmentStore = null;
3225
+ await this.thumbnailService?.stop();
3226
+ this.thumbnailService = null;
2262
3227
  }
2263
3228
  /**
2264
3229
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
@@ -2311,6 +3276,37 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2311
3276
  this.controller?.onTrigger(deviceId, "motion", atMs);
2312
3277
  }
2313
3278
  });
3279
+ try {
3280
+ await this.ensureThumbnailService().start();
3281
+ } catch (err) {
3282
+ this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: require_dist.errMsg(err) } });
3283
+ }
3284
+ }
3285
+ /** Construct the thumbnail service once — every dep is a lazy closure, so
3286
+ * construction is safe in `onInitialize` (before locations resolve). */
3287
+ ensureThumbnailService() {
3288
+ if (this.thumbnailService === null) this.thumbnailService = new ThumbnailService({
3289
+ logger: this.ctx.logger.child("thumbnails"),
3290
+ eventBus: this.ctx.eventBus,
3291
+ ...this.ctx.dataPlane !== void 0 ? { dataPlane: this.ctx.dataPlane } : {},
3292
+ locations: () => this.resolvedLocations,
3293
+ lowSegments: (deviceId) => this.index.segments(deviceId, "low"),
3294
+ recordingDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
3295
+ resolvePreset: async (deviceId) => {
3296
+ try {
3297
+ return require_dist.resolveScrubThumbnailGeometry((await loadDeviceConfig(this.configStore(), deviceId)).scrubThumbnails);
3298
+ } catch {
3299
+ return require_dist.resolveScrubThumbnailGeometry(void 0);
3300
+ }
3301
+ },
3302
+ config: {
3303
+ enabled: this.config.thumbnailsEnabled,
3304
+ backfillEnabled: this.config.thumbnailBackfillEnabled,
3305
+ windowMs: DEFAULT_WINDOW_MS,
3306
+ intervalMs: 5e3
3307
+ }
3308
+ });
3309
+ return this.thumbnailService;
2314
3310
  }
2315
3311
  /** Re-resolve recordings locations (rescanStorage picks up newly-added disks). */
2316
3312
  async refreshLocations() {
@@ -2387,6 +3383,24 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
2387
3383
  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
3384
  default: ""
2389
3385
  }]
3386
+ }, {
3387
+ id: "scrub-thumbnails",
3388
+ title: "Scrub thumbnails",
3389
+ 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).",
3390
+ columns: 1,
3391
+ fields: [{
3392
+ type: "boolean",
3393
+ key: "thumbnailsEnabled",
3394
+ label: "Serve scrub thumbnails",
3395
+ description: "Accumulate + serve sprite sheets. Disable to stop packing/serving thumbnails.",
3396
+ default: true
3397
+ }, {
3398
+ type: "boolean",
3399
+ key: "thumbnailBackfillEnabled",
3400
+ label: "Keyframe backfill",
3401
+ 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).",
3402
+ default: true
3403
+ }]
2390
3404
  }] });
2391
3405
  }
2392
3406
  /** Durable handle config-store reads/writes the deviceId→config blob on —