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