@camstack/addon-pipeline 1.1.57 → 1.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +4 -4
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +9 -9
- package/dist/detection-pipeline/index.mjs +3 -3
- package/dist/{dist-CK8Ur-OS.js → dist-B1u-QN05.js} +4822 -4650
- package/dist/{dist-CL531uVA.mjs → dist-DKeNH_5z.mjs} +4823 -4651
- package/dist/{model-download-service-C-IHWnXx-3Mmeob3l.mjs → model-download-service-Cp9f4dk6-0wh7OK3s.mjs} +2 -2
- package/dist/{model-download-service-C-IHWnXx-D326YNnt.js → model-download-service-Cp9f4dk6-Cp_GTKTw.js} +2 -2
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +147 -12
- package/dist/pipeline-runner/index.mjs +146 -11
- package/dist/recorder/index.js +993 -7
- package/dist/recorder/index.mjs +990 -5
- package/dist/session-decode/decode-worker-child.js +110 -2
- package/dist/session-decode/decode-worker-child.mjs +110 -2
- package/dist/{hub-hostname-cCknRYKj.mjs → sheet-geometry-BN80zpCa.js} +56 -1
- package/dist/{hub-hostname-DAJXlOgV.js → sheet-geometry-Bk3fR4s7.mjs} +39 -6
- package/dist/{step-definitions-DHAxruAQ.js → step-definitions-CPch37vh.js} +1 -1
- package/dist/{step-definitions-1d3_vQ6S.mjs → step-definitions-DUB27V8Y.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cm_B-hQT.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DdkfKDgw.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D3h_W9uu.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DSXctoaw.mjs +26 -0
- package/dist/stream-broker/{hostInit-DEaqJpGA.mjs → hostInit-BLn2Pv8Q.mjs} +3 -3
- package/dist/stream-broker/index.js +626 -152
- package/dist/stream-broker/index.mjs +626 -152
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CZUzu3MF.js → MaskShapeCanvas-DI4BY7W2-B0_nx15B.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CmZbzLQi.js → MotionZonesSettings-NcxxQN8r-B8uM-VeO.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BOKDrH0N.js → PrivacyMaskSettings-APgPLF7p-DMOs4Hj9.js} +1 -1
- package/embed-dist/assets/index-W9-H5LTj.js +81 -0
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Ba7Eyd47.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-TWCUWhCm.mjs +0 -26
- package/embed-dist/assets/index-DOsK8CZb.js +0 -81
package/dist/recorder/index.mjs
CHANGED
|
@@ -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
|
|
2
|
-
import {
|
|
3
|
-
import { t as createFileDataPlaneHandler } from "../model-download-service-
|
|
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 —
|