@camstack/addon-pipeline 1.2.6 → 1.2.8
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 +3 -3
- package/dist/audio-analyzer/index.mjs +2 -3
- package/dist/{chunk-D6vf50IK.js → chunk-emK7D4bc.js} +7 -0
- package/dist/detection-pipeline/index.js +104 -8
- package/dist/detection-pipeline/index.mjs +103 -8
- package/dist/{dist-Bh0H8jM6.js → dist-DNjy_sr4.js} +1374 -925
- package/dist/{dist-Bw_V5L0r.mjs → dist-owmpaeIY.mjs} +1379 -924
- package/dist/fmp4-keyframes-BzTPoPVn.js +6676 -0
- package/dist/fmp4-keyframes-qOgJBjMO.mjs +6669 -0
- package/dist/{sheet-geometry-DcpuGTCm.mjs → hub-hostname-DAJXlOgV.js} +6 -39
- package/dist/{sheet-geometry-BR-ip0CP.js → hub-hostname-cCknRYKj.mjs} +1 -56
- package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-Cp9f4dk6-WO-foD7W.mjs} +26 -1
- package/dist/{model-download-service-Cp9f4dk6-Dv-oFwjN.js → model-download-service-Cp9f4dk6-fsdDExML.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +51 -132
- package/dist/pipeline-runner/index.mjs +49 -130
- package/dist/recorder/index.js +695 -974
- package/dist/recorder/index.mjs +694 -972
- package/dist/session-decode/decode-worker-child.js +9 -5
- package/dist/session-decode/decode-worker-child.mjs +8 -4
- package/dist/{step-definitions-xxdpJX9-.js → step-definitions-C2bNlww3.js} +1 -1
- package/dist/{step-definitions-aY4D9Loy.mjs → step-definitions-CkvctwuS.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DX-Ef8CP.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-XdI0RgvI.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DSfbOjdi.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-BdxY9zld.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--zEZtV2N.mjs} +1 -1
- package/dist/stream-broker/{hostInit-DUL71Vjq.mjs → hostInit-DsMIdlwP.mjs} +2 -2
- package/dist/stream-broker/index.js +680 -15
- package/dist/stream-broker/index.mjs +679 -15
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CGWEeRkz.js → MaskShapeCanvas-DI4BY7W2-CftzR7Pj.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-4YbTa3Wp.js → MotionZonesSettings-NcxxQN8r-DPgVIdEn.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DL_tAs9-.js → PrivacyMaskSettings-APgPLF7p-D0HqiXHW.js} +1 -1
- package/embed-dist/assets/{index-CvBwcYl_.js → index-DRtbV_en.js} +17 -15
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/postprocessors/ctc.py +14 -5
- package/python/postprocessors/test_ctc.py +35 -0
- package/dist/chunk-BdkLduGY.mjs +0 -5
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-gchR4WlX.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-WO-foD7W.mjs";
|
|
2
|
+
import { $ as selectAssignedProfileSlots, F as recordingExportCapability, G as DeviceType, H as BaseAddon, L as storageEvictableCapability, P as recordingCapability, V as errMsg, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, j as nodePin, k as migrateConfigToBands, lt as record, nt as array, p as RecordingConfigSchema, q as hydrateSchema, u as ExportRecordSchema, ut as string } from "../dist-owmpaeIY.mjs";
|
|
3
|
+
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
|
+
import { t as extractKeyframes } from "../fmp4-keyframes-qOgJBjMO.mjs";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
|
-
import
|
|
6
|
-
import { createReadStream, promises } from "node:fs";
|
|
6
|
+
import { createReadStream, promises, realpathSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { tmpdir } from "node:os";
|
|
9
8
|
import { randomUUID } from "node:crypto";
|
|
10
9
|
//#region src/recorder/segment-path.ts
|
|
11
10
|
function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
|
|
@@ -141,6 +140,43 @@ var RecordingIndex = class {
|
|
|
141
140
|
for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
|
|
142
141
|
return out.toSorted((a, b) => a.startMs - b.startMs);
|
|
143
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* All segments across a SET of storage locations (one eviction DOMAIN — the
|
|
145
|
+
* locations that alias one physical filesystem root), oldest-first across the
|
|
146
|
+
* whole pool. This is the correct unit for byte-eviction: two location ids over
|
|
147
|
+
* the same disk must be reclaimed as ONE oldest-first stream, never partitioned.
|
|
148
|
+
*/
|
|
149
|
+
segmentsOnLocations(locationIds) {
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const m of this.byDevice.values()) for (const s of m.values()) if (locationIds.has(s.locationId)) out.push(s);
|
|
152
|
+
return out.toSorted((a, b) => a.startMs - b.startMs);
|
|
153
|
+
}
|
|
154
|
+
/** Aggregate accounting across a SET of storage locations (one eviction domain). */
|
|
155
|
+
accountingForLocations(locationIds) {
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
let count = 0;
|
|
158
|
+
let oldestMs = Infinity;
|
|
159
|
+
let newestMs = -Infinity;
|
|
160
|
+
for (const m of this.byDevice.values()) for (const s of m.values()) {
|
|
161
|
+
if (!locationIds.has(s.locationId)) continue;
|
|
162
|
+
bytes += s.bytes;
|
|
163
|
+
count += 1;
|
|
164
|
+
if (s.startMs < oldestMs) oldestMs = s.startMs;
|
|
165
|
+
if (s.startMs > newestMs) newestMs = s.startMs;
|
|
166
|
+
}
|
|
167
|
+
if (count === 0) return {
|
|
168
|
+
bytes: 0,
|
|
169
|
+
count: 0,
|
|
170
|
+
oldestMs: null,
|
|
171
|
+
newestMs: null
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
bytes,
|
|
175
|
+
count,
|
|
176
|
+
oldestMs,
|
|
177
|
+
newestMs
|
|
178
|
+
};
|
|
179
|
+
}
|
|
144
180
|
/** Aggregate accounting for a storage location across every device. */
|
|
145
181
|
accountingForLocation(locationId) {
|
|
146
182
|
let bytes = 0;
|
|
@@ -413,18 +449,60 @@ var StorageEvictableProvider = class {
|
|
|
413
449
|
constructor(deps) {
|
|
414
450
|
this.deps = deps;
|
|
415
451
|
}
|
|
452
|
+
/** The eviction domain of `locationId` — aliases sharing its disk, else itself. */
|
|
453
|
+
domainOf(locationId) {
|
|
454
|
+
return this.deps.resolveDomain?.(locationId) ?? new Set([locationId]);
|
|
455
|
+
}
|
|
416
456
|
async getEvictableUsage(input) {
|
|
417
|
-
return { bytes: this.deps.index.
|
|
457
|
+
return { bytes: this.deps.index.accountingForLocations(this.domainOf(input.locationId)).bytes };
|
|
418
458
|
}
|
|
419
459
|
async evict(input) {
|
|
420
|
-
const
|
|
421
|
-
const victims = selectEvictionsByBytes(
|
|
460
|
+
const onDomain = this.deps.index.segmentsOnLocations(this.domainOf(input.locationId));
|
|
461
|
+
const victims = selectEvictionsByBytes(onDomain, input.targetBytes);
|
|
462
|
+
const byLocation = /* @__PURE__ */ new Map();
|
|
463
|
+
for (const v of victims) {
|
|
464
|
+
const group = byLocation.get(v.locationId);
|
|
465
|
+
if (group) group.push(v);
|
|
466
|
+
else byLocation.set(v.locationId, [v]);
|
|
467
|
+
}
|
|
468
|
+
let reclaimedBytes = 0;
|
|
469
|
+
for (const [locationId, rows] of byLocation) reclaimedBytes += await this.deps.store.evict(locationId, rows);
|
|
470
|
+
if (reclaimedBytes > 0 && this.deps.onEvicted) this.deps.onEvicted(summarizeEvictionsByDevice(victims));
|
|
471
|
+
const exhausted = victims.length >= onDomain.length;
|
|
422
472
|
return {
|
|
423
|
-
reclaimedBytes
|
|
424
|
-
exhausted
|
|
473
|
+
reclaimedBytes,
|
|
474
|
+
exhausted
|
|
425
475
|
};
|
|
426
476
|
}
|
|
427
477
|
};
|
|
478
|
+
/** Group an eviction round's victims into per-(device, location) summaries (bytes
|
|
479
|
+
* come from the pre-delete selection — partial delete failures may overstate
|
|
480
|
+
* slightly). A domain round can span aliased locations, so each victim is bucketed
|
|
481
|
+
* by its OWN `locationId` (not a single round-level location). */
|
|
482
|
+
function summarizeEvictionsByDevice(victims) {
|
|
483
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
484
|
+
for (const v of victims) {
|
|
485
|
+
const key = `${v.deviceId}${v.locationId}`;
|
|
486
|
+
const cur = byKey.get(key);
|
|
487
|
+
if (cur === void 0) byKey.set(key, {
|
|
488
|
+
deviceId: v.deviceId,
|
|
489
|
+
locationId: v.locationId,
|
|
490
|
+
segments: 1,
|
|
491
|
+
bytes: v.bytes,
|
|
492
|
+
fromMs: v.startMs,
|
|
493
|
+
toMs: v.startMs + v.durMs
|
|
494
|
+
});
|
|
495
|
+
else byKey.set(key, {
|
|
496
|
+
deviceId: v.deviceId,
|
|
497
|
+
locationId: v.locationId,
|
|
498
|
+
segments: cur.segments + 1,
|
|
499
|
+
bytes: cur.bytes + v.bytes,
|
|
500
|
+
fromMs: Math.min(cur.fromMs, v.startMs),
|
|
501
|
+
toMs: Math.max(cur.toMs, v.startMs + v.durMs)
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return [...byKey.values()];
|
|
505
|
+
}
|
|
428
506
|
//#endregion
|
|
429
507
|
//#region src/recorder/redundant-segments.ts
|
|
430
508
|
/**
|
|
@@ -1692,6 +1770,18 @@ async function markDownloaded(id, now, deps, scheduleDelete, graceMs) {
|
|
|
1692
1770
|
/** The two recordings location types the recorder addon writes/reads. */
|
|
1693
1771
|
var RECORDING_LOCATION_TYPES = new Set(["recordings", "recordingsLow"]);
|
|
1694
1772
|
/**
|
|
1773
|
+
* Route a recording profile to its storage location by the location's declared
|
|
1774
|
+
* `type`: the `low` profile prefers the `recordingsLow` location, every other
|
|
1775
|
+
* profile the `recordings` location. Falls back to the first resolved location
|
|
1776
|
+
* when the preferred type is absent (both types alias one disk today, so the
|
|
1777
|
+
* split is free-and-future-proof). Throws when no locations resolved.
|
|
1778
|
+
*/
|
|
1779
|
+
function resolvePlacement(locations, profile) {
|
|
1780
|
+
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
1781
|
+
const preferType = profile === "low" ? "recordingsLow" : "recordings";
|
|
1782
|
+
return locations.find((l) => l.type === preferType) ?? locations[0];
|
|
1783
|
+
}
|
|
1784
|
+
/**
|
|
1695
1785
|
* Resolve every recordings storage location (`recordings` + `recordingsLow`)
|
|
1696
1786
|
* to `{ id, root }`. Returns an empty list when the storage cap is unreachable
|
|
1697
1787
|
* or no recordings location exists — callers degrade rather than crash.
|
|
@@ -1701,6 +1791,7 @@ async function resolveRecordingsLocations(api, logger) {
|
|
|
1701
1791
|
const recordings = (await api.storage.listLocations.query({})).filter((l) => RECORDING_LOCATION_TYPES.has(l.type));
|
|
1702
1792
|
return Promise.all(recordings.map(async (l) => ({
|
|
1703
1793
|
id: l.id,
|
|
1794
|
+
type: l.type,
|
|
1704
1795
|
root: await api.storage.resolve.query({
|
|
1705
1796
|
location: l.id,
|
|
1706
1797
|
relativePath: ""
|
|
@@ -1740,6 +1831,54 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
1740
1831
|
}
|
|
1741
1832
|
}
|
|
1742
1833
|
//#endregion
|
|
1834
|
+
//#region src/recorder/addon/eviction-domain.ts
|
|
1835
|
+
/**
|
|
1836
|
+
* Eviction-DOMAIN resolution for recordings locations.
|
|
1837
|
+
*
|
|
1838
|
+
* Recordings locations are operator-configurable and MAY alias one physical
|
|
1839
|
+
* filesystem root: today `recordings:default` + `recordingsLow:default` both
|
|
1840
|
+
* resolve to `/recordings`, but an operator could also point two locations at a
|
|
1841
|
+
* symlink or bind-mount of the same disk. Locations sharing a physical root
|
|
1842
|
+
* MUST evict as one oldest-first pool — otherwise disk-pressure relief on one
|
|
1843
|
+
* alias deletes the newest footage while the other alias still holds the old
|
|
1844
|
+
* (the age-partition data-loss bug).
|
|
1845
|
+
*
|
|
1846
|
+
* The domain key is the location root's PHYSICAL identity: `fs.realpathSync` of
|
|
1847
|
+
* the lexically-resolved path, which collapses symlinks and `.`/`..` so aliases
|
|
1848
|
+
* reaching one disk share a key — a plain `path.resolve` string compare would
|
|
1849
|
+
* miss a symlinked/bind-aliased path and silently re-introduce the partition.
|
|
1850
|
+
* When realpath THROWS (the root is missing at boot — a disk not yet mounted)
|
|
1851
|
+
* we fall back to the lexically-resolved path string: distinct-but-unmounted
|
|
1852
|
+
* roots then stay distinct domains. That fallback can only ever OVER-partition
|
|
1853
|
+
* (treat one disk as two), never MERGE two different disks — the safe failure
|
|
1854
|
+
* mode for a data-loss guard.
|
|
1855
|
+
*/
|
|
1856
|
+
/**
|
|
1857
|
+
* The physical-device key of a location root: `realpath` of the resolved path,
|
|
1858
|
+
* or the resolved path string itself when realpath throws (root missing).
|
|
1859
|
+
*/
|
|
1860
|
+
function physicalRootKey(root, realpath = realpathSync) {
|
|
1861
|
+
const resolved = path.resolve(root);
|
|
1862
|
+
try {
|
|
1863
|
+
return realpath(resolved);
|
|
1864
|
+
} catch {
|
|
1865
|
+
return resolved;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The eviction DOMAIN of `locationId`: the set of location ids (INCLUDING
|
|
1870
|
+
* itself) whose roots share `locationId`'s physical root key. An unknown
|
|
1871
|
+
* `locationId` (not present in `locations`) is its own singleton domain.
|
|
1872
|
+
*/
|
|
1873
|
+
function resolveEvictionDomain(locations, locationId, realpath = realpathSync) {
|
|
1874
|
+
const self = locations.find((l) => l.id === locationId);
|
|
1875
|
+
if (!self) return new Set([locationId]);
|
|
1876
|
+
const selfKey = physicalRootKey(self.root, realpath);
|
|
1877
|
+
const domain = new Set([locationId]);
|
|
1878
|
+
for (const l of locations) if (physicalRootKey(l.root, realpath) === selfKey) domain.add(l.id);
|
|
1879
|
+
return domain;
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1743
1882
|
//#region src/recorder/band-engine.ts
|
|
1744
1883
|
function toMin(hhmm) {
|
|
1745
1884
|
const [h, m] = hhmm.split(":");
|
|
@@ -2521,16 +2660,13 @@ var RecordingController = class {
|
|
|
2521
2660
|
}
|
|
2522
2661
|
}
|
|
2523
2662
|
/**
|
|
2524
|
-
* Resolve the storage location a profile writes to
|
|
2525
|
-
*
|
|
2526
|
-
*
|
|
2527
|
-
*
|
|
2663
|
+
* Resolve the storage location a profile writes to. Delegates to the pure
|
|
2664
|
+
* `resolvePlacement` in `roots.ts`, which matches on the location's declared
|
|
2665
|
+
* `type` (`recordings` / `recordingsLow`) rather than its `<type>:<instance>`
|
|
2666
|
+
* id — so `low` correctly routes to `recordingsLow`.
|
|
2528
2667
|
*/
|
|
2529
2668
|
resolvePlacement(profile) {
|
|
2530
|
-
|
|
2531
|
-
if (locations.length === 0) throw new Error("recorder controller: no recordings storage locations resolved");
|
|
2532
|
-
const preferLow = profile === "low";
|
|
2533
|
-
return locations.find((l) => preferLow ? l.id === "recordingsLow" : l.id === "recordings") ?? locations[0];
|
|
2669
|
+
return resolvePlacement(this.deps.locations(), profile);
|
|
2534
2670
|
}
|
|
2535
2671
|
/** Stop one profile's writer + watcher and release its broker lease. */
|
|
2536
2672
|
async teardownProfile(deviceId, r) {
|
|
@@ -2603,8 +2739,8 @@ function makeBrokerCall(handle) {
|
|
|
2603
2739
|
* sweeper only decides WHICH devices to prune and WHEN).
|
|
2604
2740
|
*
|
|
2605
2741
|
* Owns its own timer + single-flight guard so a slow sweep is never overlapped
|
|
2606
|
-
* by the next tick — the same subsystem-owns-its-timer shape as
|
|
2607
|
-
*
|
|
2742
|
+
* by the next tick — the same subsystem-owns-its-timer shape as the
|
|
2743
|
+
* scrub-pack prune. Pure over injected deps (config read + prune + logger +
|
|
2608
2744
|
* clock via `setInterval`), so it is unit-testable with fake timers and a mock
|
|
2609
2745
|
* `prune`. Every pass is best-effort: a per-device prune failure is logged and
|
|
2610
2746
|
* never aborts the rest, and the sweep never throws.
|
|
@@ -2720,943 +2856,324 @@ var RetentionSweeper = class {
|
|
|
2720
2856
|
*/
|
|
2721
2857
|
var resolveRecordingHubHostname = resolveHubHostname;
|
|
2722
2858
|
//#endregion
|
|
2723
|
-
//#region src/
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
const
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
const
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2859
|
+
//#region src/recorder/scrub/scrub-pack-format.ts
|
|
2860
|
+
function encodeScrubPack(header, samples) {
|
|
2861
|
+
const frames = [];
|
|
2862
|
+
let offset = 0;
|
|
2863
|
+
for (const s of samples) {
|
|
2864
|
+
frames.push({
|
|
2865
|
+
ts: s.ts,
|
|
2866
|
+
offset,
|
|
2867
|
+
size: s.data.byteLength
|
|
2868
|
+
});
|
|
2869
|
+
offset += s.data.byteLength;
|
|
2870
|
+
}
|
|
2871
|
+
const full = {
|
|
2872
|
+
v: 1,
|
|
2873
|
+
codec: header.codec,
|
|
2874
|
+
description: header.description,
|
|
2875
|
+
frames
|
|
2876
|
+
};
|
|
2877
|
+
const headerBytes = new TextEncoder().encode(JSON.stringify(full));
|
|
2878
|
+
const out = new Uint8Array(4 + headerBytes.byteLength + offset);
|
|
2879
|
+
new DataView(out.buffer).setUint32(0, headerBytes.byteLength, true);
|
|
2880
|
+
out.set(headerBytes, 4);
|
|
2881
|
+
let cursor = 4 + headerBytes.byteLength;
|
|
2882
|
+
for (const s of samples) {
|
|
2883
|
+
out.set(s.data, cursor);
|
|
2884
|
+
cursor += s.data.byteLength;
|
|
2885
|
+
}
|
|
2886
|
+
return out;
|
|
2887
|
+
}
|
|
2888
|
+
function decodeScrubPackHeader(buf) {
|
|
2889
|
+
if (buf.byteLength < 4) throw new Error("scrub pack: truncated (no length prefix)");
|
|
2890
|
+
const headerLen = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(0, true);
|
|
2891
|
+
if (headerLen <= 0 || 4 + headerLen > buf.byteLength) throw new Error(`scrub pack: bogus header length ${headerLen}`);
|
|
2892
|
+
const json = new TextDecoder().decode(buf.subarray(4, 4 + headerLen));
|
|
2893
|
+
const header = JSON.parse(json);
|
|
2894
|
+
if (header.v !== 1) throw new Error(`scrub pack: unsupported version ${String(header.v)}`);
|
|
2895
|
+
if (!Array.isArray(header.frames)) throw new Error("scrub pack: malformed header (frames)");
|
|
2749
2896
|
return {
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
deviceId: input.deviceId,
|
|
2753
|
-
windowStartMs: input.windowStartMs,
|
|
2754
|
-
windowMs: input.windowMs,
|
|
2755
|
-
intervalMs: input.intervalMs,
|
|
2756
|
-
tileWidth,
|
|
2757
|
-
tileHeight,
|
|
2758
|
-
cols: geometry.cols,
|
|
2759
|
-
rows: geometry.rows,
|
|
2760
|
-
sheet: `sheet-${input.windowStartMs}.jpg`,
|
|
2761
|
-
source: input.source,
|
|
2762
|
-
tiles
|
|
2763
|
-
},
|
|
2764
|
-
geometry,
|
|
2765
|
-
orderedSamples: ordered
|
|
2897
|
+
header,
|
|
2898
|
+
payloadOffset: 4 + headerLen
|
|
2766
2899
|
};
|
|
2767
2900
|
}
|
|
2768
2901
|
//#endregion
|
|
2769
|
-
//#region src/
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
* A thumbnail "window" is a fixed UTC-aligned span (default 5 min). Every
|
|
2774
|
-
* timestamp maps to exactly one window; the window start, its UTC bucket
|
|
2775
|
-
* (Y/M/D/H like the segment store), and the sheet/index filenames are all
|
|
2776
|
-
* derived from it with NO state — so the recorder writes and the viewer reads
|
|
2777
|
-
* the SAME deterministic path from a bare timestamp (no directory listing, no
|
|
2778
|
-
* cap round-trip).
|
|
2779
|
-
*/
|
|
2780
|
-
/** Default window span — 5 minutes. */
|
|
2781
|
-
var DEFAULT_WINDOW_MS = 3e5;
|
|
2782
|
-
/** Zero-padded 2-digit. */
|
|
2783
|
-
function p2(n) {
|
|
2784
|
-
return String(n).padStart(2, "0");
|
|
2902
|
+
//#region src/recorder/scrub/scrub-pack-service.ts
|
|
2903
|
+
var SCRUB_WINDOW_MS = 6e4;
|
|
2904
|
+
function utcDay(ms) {
|
|
2905
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
2785
2906
|
}
|
|
2786
|
-
|
|
2787
|
-
function windowStartFor(t, windowMs = DEFAULT_WINDOW_MS) {
|
|
2788
|
-
if (!Number.isFinite(t)) throw new Error(`windowStartFor: t must be finite, got ${t}`);
|
|
2789
|
-
if (!(windowMs > 0)) throw new Error(`windowStartFor: windowMs must be > 0, got ${windowMs}`);
|
|
2790
|
-
return Math.floor(t / windowMs) * windowMs;
|
|
2791
|
-
}
|
|
2792
|
-
/** UTC `Y/M/D/H` bucket dir (relative) for a window start — mirrors
|
|
2793
|
-
* `segmentRelPath`'s bucketing so thumbs co-locate with the same hour tree. */
|
|
2794
|
-
function bucketDir(windowStartMs) {
|
|
2795
|
-
const d = new Date(windowStartMs);
|
|
2796
|
-
return `${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
2797
|
-
}
|
|
2798
|
-
/** Sheet image filename for a window start. */
|
|
2799
|
-
function sheetFileName(windowStartMs) {
|
|
2800
|
-
return `sheet-${windowStartMs}.jpg`;
|
|
2801
|
-
}
|
|
2802
|
-
/** Index sidecar filename for a window start. */
|
|
2803
|
-
function indexFileName(windowStartMs) {
|
|
2804
|
-
return `index-${windowStartMs}.json`;
|
|
2805
|
-
}
|
|
2806
|
-
/**
|
|
2807
|
-
* Device-relative directory (under the thumbs root) for a window:
|
|
2808
|
-
* `<deviceId>/<Y>/<M>/<D>/<H>`. The device is the top segment (thumbs are
|
|
2809
|
-
* profile-agnostic, unlike segments which nest under a profile).
|
|
2810
|
-
*/
|
|
2811
|
-
function windowRelDir(deviceId, windowStartMs) {
|
|
2812
|
-
return `${deviceId}/${bucketDir(windowStartMs)}`;
|
|
2813
|
-
}
|
|
2814
|
-
//#endregion
|
|
2815
|
-
//#region src/thumbnails/thumbnail-sink.ts
|
|
2816
|
-
var ThumbnailSink = class {
|
|
2907
|
+
var ScrubPackService = class {
|
|
2817
2908
|
deps;
|
|
2818
|
-
|
|
2819
|
-
open = /* @__PURE__ */ new Map();
|
|
2820
|
-
lastFlushed = /* @__PURE__ */ new Map();
|
|
2821
|
-
/** In-flight flush promises per device — chained so a device never composes
|
|
2822
|
-
* two windows concurrently (ordering + bounded work). */
|
|
2823
|
-
flushChain = /* @__PURE__ */ new Map();
|
|
2909
|
+
inflight = /* @__PURE__ */ new Map();
|
|
2824
2910
|
constructor(deps) {
|
|
2825
2911
|
this.deps = deps;
|
|
2826
|
-
this.now = deps.now ?? (() => Date.now());
|
|
2827
|
-
}
|
|
2828
|
-
/** Accept a sampled thumb. Rolls over (flushes) the device's open window when
|
|
2829
|
-
* a strictly-later window's sample arrives. */
|
|
2830
|
-
add(deviceId, sample) {
|
|
2831
|
-
const windowStartMs = windowStartFor(sample.capturedAt, this.deps.windowMs);
|
|
2832
|
-
const lastFlushed = this.lastFlushed.get(deviceId);
|
|
2833
|
-
if (lastFlushed !== void 0 && windowStartMs <= lastFlushed) return;
|
|
2834
|
-
let cur = this.open.get(deviceId);
|
|
2835
|
-
if (cur && windowStartMs > cur.windowStartMs) {
|
|
2836
|
-
this.scheduleFlush(deviceId, cur);
|
|
2837
|
-
this.open.delete(deviceId);
|
|
2838
|
-
cur = void 0;
|
|
2839
|
-
}
|
|
2840
|
-
if (cur && windowStartMs < cur.windowStartMs) return;
|
|
2841
|
-
if (!cur) {
|
|
2842
|
-
cur = {
|
|
2843
|
-
windowStartMs,
|
|
2844
|
-
samples: []
|
|
2845
|
-
};
|
|
2846
|
-
this.open.set(deviceId, cur);
|
|
2847
|
-
}
|
|
2848
|
-
if (cur.samples.length < this.deps.maxTilesPerWindow) cur.samples.push(sample);
|
|
2849
|
-
}
|
|
2850
|
-
/** Flush every open window whose end + grace has passed (idle cameras). */
|
|
2851
|
-
flushExpired() {
|
|
2852
|
-
const now = this.now();
|
|
2853
|
-
for (const [deviceId, win] of [...this.open]) if (now - (win.windowStartMs + this.deps.windowMs) >= this.deps.idleGraceMs) {
|
|
2854
|
-
this.scheduleFlush(deviceId, win);
|
|
2855
|
-
this.open.delete(deviceId);
|
|
2856
|
-
}
|
|
2857
2912
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2913
|
+
async getOrBuild(deviceId, windowStartMs) {
|
|
2914
|
+
const key = `${deviceId}/${windowStartMs}`;
|
|
2915
|
+
const pending = this.inflight.get(key);
|
|
2916
|
+
if (pending) return pending;
|
|
2917
|
+
const run = this.getOrBuildInner(deviceId, windowStartMs).finally(() => this.inflight.delete(key));
|
|
2918
|
+
this.inflight.set(key, run);
|
|
2919
|
+
return run;
|
|
2920
|
+
}
|
|
2921
|
+
cachePath(deviceId, windowStartMs) {
|
|
2922
|
+
return path.join(this.deps.cacheDir, String(deviceId), utcDay(windowStartMs), `${windowStartMs}.pack`);
|
|
2923
|
+
}
|
|
2924
|
+
async getOrBuildInner(deviceId, windowStartMs) {
|
|
2925
|
+
const now = (this.deps.now ?? Date.now)();
|
|
2926
|
+
const immutable = windowStartMs + SCRUB_WINDOW_MS < now;
|
|
2927
|
+
const cacheFile = this.cachePath(deviceId, windowStartMs);
|
|
2928
|
+
if (immutable) try {
|
|
2929
|
+
const cached = new Uint8Array(await promises.readFile(cacheFile));
|
|
2930
|
+
try {
|
|
2931
|
+
decodeScrubPackHeader(cached);
|
|
2932
|
+
} catch {
|
|
2933
|
+
this.deps.logger.debug("scrub pack: corrupt cache — rebuilding", { meta: { cacheFile } });
|
|
2934
|
+
await promises.rm(cacheFile, { force: true }).catch(() => {});
|
|
2935
|
+
throw new Error("corrupt cache");
|
|
2936
|
+
}
|
|
2937
|
+
return {
|
|
2938
|
+
pack: cached,
|
|
2939
|
+
immutable
|
|
2940
|
+
};
|
|
2941
|
+
} catch {}
|
|
2942
|
+
const end = windowStartMs + SCRUB_WINDOW_MS;
|
|
2943
|
+
const rows = this.deps.listLowSegments(deviceId).filter((r) => r.startMs < end && r.startMs + r.durMs > windowStartMs).sort((a, b) => a.startMs - b.startMs);
|
|
2944
|
+
if (rows.length === 0) return null;
|
|
2945
|
+
let codec = null;
|
|
2946
|
+
let description = null;
|
|
2947
|
+
const samples = [];
|
|
2948
|
+
for (const row of rows) {
|
|
2949
|
+
let bytes;
|
|
2950
|
+
try {
|
|
2951
|
+
bytes = await this.deps.readSegmentBytes(row);
|
|
2952
|
+
} catch (err) {
|
|
2953
|
+
this.deps.logger.debug("scrub pack: segment read failed — skipping", { meta: {
|
|
2954
|
+
relPath: row.path,
|
|
2955
|
+
error: String(err)
|
|
2956
|
+
} });
|
|
2957
|
+
continue;
|
|
2958
|
+
}
|
|
2959
|
+
const extracted = await extractKeyframes(bytes, row.startMs);
|
|
2960
|
+
if (!extracted) continue;
|
|
2961
|
+
if (codec === null) {
|
|
2962
|
+
codec = extracted.codec;
|
|
2963
|
+
description = extracted.description;
|
|
2964
|
+
} else if (extracted.codec !== codec) {
|
|
2965
|
+
this.deps.logger.warn("scrub pack: mid-window codec change — dropping segment", { meta: {
|
|
2966
|
+
relPath: row.path,
|
|
2967
|
+
expected: codec,
|
|
2968
|
+
got: extracted.codec
|
|
2969
|
+
} });
|
|
2970
|
+
continue;
|
|
2971
|
+
}
|
|
2972
|
+
for (const k of extracted.keyframes) if (k.ts >= windowStartMs && k.ts < end) samples.push(k);
|
|
2973
|
+
}
|
|
2974
|
+
if (codec === null || description === null || samples.length === 0) return null;
|
|
2975
|
+
samples.sort((a, b) => a.ts - b.ts);
|
|
2976
|
+
const pack = encodeScrubPack({
|
|
2977
|
+
codec,
|
|
2978
|
+
description: Buffer.from(description).toString("base64")
|
|
2979
|
+
}, samples);
|
|
2980
|
+
if (immutable) try {
|
|
2981
|
+
await promises.mkdir(path.dirname(cacheFile), { recursive: true });
|
|
2982
|
+
const tmpFile = `${cacheFile}.tmp`;
|
|
2983
|
+
await promises.writeFile(tmpFile, pack);
|
|
2984
|
+
await promises.rename(tmpFile, cacheFile);
|
|
2985
|
+
} catch (err) {
|
|
2986
|
+
this.deps.logger.debug("scrub pack: cache write failed (serving anyway)", { meta: {
|
|
2987
|
+
cacheFile,
|
|
2988
|
+
error: String(err)
|
|
2989
|
+
} });
|
|
2863
2990
|
}
|
|
2864
|
-
await Promise.all([...this.flushChain.values()]);
|
|
2865
|
-
}
|
|
2866
|
-
/** Test/diagnostic: number of open windows. */
|
|
2867
|
-
get openWindowCount() {
|
|
2868
|
-
return this.open.size;
|
|
2869
|
-
}
|
|
2870
|
-
scheduleFlush(deviceId, win) {
|
|
2871
|
-
const next = (this.flushChain.get(deviceId) ?? Promise.resolve()).then(() => this.doFlush(deviceId, win)).catch((err) => {
|
|
2872
|
-
this.deps.logger.warn("thumbnail sink: window flush failed", {
|
|
2873
|
-
tags: { deviceId },
|
|
2874
|
-
meta: {
|
|
2875
|
-
windowStartMs: win.windowStartMs,
|
|
2876
|
-
error: err instanceof Error ? err.message : err
|
|
2877
|
-
}
|
|
2878
|
-
});
|
|
2879
|
-
});
|
|
2880
|
-
this.flushChain.set(deviceId, next);
|
|
2881
|
-
this.lastFlushed.set(deviceId, Math.max(this.lastFlushed.get(deviceId) ?? -1, win.windowStartMs));
|
|
2882
|
-
}
|
|
2883
|
-
async doFlush(deviceId, win) {
|
|
2884
|
-
if (win.samples.length === 0) return;
|
|
2885
|
-
const preset = await this.deps.resolvePreset(deviceId);
|
|
2886
|
-
const ordered = [...win.samples].sort((a, b) => a.capturedAt - b.capturedAt);
|
|
2887
|
-
const built = buildThumbnailIndex({
|
|
2888
|
-
deviceId,
|
|
2889
|
-
windowStartMs: win.windowStartMs,
|
|
2890
|
-
windowMs: this.deps.windowMs,
|
|
2891
|
-
intervalMs: this.deps.intervalMs,
|
|
2892
|
-
source: this.deps.source,
|
|
2893
|
-
tileWidth: preset.tileWidth,
|
|
2894
|
-
tileHeight: preset.tileHeight,
|
|
2895
|
-
maxCols: this.deps.maxCols,
|
|
2896
|
-
samples: ordered.map((s) => ({ t: s.capturedAt }))
|
|
2897
|
-
});
|
|
2898
|
-
const placements = allTilePlacements(ordered.length, built.geometry);
|
|
2899
|
-
const sheetBytes = await this.deps.compose({
|
|
2900
|
-
tiles: ordered.map((s) => s.jpeg),
|
|
2901
|
-
placements,
|
|
2902
|
-
geometry: built.geometry,
|
|
2903
|
-
quality: preset.quality
|
|
2904
|
-
});
|
|
2905
|
-
await this.deps.persist({
|
|
2906
|
-
deviceId,
|
|
2907
|
-
windowStartMs: win.windowStartMs,
|
|
2908
|
-
relDir: windowRelDir(deviceId, win.windowStartMs),
|
|
2909
|
-
sheetName: sheetFileName(win.windowStartMs),
|
|
2910
|
-
sheetBytes,
|
|
2911
|
-
indexName: indexFileName(win.windowStartMs),
|
|
2912
|
-
index: built.index
|
|
2913
|
-
});
|
|
2914
|
-
}
|
|
2915
|
-
};
|
|
2916
|
-
//#endregion
|
|
2917
|
-
//#region src/thumbnails/sheet-composer.ts
|
|
2918
|
-
/**
|
|
2919
|
-
* Resize each tile to the uniform tile size and composite onto a black canvas
|
|
2920
|
-
* at its placement. Returns the sheet JPEG bytes. Throws if tiles/placements
|
|
2921
|
-
* lengths disagree.
|
|
2922
|
-
*/
|
|
2923
|
-
async function composeSheet(sharp, input) {
|
|
2924
|
-
const { tiles, placements, geometry } = input;
|
|
2925
|
-
if (tiles.length !== placements.length) throw new Error(`composeSheet: tiles(${tiles.length}) != placements(${placements.length})`);
|
|
2926
|
-
if (tiles.length === 0) throw new Error("composeSheet: no tiles");
|
|
2927
|
-
const entries = (await Promise.all(tiles.map((t) => sharp(t).resize(geometry.tileWidth, geometry.tileHeight, {
|
|
2928
|
-
fit: "cover",
|
|
2929
|
-
position: "centre"
|
|
2930
|
-
}).jpeg({ quality: input.quality ?? 72 }).toBuffer()))).map((buf, i) => {
|
|
2931
|
-
const p = placements[i];
|
|
2932
2991
|
return {
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
top: p.y
|
|
2992
|
+
pack,
|
|
2993
|
+
immutable
|
|
2936
2994
|
};
|
|
2937
|
-
});
|
|
2938
|
-
return sharp({ create: {
|
|
2939
|
-
width: geometry.sheetWidth,
|
|
2940
|
-
height: geometry.sheetHeight,
|
|
2941
|
-
channels: 3,
|
|
2942
|
-
background: {
|
|
2943
|
-
r: 0,
|
|
2944
|
-
g: 0,
|
|
2945
|
-
b: 0
|
|
2946
|
-
}
|
|
2947
|
-
} }).composite(entries).jpeg({ quality: input.quality ?? 72 }).toBuffer();
|
|
2948
|
-
}
|
|
2949
|
-
//#endregion
|
|
2950
|
-
//#region src/thumbnails/thumbnail-persister.ts
|
|
2951
|
-
/**
|
|
2952
|
-
* Thumbnail disk persister — writes a window's sheet JPEG + index JSON
|
|
2953
|
-
* write-once under `<thumbsRoot>/<dev>/Y/M/D/H/`. Small fs wrapper the recorder
|
|
2954
|
-
* wires with a real root; kept separate so the sink stays fs-free and testable.
|
|
2955
|
-
*/
|
|
2956
|
-
/** Root dir thumbs are written under for a location (`<locationRoot>/.thumbs`). */
|
|
2957
|
-
var THUMBS_SUBDIR = ".thumbs";
|
|
2958
|
-
/**
|
|
2959
|
-
* Persist one window. Writes are best-effort atomic (write to a `.tmp` then
|
|
2960
|
-
* rename) so a crash mid-write never leaves a truncated sheet a viewer would
|
|
2961
|
-
* cache as `immutable`. Idempotent: an existing sheet is left as-is (write-once).
|
|
2962
|
-
*/
|
|
2963
|
-
async function persistThumbnailWindow(thumbsRoot, req) {
|
|
2964
|
-
const dir = path.join(thumbsRoot, req.relDir);
|
|
2965
|
-
await promises.mkdir(dir, { recursive: true });
|
|
2966
|
-
const sheetPath = path.join(dir, req.sheetName);
|
|
2967
|
-
const indexPath = path.join(dir, req.indexName);
|
|
2968
|
-
await Promise.all([writeOnce(sheetPath, req.sheetBytes), writeOnce(indexPath, Buffer.from(JSON.stringify(req.index)))]);
|
|
2969
|
-
}
|
|
2970
|
-
async function writeOnce(absPath, bytes) {
|
|
2971
|
-
try {
|
|
2972
|
-
await promises.access(absPath);
|
|
2973
|
-
return;
|
|
2974
|
-
} catch {}
|
|
2975
|
-
const tmp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
|
|
2976
|
-
await promises.writeFile(tmp, bytes);
|
|
2977
|
-
try {
|
|
2978
|
-
await promises.rename(tmp, absPath);
|
|
2979
|
-
} catch (err) {
|
|
2980
|
-
await promises.rm(tmp, { force: true }).catch(() => {});
|
|
2981
|
-
throw err;
|
|
2982
2995
|
}
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
* device's oldest indexed segment (thumbs live exactly as long as footage),
|
|
2988
|
-
* or `now - fallback` when the device has no segments.
|
|
2989
|
-
*/
|
|
2990
|
-
function planThumbPrune(input) {
|
|
2991
|
-
const cutoffMs = input.oldestSegmentStartMs ?? input.nowMs - 2592e6;
|
|
2992
|
-
return input.buckets.filter((b) => b.hourStartMs + HOUR_MS <= cutoffMs);
|
|
2993
|
-
}
|
|
2994
|
-
/**
|
|
2995
|
-
* Parse a `Y/M/D/H` relative bucket path (as walked under a device dir) into
|
|
2996
|
-
* its UTC hour start. Returns null for anything that isn't a valid bucket
|
|
2997
|
-
* (staging debris, foreign files) so the janitor never deletes what it does
|
|
2998
|
-
* not understand.
|
|
2999
|
-
*/
|
|
3000
|
-
function parseHourBucket(relYmdh) {
|
|
3001
|
-
const m = /^(\d{4})\/(\d{2})\/(\d{2})\/(\d{2})$/.exec(relYmdh);
|
|
3002
|
-
if (!m) return null;
|
|
3003
|
-
const [, y, mo, d, h] = m;
|
|
3004
|
-
const year = Number(y);
|
|
3005
|
-
const month = Number(mo);
|
|
3006
|
-
const day = Number(d);
|
|
3007
|
-
const hour = Number(h);
|
|
3008
|
-
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
|
|
3009
|
-
const t = Date.UTC(year, month - 1, day, hour);
|
|
3010
|
-
const dt = new Date(t);
|
|
3011
|
-
if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day || dt.getUTCHours() !== hour) return null;
|
|
3012
|
-
return t;
|
|
3013
|
-
}
|
|
3014
|
-
//#endregion
|
|
3015
|
-
//#region src/thumbnails/thumbs-media-handler.ts
|
|
3016
|
-
var IMMUTABLE = "public, max-age=31536000, immutable";
|
|
3017
|
-
/** Reject traversal / absolute / NUL and normalise leading slashes. Returns the
|
|
3018
|
-
* safe rel path or null. Exported for unit coverage. */
|
|
3019
|
-
function sanitizeThumbsPath(url) {
|
|
3020
|
-
const qIdx = url.indexOf("?");
|
|
3021
|
-
const raw = qIdx === -1 ? url : url.slice(0, qIdx);
|
|
3022
|
-
let rel;
|
|
3023
|
-
try {
|
|
3024
|
-
rel = decodeURIComponent(raw.replace(/^\/+/, ""));
|
|
3025
|
-
} catch {
|
|
3026
|
-
return null;
|
|
3027
|
-
}
|
|
3028
|
-
if (rel.length === 0) return null;
|
|
3029
|
-
if (rel.includes("\0")) return null;
|
|
3030
|
-
if (rel.startsWith("/")) return null;
|
|
3031
|
-
if (rel.split("/").some((seg) => seg === ".." || seg === ".")) return null;
|
|
3032
|
-
if (!/\.(jpe?g|json)$/i.test(rel)) return null;
|
|
3033
|
-
return rel;
|
|
3034
|
-
}
|
|
3035
|
-
/** Content type for a thumbs file by extension. */
|
|
3036
|
-
function thumbsContentType(relPath) {
|
|
3037
|
-
if (/\.json$/i.test(relPath)) return "application/json";
|
|
3038
|
-
return "image/jpeg";
|
|
3039
|
-
}
|
|
3040
|
-
function createThumbsMediaHandler(deps) {
|
|
3041
|
-
return async (req, res) => {
|
|
3042
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
3043
|
-
res.writeHead(405, { allow: "GET, HEAD" }).end();
|
|
3044
|
-
return;
|
|
3045
|
-
}
|
|
3046
|
-
const rel = sanitizeThumbsPath(req.url ?? "/");
|
|
3047
|
-
if (rel === null) {
|
|
3048
|
-
res.writeHead(404).end();
|
|
3049
|
-
return;
|
|
3050
|
-
}
|
|
3051
|
-
const etag = `"${rel}"`;
|
|
3052
|
-
if (req.headers["if-none-match"] === etag) {
|
|
3053
|
-
res.writeHead(304, {
|
|
3054
|
-
etag,
|
|
3055
|
-
"cache-control": IMMUTABLE
|
|
3056
|
-
}).end();
|
|
3057
|
-
return;
|
|
3058
|
-
}
|
|
3059
|
-
let file;
|
|
2996
|
+
/** Remove per-device day directories older than the retention horizon. */
|
|
2997
|
+
async prune(retentionDays) {
|
|
2998
|
+
const cutoff = (this.deps.now ?? Date.now)() - retentionDays * 864e5;
|
|
2999
|
+
let deviceDirs = [];
|
|
3060
3000
|
try {
|
|
3061
|
-
|
|
3001
|
+
deviceDirs = await promises.readdir(this.deps.cacheDir);
|
|
3062
3002
|
} catch {
|
|
3063
|
-
const body = "Internal server error";
|
|
3064
|
-
res.writeHead(500, {
|
|
3065
|
-
"content-type": "text/plain",
|
|
3066
|
-
"content-length": String(Buffer.byteLength(body))
|
|
3067
|
-
});
|
|
3068
|
-
if (req.method === "HEAD") res.end();
|
|
3069
|
-
else res.end(body);
|
|
3070
3003
|
return;
|
|
3071
3004
|
}
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3005
|
+
for (const dev of deviceDirs) {
|
|
3006
|
+
const devPath = path.join(this.deps.cacheDir, dev);
|
|
3007
|
+
let days = [];
|
|
3008
|
+
try {
|
|
3009
|
+
days = await promises.readdir(devPath);
|
|
3010
|
+
} catch {
|
|
3011
|
+
continue;
|
|
3012
|
+
}
|
|
3013
|
+
for (const day of days) {
|
|
3014
|
+
const dayMs = Date.parse(`${day}T00:00:00Z`);
|
|
3015
|
+
if (Number.isFinite(dayMs) && dayMs < cutoff) await promises.rm(path.join(devPath, day), {
|
|
3016
|
+
recursive: true,
|
|
3017
|
+
force: true
|
|
3018
|
+
}).catch(() => {});
|
|
3019
|
+
}
|
|
3020
|
+
try {
|
|
3021
|
+
if ((await promises.readdir(devPath)).length === 0) await promises.rm(devPath, {
|
|
3022
|
+
recursive: true,
|
|
3023
|
+
force: true
|
|
3024
|
+
}).catch(() => {});
|
|
3025
|
+
} catch {}
|
|
3075
3026
|
}
|
|
3076
|
-
res.writeHead(200, {
|
|
3077
|
-
"content-type": file.contentType,
|
|
3078
|
-
"cache-control": IMMUTABLE,
|
|
3079
|
-
etag,
|
|
3080
|
-
"content-length": String(file.bytes.byteLength)
|
|
3081
|
-
});
|
|
3082
|
-
if (req.method === "HEAD") res.end();
|
|
3083
|
-
else res.end(Buffer.from(file.bytes));
|
|
3084
|
-
};
|
|
3085
|
-
}
|
|
3086
|
-
//#endregion
|
|
3087
|
-
//#region src/thumbnails/thumb-backfill-plan.ts
|
|
3088
|
-
/**
|
|
3089
|
-
* Backfill PLANNER — pure (no ffmpeg, no I/O). Given a device's low segments
|
|
3090
|
-
* and the window starts already covered by a sampled/backfilled index, decide
|
|
3091
|
-
* which windows still need a keyframe-only ffmpeg pass and which segments cover
|
|
3092
|
-
* each. The executor (recorder) runs at most ONE ffmpeg per planned window,
|
|
3093
|
-
* single-flighted; this module owns only the "what to do" decision so it is
|
|
3094
|
-
* fully unit-testable.
|
|
3095
|
-
*
|
|
3096
|
-
* A window is a backfill candidate when:
|
|
3097
|
-
* - at least one low segment overlaps it (there is footage to sample), AND
|
|
3098
|
-
* - no index already covers it (sampling didn't reach it), AND
|
|
3099
|
-
* - the window is fully in the past by `settleMs` (no new segment can still
|
|
3100
|
-
* land in it — mirrors the segment store's current-hour eviction guard).
|
|
3101
|
-
*/
|
|
3102
|
-
/** True when `[startMs, startMs+durMs)` overlaps `[winStart, winEnd)`. */
|
|
3103
|
-
function overlaps(startMs, durMs, winStart, winEnd) {
|
|
3104
|
-
return startMs < winEnd && startMs + durMs > winStart;
|
|
3105
|
-
}
|
|
3106
|
-
/**
|
|
3107
|
-
* Compute the ordered (ascending window) backfill plan. Deterministic and
|
|
3108
|
-
* bounded: returns at most `maxWindows` window plans (oldest first), each with
|
|
3109
|
-
* its overlapping segments.
|
|
3110
|
-
*/
|
|
3111
|
-
function planBackfill(input) {
|
|
3112
|
-
const { segments, coveredWindows, windowMs, now, settleMs } = input;
|
|
3113
|
-
const maxWindows = input.maxWindows ?? 500;
|
|
3114
|
-
if (!(windowMs > 0)) return [];
|
|
3115
|
-
const candidates = /* @__PURE__ */ new Set();
|
|
3116
|
-
for (const seg of segments) {
|
|
3117
|
-
if (seg.durMs <= 0) continue;
|
|
3118
|
-
const firstW = windowStartFor(seg.startMs, windowMs);
|
|
3119
|
-
const lastW = windowStartFor(seg.startMs + seg.durMs - 1, windowMs);
|
|
3120
|
-
for (let w = firstW; w <= lastW; w += windowMs) candidates.add(w);
|
|
3121
|
-
}
|
|
3122
|
-
const plans = [];
|
|
3123
|
-
for (const windowStartMs of [...candidates].sort((a, b) => a - b)) {
|
|
3124
|
-
if (coveredWindows.has(windowStartMs)) continue;
|
|
3125
|
-
const windowEndMs = windowStartMs + windowMs;
|
|
3126
|
-
if (now - windowEndMs < settleMs) continue;
|
|
3127
|
-
const covering = segments.filter((s) => s.durMs > 0 && overlaps(s.startMs, s.durMs, windowStartMs, windowEndMs)).sort((a, b) => a.startMs - b.startMs);
|
|
3128
|
-
if (covering.length === 0) continue;
|
|
3129
|
-
plans.push({
|
|
3130
|
-
windowStartMs,
|
|
3131
|
-
windowEndMs,
|
|
3132
|
-
segments: covering
|
|
3133
|
-
});
|
|
3134
|
-
if (plans.length >= maxWindows) break;
|
|
3135
3027
|
}
|
|
3136
|
-
|
|
3137
|
-
}
|
|
3028
|
+
};
|
|
3138
3029
|
//#endregion
|
|
3139
|
-
//#region src/
|
|
3030
|
+
//#region src/recorder/still/still-source.ts
|
|
3140
3031
|
/**
|
|
3141
|
-
*
|
|
3142
|
-
*
|
|
3143
|
-
*
|
|
3144
|
-
*
|
|
3145
|
-
*
|
|
3146
|
-
* These extracted keyframes are INTERMEDIATE — `composeSheet` re-encodes them
|
|
3147
|
-
* into the sheet at the preset's final JPEG quality — so this only governs the
|
|
3148
|
-
* extract's crispness. Anchoring the extract to the same preset keeps a
|
|
3149
|
-
* high-preset camera's backfill from being the bottleneck while a low-preset
|
|
3150
|
-
* camera spends fewer bytes on throwaway intermediates.
|
|
3032
|
+
* Resolve the low segment covering `epochMs` and the seek offset from that
|
|
3033
|
+
* segment's start. Returns the LAST segment whose half-open span
|
|
3034
|
+
* `[startMs, startMs+durMs)` contains the instant (segments can overlap after a
|
|
3035
|
+
* restart; the newest wins). Null when no segment covers the instant.
|
|
3151
3036
|
*/
|
|
3152
|
-
function
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
const q = input.quality ?? 4;
|
|
3164
|
-
return [
|
|
3165
|
-
"-hide_banner",
|
|
3166
|
-
"-loglevel",
|
|
3167
|
-
"error",
|
|
3168
|
-
"-skip_frame",
|
|
3169
|
-
"nokey",
|
|
3170
|
-
"-i",
|
|
3171
|
-
input.inputPath,
|
|
3172
|
-
"-an",
|
|
3173
|
-
"-vsync",
|
|
3174
|
-
"0",
|
|
3175
|
-
"-vf",
|
|
3176
|
-
`scale=${input.tileWidth}:-2`,
|
|
3177
|
-
"-q:v",
|
|
3178
|
-
String(q),
|
|
3179
|
-
input.outputPattern
|
|
3180
|
-
];
|
|
3037
|
+
function resolveStillSource(segments, epochMs) {
|
|
3038
|
+
let match = null;
|
|
3039
|
+
for (const s of segments) if (epochMs >= s.startMs && epochMs < s.startMs + s.durMs) {
|
|
3040
|
+
if (match === null || s.startMs >= match.startMs) match = s;
|
|
3041
|
+
}
|
|
3042
|
+
if (match === null) return null;
|
|
3043
|
+
const offsetSec = Math.max(0, (epochMs - match.startMs) / 1e3);
|
|
3044
|
+
return {
|
|
3045
|
+
row: match,
|
|
3046
|
+
offsetSec
|
|
3047
|
+
};
|
|
3181
3048
|
}
|
|
3182
3049
|
//#endregion
|
|
3183
|
-
//#region src/recorder/
|
|
3050
|
+
//#region src/recorder/still/still-frame-service.ts
|
|
3184
3051
|
/**
|
|
3185
|
-
* Recorder
|
|
3186
|
-
*
|
|
3187
|
-
*
|
|
3188
|
-
* runner (any node) --recording.thumb-sampled event--> THIS (recording node)
|
|
3189
|
-
* -> ThumbnailSink accumulates a 5-min window
|
|
3190
|
-
* -> composeSheet (sharp) + persistThumbnailWindow (write-once)
|
|
3191
|
-
* -> data-plane `/addon/recorder/thumbs/<dev>/Y/M/D/H/{sheet,index}`
|
|
3192
|
-
*
|
|
3193
|
-
* Cross-node decision (documented, simplest per the analysis §3 open
|
|
3194
|
-
* questions): the thumb rides the EVENT BUS. The recorder is a SINGLE
|
|
3195
|
-
* designated node, so its data-plane prefix (`/addon/recorder/thumbs`) is
|
|
3196
|
-
* unambiguous — serving from any decode node's own prefix would be
|
|
3197
|
-
* multi-node-ambiguous. Sampling is compressed (~10 KB) at 1/5 Hz — well inside
|
|
3198
|
-
* D9 — and telemetry-grade (a lost thumb is a scrub gap the keyframe backfill
|
|
3199
|
-
* fills), so no new cap method and no RPC hop are needed.
|
|
3052
|
+
* Recorder STILL-frame service — decode ONE JPEG near a wall-clock instant from
|
|
3053
|
+
* the recorded LOW footage, on demand, for the export dialog's start/end frame
|
|
3054
|
+
* previews. Replaces the retired sprite-sheet scrub-thumbnail tier for this use.
|
|
3200
3055
|
*
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
*
|
|
3204
|
-
* a
|
|
3056
|
+
* A one-off still (a dialog preview, not frame-rate playback), so the simplest
|
|
3057
|
+
* robust path is chosen: feed ffmpeg the segment file directly with an INPUT
|
|
3058
|
+
* seek (`-ss` before `-i`, which lands on the nearest keyframe at/before the
|
|
3059
|
+
* offset — cheap, no full decode) and ask for a single frame on stdout. No
|
|
3060
|
+
* WebCodecs, no mp4box parse, no persistent cache — a ~100ms one-shot is fine
|
|
3061
|
+
* for a dialog and keeps the endpoint stateless.
|
|
3205
3062
|
*/
|
|
3206
|
-
|
|
3207
|
-
var
|
|
3208
|
-
/**
|
|
3209
|
-
*
|
|
3210
|
-
var
|
|
3211
|
-
/**
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
* low segments, concurrency-gated, per-device window budget). */
|
|
3216
|
-
var BACKFILL_TICK_MS = 15 * 6e4;
|
|
3217
|
-
/** A window must have ended this long ago before backfill considers it. */
|
|
3218
|
-
var BACKFILL_SETTLE_MS = 5 * DEFAULT_WINDOW_MS;
|
|
3219
|
-
/** Max concurrent backfill ffmpeg passes across all cameras. */
|
|
3220
|
-
var BACKFILL_MAX_CONCURRENT = 2;
|
|
3221
|
-
/** Max windows filled PER DEVICE per backfill pass. A recording camera accrues
|
|
3222
|
-
* ≤12 uncovered 5-min windows/hour, so 24 lets a device also DRAIN backlog.
|
|
3223
|
-
* (This was a GLOBAL cap: 24/pass vs ~12×N windows/hour needed cluster-wide —
|
|
3224
|
-
* devices later in the list starved and coverage grew permanent holes,
|
|
3225
|
-
* live-measured 2026-07-17 on 590: gaps at −55…−110 min.) */
|
|
3226
|
-
var BACKFILL_MAX_WINDOWS_PER_DEVICE = 24;
|
|
3227
|
-
/** Global safety cap per pass (runaway bound across all cameras). */
|
|
3228
|
-
var BACKFILL_MAX_WINDOWS_PER_PASS = 240;
|
|
3229
|
-
/** Hard cap on tiles per window (bounds sheet size + compose cost). */
|
|
3230
|
-
var MAX_TILES_PER_WINDOW = 90;
|
|
3231
|
-
/** Retention-prune cadence (sheets are small — a slow sweep is plenty). */
|
|
3232
|
-
var PRUNE_TICK_MS = 6 * 36e5;
|
|
3233
|
-
/** First prune runs this long after start (off the boot churn). */
|
|
3234
|
-
var PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
|
|
3235
|
-
var SHARP = sharp;
|
|
3236
|
-
var ThumbnailService = class {
|
|
3063
|
+
/** Hard ceiling on a single still decode (a stuck ffmpeg must never leak). */
|
|
3064
|
+
var FFMPEG_TIMEOUT_MS = 15e3;
|
|
3065
|
+
/** At most this many concurrent ffmpeg decodes (the endpoint is authenticated
|
|
3066
|
+
* but request-driven — without a cap a client loop could fork-bomb the node). */
|
|
3067
|
+
var MAX_CONCURRENT_DECODES = 3;
|
|
3068
|
+
/** Requests parked beyond the concurrency cap; overflow answers 204 instead of
|
|
3069
|
+
* queueing unboundedly. The real dialog fetches 2 stills — this is headroom. */
|
|
3070
|
+
var MAX_WAIT_QUEUE = 8;
|
|
3071
|
+
var StillFrameService = class {
|
|
3237
3072
|
deps;
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
dataPlaneDispose = null;
|
|
3241
|
-
flushTimer = null;
|
|
3242
|
-
backfillTimer = null;
|
|
3243
|
-
pruneTimer = null;
|
|
3244
|
-
pruneInitialTimer = null;
|
|
3245
|
-
backfillActive = 0;
|
|
3246
|
-
backfillInFlight = /* @__PURE__ */ new Set();
|
|
3247
|
-
stopped = false;
|
|
3073
|
+
inFlight = 0;
|
|
3074
|
+
waitQueue = [];
|
|
3248
3075
|
constructor(deps) {
|
|
3249
3076
|
this.deps = deps;
|
|
3250
|
-
this.sink = new ThumbnailSink({
|
|
3251
|
-
windowMs: deps.config.windowMs,
|
|
3252
|
-
intervalMs: deps.config.intervalMs,
|
|
3253
|
-
maxCols: 10,
|
|
3254
|
-
resolvePreset: (deviceId) => deps.resolvePreset(deviceId),
|
|
3255
|
-
maxTilesPerWindow: MAX_TILES_PER_WINDOW,
|
|
3256
|
-
idleGraceMs: IDLE_GRACE_MS,
|
|
3257
|
-
source: "sampled",
|
|
3258
|
-
compose: (req) => composeSheet(SHARP, {
|
|
3259
|
-
tiles: [...req.tiles],
|
|
3260
|
-
placements: req.placements,
|
|
3261
|
-
geometry: req.geometry,
|
|
3262
|
-
quality: req.quality
|
|
3263
|
-
}),
|
|
3264
|
-
persist: (req) => this.persist(req),
|
|
3265
|
-
logger: deps.logger.child("thumbnail-sink")
|
|
3266
|
-
});
|
|
3267
|
-
}
|
|
3268
|
-
/** First writable thumbs root (`<locationRoot>/.thumbs`), or null when no
|
|
3269
|
-
* recordings location is resolved yet. */
|
|
3270
|
-
writeRoot() {
|
|
3271
|
-
const loc = this.deps.locations()[0];
|
|
3272
|
-
return loc ? path.join(loc.root, THUMBS_SUBDIR) : null;
|
|
3273
|
-
}
|
|
3274
|
-
/** Every thumbs root (for the data-plane getFile search). */
|
|
3275
|
-
thumbsRoots() {
|
|
3276
|
-
return this.deps.locations().map((l) => path.join(l.root, THUMBS_SUBDIR));
|
|
3277
|
-
}
|
|
3278
|
-
/**
|
|
3279
|
-
* Serve ONLY the `thumbs` data-plane endpoint, without starting the sink /
|
|
3280
|
-
* timers. Called from the recorder's `onInitialize` — BEFORE the
|
|
3281
|
-
* node-registration handshake — because the hub pulls a forked addon's
|
|
3282
|
-
* data-plane endpoints once, off the caps-registered signal: an endpoint
|
|
3283
|
-
* served later (the `start()` in `onHubReachable` lands minutes after boot)
|
|
3284
|
-
* never reaches the reverse-proxy and `/addon/recorder/thumbs/*` 404s to the
|
|
3285
|
-
* SPA (live-diagnosed 2026-07-17). Safe to serve early: the handler resolves
|
|
3286
|
-
* roots lazily per request (mirrors the playback data-plane pattern).
|
|
3287
|
-
* Idempotent — `start()` re-invokes it harmlessly.
|
|
3288
|
-
*/
|
|
3289
|
-
async serveEarly() {
|
|
3290
|
-
if (!this.deps.config.enabled) return;
|
|
3291
|
-
if (this.dataPlaneDispose !== null) return;
|
|
3292
|
-
await this.serveDataPlane();
|
|
3293
3077
|
}
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
jpeg: Buffer.from(d.jpeg)
|
|
3307
|
-
});
|
|
3308
|
-
} catch (err) {
|
|
3309
|
-
this.deps.logger.debug("thumbnail add failed", {
|
|
3310
|
-
tags: { deviceId: d.deviceId },
|
|
3311
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
3312
|
-
});
|
|
3313
|
-
}
|
|
3078
|
+
/** Acquire a decode slot; resolves false when the wait queue is saturated. */
|
|
3079
|
+
acquireSlot() {
|
|
3080
|
+
if (this.inFlight < MAX_CONCURRENT_DECODES) {
|
|
3081
|
+
this.inFlight += 1;
|
|
3082
|
+
return Promise.resolve(true);
|
|
3083
|
+
}
|
|
3084
|
+
if (this.waitQueue.length >= MAX_WAIT_QUEUE) return Promise.resolve(false);
|
|
3085
|
+
return new Promise((resolve) => {
|
|
3086
|
+
this.waitQueue.push(() => {
|
|
3087
|
+
this.inFlight += 1;
|
|
3088
|
+
resolve(true);
|
|
3089
|
+
});
|
|
3314
3090
|
});
|
|
3315
|
-
if (this.dataPlaneDispose === null) await this.serveDataPlane();
|
|
3316
|
-
this.flushTimer = setInterval(() => this.sink.flushExpired(), FLUSH_TICK_MS);
|
|
3317
|
-
if (typeof this.flushTimer === "object" && "unref" in this.flushTimer) this.flushTimer.unref?.();
|
|
3318
|
-
if (this.deps.config.backfillEnabled) {
|
|
3319
|
-
this.backfillTimer = setInterval(() => {
|
|
3320
|
-
this.runBackfillPass();
|
|
3321
|
-
}, BACKFILL_TICK_MS);
|
|
3322
|
-
this.backfillTimer.unref?.();
|
|
3323
|
-
}
|
|
3324
|
-
this.pruneInitialTimer = setTimeout(() => {
|
|
3325
|
-
this.runPrunePass();
|
|
3326
|
-
}, PRUNE_INITIAL_DELAY_MS);
|
|
3327
|
-
this.pruneInitialTimer.unref?.();
|
|
3328
|
-
this.pruneTimer = setInterval(() => {
|
|
3329
|
-
this.runPrunePass();
|
|
3330
|
-
}, PRUNE_TICK_MS);
|
|
3331
|
-
this.pruneTimer.unref?.();
|
|
3332
|
-
this.deps.logger.info("thumbnail service started", { meta: {
|
|
3333
|
-
windowMs: this.deps.config.windowMs,
|
|
3334
|
-
intervalMs: this.deps.config.intervalMs,
|
|
3335
|
-
backfill: this.deps.config.backfillEnabled
|
|
3336
|
-
} });
|
|
3337
3091
|
}
|
|
3338
|
-
|
|
3339
|
-
this.
|
|
3340
|
-
this.
|
|
3341
|
-
|
|
3342
|
-
if (this.flushTimer) clearInterval(this.flushTimer);
|
|
3343
|
-
if (this.backfillTimer) clearInterval(this.backfillTimer);
|
|
3344
|
-
if (this.pruneTimer) clearInterval(this.pruneTimer);
|
|
3345
|
-
if (this.pruneInitialTimer) clearTimeout(this.pruneInitialTimer);
|
|
3346
|
-
this.flushTimer = null;
|
|
3347
|
-
this.backfillTimer = null;
|
|
3348
|
-
this.pruneTimer = null;
|
|
3349
|
-
this.pruneInitialTimer = null;
|
|
3350
|
-
try {
|
|
3351
|
-
await this.sink.flushAll();
|
|
3352
|
-
} catch {}
|
|
3353
|
-
await this.dataPlaneDispose?.().catch(() => {});
|
|
3354
|
-
this.dataPlaneDispose = null;
|
|
3355
|
-
}
|
|
3356
|
-
async serveDataPlane() {
|
|
3357
|
-
const handler = createThumbsMediaHandler({ getFile: async (rel) => {
|
|
3358
|
-
for (const root of this.thumbsRoots()) {
|
|
3359
|
-
const abs = path.join(root, rel);
|
|
3360
|
-
if (!abs.startsWith(root + path.sep)) continue;
|
|
3361
|
-
try {
|
|
3362
|
-
return {
|
|
3363
|
-
bytes: await promises.readFile(abs),
|
|
3364
|
-
contentType: thumbsContentType(rel)
|
|
3365
|
-
};
|
|
3366
|
-
} catch {}
|
|
3367
|
-
}
|
|
3368
|
-
return null;
|
|
3369
|
-
} });
|
|
3370
|
-
try {
|
|
3371
|
-
const served = await this.deps.dataPlane?.serve({
|
|
3372
|
-
prefix: THUMBS_PREFIX,
|
|
3373
|
-
access: "authenticated",
|
|
3374
|
-
handler
|
|
3375
|
-
});
|
|
3376
|
-
if (served) {
|
|
3377
|
-
this.dataPlaneDispose = () => served.dispose();
|
|
3378
|
-
this.deps.logger.info("thumbnail data-plane served", { meta: { clientBase: `/addon/${RECORDER_ADDON_ID$1}/${THUMBS_PREFIX}` } });
|
|
3379
|
-
}
|
|
3380
|
-
} catch (err) {
|
|
3381
|
-
this.deps.logger.warn("thumbnail data-plane failed to serve", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
3382
|
-
}
|
|
3092
|
+
releaseSlot() {
|
|
3093
|
+
this.inFlight -= 1;
|
|
3094
|
+
const next = this.waitQueue.shift();
|
|
3095
|
+
if (next !== void 0) next();
|
|
3383
3096
|
}
|
|
3384
3097
|
/**
|
|
3385
|
-
*
|
|
3386
|
-
*
|
|
3387
|
-
*
|
|
3388
|
-
* device has no segments). Pure decision in `planThumbPrune`; only valid
|
|
3389
|
-
* `Y/M/D/H` buckets are ever deleted. Best-effort — a failed rm retries on
|
|
3390
|
-
* the next sweep.
|
|
3098
|
+
* Decode the frame covering `epochMs`. Returns the JPEG + immutability, or
|
|
3099
|
+
* null when no footage covers the instant / the decode yields no bytes (the
|
|
3100
|
+
* caller replies 204 → the dialog shows its placeholder).
|
|
3391
3101
|
*/
|
|
3392
|
-
async
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
const deviceId = Number(dev);
|
|
3405
|
-
const deviceDir = path.join(root, dev);
|
|
3406
|
-
const buckets = [];
|
|
3407
|
-
try {
|
|
3408
|
-
for (const rel of await promises.readdir(deviceDir, { recursive: true })) {
|
|
3409
|
-
const relPosix = String(rel).split(path.sep).join("/");
|
|
3410
|
-
const hourStartMs = parseHourBucket(relPosix);
|
|
3411
|
-
if (hourStartMs !== null) buckets.push({
|
|
3412
|
-
relDir: relPosix,
|
|
3413
|
-
hourStartMs
|
|
3414
|
-
});
|
|
3415
|
-
}
|
|
3416
|
-
} catch {
|
|
3417
|
-
continue;
|
|
3418
|
-
}
|
|
3419
|
-
const segments = this.deps.lowSegments(deviceId);
|
|
3420
|
-
const plan = planThumbPrune({
|
|
3421
|
-
buckets,
|
|
3422
|
-
oldestSegmentStartMs: segments.length > 0 ? Math.min(...segments.map((s) => s.startMs)) : null,
|
|
3423
|
-
nowMs: Date.now()
|
|
3424
|
-
});
|
|
3425
|
-
for (const b of plan) try {
|
|
3426
|
-
await promises.rm(path.join(deviceDir, b.relDir), {
|
|
3427
|
-
recursive: true,
|
|
3428
|
-
force: true
|
|
3429
|
-
});
|
|
3430
|
-
pruned += 1;
|
|
3431
|
-
} catch {}
|
|
3432
|
-
if (plan.length > 0) await removeEmptyParents(deviceDir);
|
|
3433
|
-
}
|
|
3434
|
-
}
|
|
3435
|
-
if (pruned > 0) this.deps.logger.info("thumbnail retention prune removed buckets", { meta: { pruned } });
|
|
3436
|
-
}
|
|
3437
|
-
async persist(req) {
|
|
3438
|
-
const root = this.writeRoot();
|
|
3439
|
-
if (!root) {
|
|
3440
|
-
this.deps.logger.warn("thumbnail persist skipped — no recordings location resolved", { tags: { deviceId: req.deviceId } });
|
|
3441
|
-
return;
|
|
3442
|
-
}
|
|
3443
|
-
await persistThumbnailWindow(root, req);
|
|
3444
|
-
}
|
|
3445
|
-
/** One backfill scan: fill uncovered, settled windows with recorded low
|
|
3446
|
-
* footage, bounded per pass + per concurrency. Best-effort. */
|
|
3447
|
-
async runBackfillPass() {
|
|
3448
|
-
if (this.stopped || !this.deps.config.backfillEnabled) return;
|
|
3449
|
-
const root = this.writeRoot();
|
|
3450
|
-
if (!root) return;
|
|
3451
|
-
let deviceIds;
|
|
3452
|
-
try {
|
|
3453
|
-
deviceIds = await this.deps.recordingDeviceIds();
|
|
3454
|
-
} catch {
|
|
3455
|
-
return;
|
|
3456
|
-
}
|
|
3457
|
-
let filled = 0;
|
|
3458
|
-
for (const deviceId of deviceIds) {
|
|
3459
|
-
if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || this.stopped) break;
|
|
3460
|
-
const segments = this.deps.lowSegments(deviceId).map((s) => ({
|
|
3461
|
-
startMs: s.startMs,
|
|
3462
|
-
durMs: s.durMs,
|
|
3463
|
-
path: s.path
|
|
3464
|
-
}));
|
|
3465
|
-
if (segments.length === 0) continue;
|
|
3466
|
-
const plans = planBackfill({
|
|
3467
|
-
segments,
|
|
3468
|
-
coveredWindows: await this.readCoveredWindows(root, deviceId),
|
|
3469
|
-
windowMs: this.deps.config.windowMs,
|
|
3470
|
-
now: Date.now(),
|
|
3471
|
-
settleMs: BACKFILL_SETTLE_MS,
|
|
3472
|
-
maxWindows: Math.min(BACKFILL_MAX_WINDOWS_PER_DEVICE, BACKFILL_MAX_WINDOWS_PER_PASS - filled)
|
|
3473
|
-
});
|
|
3474
|
-
let filledForDevice = 0;
|
|
3475
|
-
for (const plan of plans) {
|
|
3476
|
-
if (filled >= BACKFILL_MAX_WINDOWS_PER_PASS || filledForDevice >= BACKFILL_MAX_WINDOWS_PER_DEVICE || this.stopped) break;
|
|
3477
|
-
const key = `${deviceId}:${plan.windowStartMs}`;
|
|
3478
|
-
if (this.backfillInFlight.has(key)) continue;
|
|
3479
|
-
this.backfillInFlight.add(key);
|
|
3480
|
-
try {
|
|
3481
|
-
await this.gateBackfill(() => this.backfillWindow(deviceId, plan.windowStartMs, plan.segments, root));
|
|
3482
|
-
filled += 1;
|
|
3483
|
-
filledForDevice += 1;
|
|
3484
|
-
} catch (err) {
|
|
3485
|
-
this.deps.logger.debug("backfill window failed", {
|
|
3486
|
-
tags: { deviceId },
|
|
3487
|
-
meta: {
|
|
3488
|
-
windowStartMs: plan.windowStartMs,
|
|
3489
|
-
error: err instanceof Error ? err.message : err
|
|
3490
|
-
}
|
|
3491
|
-
});
|
|
3492
|
-
} finally {
|
|
3493
|
-
this.backfillInFlight.delete(key);
|
|
3494
|
-
}
|
|
3495
|
-
}
|
|
3496
|
-
}
|
|
3497
|
-
if (filled > 0) this.deps.logger.info("thumbnail backfill pass filled windows", { meta: { filled } });
|
|
3498
|
-
}
|
|
3499
|
-
/** Bounded-concurrency gate (spin-wait, coarse — backfill is rare/hourly). */
|
|
3500
|
-
async gateBackfill(fn) {
|
|
3501
|
-
while (this.backfillActive >= BACKFILL_MAX_CONCURRENT && !this.stopped) await new Promise((r) => setTimeout(r, 200));
|
|
3502
|
-
this.backfillActive += 1;
|
|
3503
|
-
try {
|
|
3504
|
-
return await fn();
|
|
3505
|
-
} finally {
|
|
3506
|
-
this.backfillActive -= 1;
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
/** Which windows already have an index sidecar on disk (sampled or prior backfill). */
|
|
3510
|
-
async readCoveredWindows(root, deviceId) {
|
|
3511
|
-
const covered = /* @__PURE__ */ new Set();
|
|
3512
|
-
const deviceDir = path.join(root, String(deviceId));
|
|
3513
|
-
let entries;
|
|
3514
|
-
try {
|
|
3515
|
-
entries = await promises.readdir(deviceDir, { recursive: true });
|
|
3516
|
-
} catch {
|
|
3517
|
-
return covered;
|
|
3518
|
-
}
|
|
3519
|
-
for (const e of entries) {
|
|
3520
|
-
const m = /index-(\d+)\.json$/.exec(e);
|
|
3521
|
-
if (m) covered.add(Number(m[1]));
|
|
3102
|
+
async getStill(deviceId, epochMs) {
|
|
3103
|
+
const source = resolveStillSource(this.deps.listLowSegments(deviceId), epochMs);
|
|
3104
|
+
if (source === null) return null;
|
|
3105
|
+
const abs = this.deps.resolveAbsPath(source.row);
|
|
3106
|
+
const now = (this.deps.now ?? Date.now)();
|
|
3107
|
+
const immutable = source.row.startMs + source.row.durMs < now;
|
|
3108
|
+
if (!await this.acquireSlot()) {
|
|
3109
|
+
this.deps.logger.debug("still: decode queue saturated, dropping request", { meta: {
|
|
3110
|
+
deviceId,
|
|
3111
|
+
epochMs
|
|
3112
|
+
} });
|
|
3113
|
+
return null;
|
|
3522
3114
|
}
|
|
3523
|
-
return covered;
|
|
3524
|
-
}
|
|
3525
|
-
/** Fill ONE window: extract keyframes from its covering low segments, compose
|
|
3526
|
-
* a sheet, persist with `source:'backfill'`. */
|
|
3527
|
-
async backfillWindow(deviceId, windowStartMs, segments, root) {
|
|
3528
|
-
const preset = await this.deps.resolvePreset(deviceId);
|
|
3529
|
-
const workDir = await promises.mkdtemp(path.join(tmpdir(), `csthumb-${deviceId}-`));
|
|
3530
3115
|
try {
|
|
3531
|
-
const
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
for (let i = 0; i < n; i += 1) {
|
|
3538
|
-
const t = seg.startMs + Math.round(seg.durMs * i / Math.max(1, n));
|
|
3539
|
-
if (t >= windowStartMs && t < windowStartMs + this.deps.config.windowMs) samples.push({
|
|
3540
|
-
t,
|
|
3541
|
-
jpeg: jpegs[i]
|
|
3542
|
-
});
|
|
3543
|
-
}
|
|
3544
|
-
}
|
|
3545
|
-
if (samples.length === 0) return;
|
|
3546
|
-
if (samples.length > MAX_TILES_PER_WINDOW) samples.length = MAX_TILES_PER_WINDOW;
|
|
3547
|
-
const ordered = samples.sort((a, b) => a.t - b.t);
|
|
3548
|
-
const built = buildThumbnailIndex({
|
|
3549
|
-
deviceId,
|
|
3550
|
-
windowStartMs,
|
|
3551
|
-
windowMs: this.deps.config.windowMs,
|
|
3552
|
-
intervalMs: this.deps.config.intervalMs,
|
|
3553
|
-
source: "backfill",
|
|
3554
|
-
tileWidth: preset.tileWidth,
|
|
3555
|
-
tileHeight: preset.tileHeight,
|
|
3556
|
-
maxCols: 10,
|
|
3557
|
-
samples: ordered.map((s) => ({ t: s.t }))
|
|
3558
|
-
});
|
|
3559
|
-
const geometry = computeSheetGeometry(ordered.length, {
|
|
3560
|
-
tileWidth: preset.tileWidth,
|
|
3561
|
-
tileHeight: preset.tileHeight,
|
|
3562
|
-
maxCols: 10
|
|
3563
|
-
});
|
|
3564
|
-
const placements = allTilePlacements(ordered.length, geometry);
|
|
3565
|
-
const sheetBytes = await composeSheet(SHARP, {
|
|
3566
|
-
tiles: ordered.map((s) => s.jpeg),
|
|
3567
|
-
placements,
|
|
3568
|
-
geometry,
|
|
3569
|
-
quality: preset.quality
|
|
3570
|
-
});
|
|
3571
|
-
await persistThumbnailWindow(root, {
|
|
3572
|
-
deviceId,
|
|
3573
|
-
windowStartMs,
|
|
3574
|
-
relDir: `${deviceId}/${bucketDir(windowStartMs)}`,
|
|
3575
|
-
sheetName: sheetFileName(windowStartMs),
|
|
3576
|
-
sheetBytes,
|
|
3577
|
-
indexName: indexFileName(windowStartMs),
|
|
3578
|
-
index: built.index
|
|
3579
|
-
});
|
|
3116
|
+
const jpeg = await this.decodeFrame(abs, source.offsetSec);
|
|
3117
|
+
if (jpeg === null || jpeg.byteLength === 0) return null;
|
|
3118
|
+
return {
|
|
3119
|
+
jpeg,
|
|
3120
|
+
immutable
|
|
3121
|
+
};
|
|
3580
3122
|
} finally {
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
});
|
|
3604
|
-
await this.spawnFfmpeg(args);
|
|
3605
|
-
const files = (await promises.readdir(workDir)).filter((f) => /^kf-\d+\.jpg$/.test(f)).sort();
|
|
3606
|
-
const out = [];
|
|
3607
|
-
for (const f of files) {
|
|
3608
|
-
out.push(await promises.readFile(path.join(workDir, f)));
|
|
3609
|
-
await promises.rm(path.join(workDir, f), { force: true }).catch(() => {});
|
|
3610
|
-
}
|
|
3611
|
-
return out;
|
|
3612
|
-
}
|
|
3613
|
-
spawnFfmpeg(args) {
|
|
3614
|
-
return new Promise((resolve, reject) => {
|
|
3123
|
+
this.releaseSlot();
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
/** Run ONE ffmpeg input-seek + single-frame decode; JPEG bytes on stdout. */
|
|
3127
|
+
decodeFrame(absPath, offsetSec) {
|
|
3128
|
+
const args = [
|
|
3129
|
+
"-hide_banner",
|
|
3130
|
+
"-loglevel",
|
|
3131
|
+
"error",
|
|
3132
|
+
"-ss",
|
|
3133
|
+
offsetSec.toFixed(3),
|
|
3134
|
+
"-i",
|
|
3135
|
+
absPath,
|
|
3136
|
+
"-frames:v",
|
|
3137
|
+
"1",
|
|
3138
|
+
"-f",
|
|
3139
|
+
"mjpeg",
|
|
3140
|
+
"pipe:1"
|
|
3141
|
+
];
|
|
3142
|
+
const spawn$1 = this.deps.spawn ?? spawn;
|
|
3143
|
+
const bin = this.deps.ffmpegPath ?? "ffmpeg";
|
|
3144
|
+
return new Promise((resolve) => {
|
|
3615
3145
|
let child;
|
|
3616
3146
|
try {
|
|
3617
|
-
child = spawn(
|
|
3618
|
-
"ignore",
|
|
3619
|
-
"ignore",
|
|
3620
|
-
"ignore"
|
|
3621
|
-
] });
|
|
3147
|
+
child = spawn$1(bin, args);
|
|
3622
3148
|
} catch (err) {
|
|
3623
|
-
|
|
3149
|
+
this.deps.logger.debug("still: ffmpeg spawn failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
3150
|
+
resolve(null);
|
|
3624
3151
|
return;
|
|
3625
3152
|
}
|
|
3153
|
+
const chunks = [];
|
|
3154
|
+
child.stdout?.on("data", (c) => chunks.push(c));
|
|
3626
3155
|
const timer = setTimeout(() => {
|
|
3627
3156
|
child.kill("SIGKILL");
|
|
3628
|
-
|
|
3629
|
-
|
|
3157
|
+
this.deps.logger.debug("still: ffmpeg decode timed out", { meta: { absPath } });
|
|
3158
|
+
resolve(null);
|
|
3159
|
+
}, FFMPEG_TIMEOUT_MS);
|
|
3630
3160
|
timer.unref?.();
|
|
3631
3161
|
child.on("error", (err) => {
|
|
3632
3162
|
clearTimeout(timer);
|
|
3633
|
-
|
|
3163
|
+
this.deps.logger.debug("still: ffmpeg error", { meta: { error: err.message } });
|
|
3164
|
+
resolve(null);
|
|
3634
3165
|
});
|
|
3635
3166
|
child.on("close", (code) => {
|
|
3636
3167
|
clearTimeout(timer);
|
|
3637
|
-
if (code === 0)
|
|
3638
|
-
|
|
3168
|
+
if (code !== 0 && chunks.length === 0) {
|
|
3169
|
+
resolve(null);
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
resolve(chunks.length > 0 ? Buffer.concat(chunks) : null);
|
|
3639
3173
|
});
|
|
3640
3174
|
});
|
|
3641
3175
|
}
|
|
3642
3176
|
};
|
|
3643
|
-
/**
|
|
3644
|
-
* Best-effort removal of now-empty `Y/M/D` parent dirs after a prune (never
|
|
3645
|
-
* the device dir itself). `rmdir` refuses non-empty dirs, so this can never
|
|
3646
|
-
* delete content — failures are simply left for the next sweep.
|
|
3647
|
-
*/
|
|
3648
|
-
async function removeEmptyParents(deviceDir) {
|
|
3649
|
-
let entries;
|
|
3650
|
-
try {
|
|
3651
|
-
entries = (await promises.readdir(deviceDir, { recursive: true })).map(String);
|
|
3652
|
-
} catch {
|
|
3653
|
-
return;
|
|
3654
|
-
}
|
|
3655
|
-
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);
|
|
3656
|
-
for (const rel of dirs) try {
|
|
3657
|
-
await promises.rmdir(path.join(deviceDir, rel));
|
|
3658
|
-
} catch {}
|
|
3659
|
-
}
|
|
3660
3177
|
//#endregion
|
|
3661
3178
|
//#region src/recorder/addon/index.ts
|
|
3662
3179
|
/**
|
|
@@ -3691,9 +3208,7 @@ var DEFAULT_CONFIG = {
|
|
|
3691
3208
|
segmentSeconds: 10,
|
|
3692
3209
|
watchIntervalMs: 2e3,
|
|
3693
3210
|
recordingNodeId: RECORDING_NODE_DEFAULT,
|
|
3694
|
-
recordingHubHostname: ""
|
|
3695
|
-
thumbnailsEnabled: true,
|
|
3696
|
-
thumbnailBackfillEnabled: true
|
|
3211
|
+
recordingHubHostname: ""
|
|
3697
3212
|
};
|
|
3698
3213
|
/** Playback-marker window padding around a motion event (EventMap only; the
|
|
3699
3214
|
* controller's events-band gating uses the raw trigger ms, not this pad).
|
|
@@ -3708,6 +3223,12 @@ var MARKER_PAD_POST_MS = EVENT_PAD_MS.postMs;
|
|
|
3708
3223
|
* form the hub reverse-proxy client path `/addon/recorder/playback`. */
|
|
3709
3224
|
var RECORDER_ADDON_ID = "recorder";
|
|
3710
3225
|
var PLAYBACK_PREFIX = "playback";
|
|
3226
|
+
/** Data-plane prefix for keyframe-only scrub packs →
|
|
3227
|
+
* `/addon/recorder/scrub/<deviceId>/<windowStartSec>`. */
|
|
3228
|
+
var SCRUB_PREFIX = "scrub";
|
|
3229
|
+
/** Data-plane prefix for on-demand single stills →
|
|
3230
|
+
* `/addon/recorder/still/<deviceId>/<epochSec>`. */
|
|
3231
|
+
var STILL_PREFIX = "still";
|
|
3711
3232
|
/** Data-plane prefix for export MP4 downloads → `/addon/recorder/exports/<id>.mp4`. */
|
|
3712
3233
|
var EXPORTS_PREFIX = "exports";
|
|
3713
3234
|
/** How often the export janitor sweeps expired exports. */
|
|
@@ -3718,6 +3239,19 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
|
|
|
3718
3239
|
* hourly cadence keeps the storage.list/evict cost negligible while honouring
|
|
3719
3240
|
* the recording-spec §6 periodic retention. */
|
|
3720
3241
|
var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
|
|
3242
|
+
/** Scrub-pack cache prune cadence — mirrors the thumbnail service's own prune
|
|
3243
|
+
* tick (6h) + initial delay (10min off boot churn). Packs are a rebuild-on-
|
|
3244
|
+
* demand cache, so a slow sweep is plenty. */
|
|
3245
|
+
var SCRUB_PRUNE_TICK_MS = 6 * 36e5;
|
|
3246
|
+
var SCRUB_PRUNE_INITIAL_DELAY_MS = 10 * 6e4;
|
|
3247
|
+
/** Retention horizon for the scrub-pack cache (days). Packs are a keyframe-only
|
|
3248
|
+
* CACHE derived from the LOW footage — never primary data — so a pruned window
|
|
3249
|
+
* simply rebuilds on the next request while its segments still exist. A fixed,
|
|
3250
|
+
* generous horizon (comfortably past any typical per-device footage max-age)
|
|
3251
|
+
* reclaims disk from truly-old orphaned packs without racing footage retention.
|
|
3252
|
+
* The recorder has no single global footage max-age (it is per-device), so a
|
|
3253
|
+
* fixed cache horizon is the correct scope here. */
|
|
3254
|
+
var SCRUB_PACK_RETENTION_DAYS = 90;
|
|
3721
3255
|
/** Grace after a completed download before a `deleteAfterDownload` file is removed. */
|
|
3722
3256
|
var EXPORT_DELETE_GRACE_MS = 6e4;
|
|
3723
3257
|
/** Only `.mp4` export ids matching this shape are servable (traversal guard). */
|
|
@@ -3747,8 +3281,15 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3747
3281
|
resolvedLocations = [];
|
|
3748
3282
|
/** Base URL of the playback HTTP data-plane, null until served in onHubReachable. */
|
|
3749
3283
|
playbackBaseUrl = null;
|
|
3750
|
-
/**
|
|
3751
|
-
|
|
3284
|
+
/** On-demand single-still decode service (export-dialog frame previews). Null
|
|
3285
|
+
* on non-recording nodes / when the data-plane serve fails. */
|
|
3286
|
+
/** Keyframe-only scrub-pack service (built + served in onInitialize). Null on
|
|
3287
|
+
* non-recording nodes / when the data-plane serve fails. */
|
|
3288
|
+
scrubPackService = null;
|
|
3289
|
+
/** Periodic scrub-pack cache prune timer + its one-shot initial delay. Cleared
|
|
3290
|
+
* on shutdown. */
|
|
3291
|
+
scrubPruneTimer = null;
|
|
3292
|
+
scrubPruneInitialTimer = null;
|
|
3752
3293
|
/** Render engine for recording exports. Null on non-recording nodes. */
|
|
3753
3294
|
exportEngine = null;
|
|
3754
3295
|
/** Periodic export-expiry sweep timer. Cleared on shutdown. */
|
|
@@ -3870,7 +3411,31 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3870
3411
|
this.recordingProvider = recordingProvider;
|
|
3871
3412
|
const evictableProvider = new StorageEvictableProvider({
|
|
3872
3413
|
index: this.index,
|
|
3873
|
-
store: this.segmentStore
|
|
3414
|
+
store: this.segmentStore,
|
|
3415
|
+
resolveDomain: (locationId) => this.locationDomain(locationId),
|
|
3416
|
+
onEvicted: (summaries) => {
|
|
3417
|
+
for (const s of summaries) this.ctx.eventBus.emit({
|
|
3418
|
+
id: randomUUID(),
|
|
3419
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3420
|
+
source: {
|
|
3421
|
+
type: "device",
|
|
3422
|
+
id: s.deviceId,
|
|
3423
|
+
addonId: this.ctx.id,
|
|
3424
|
+
deviceId: s.deviceId,
|
|
3425
|
+
nodeId: this.nodeId
|
|
3426
|
+
},
|
|
3427
|
+
category: EventCategory.RecordingRetentionCompleted,
|
|
3428
|
+
data: {
|
|
3429
|
+
deviceId: s.deviceId,
|
|
3430
|
+
locationId: s.locationId,
|
|
3431
|
+
segments: s.segments,
|
|
3432
|
+
bytes: s.bytes,
|
|
3433
|
+
fromMs: s.fromMs,
|
|
3434
|
+
toMs: s.toMs,
|
|
3435
|
+
trigger: "disk-pressure"
|
|
3436
|
+
}
|
|
3437
|
+
});
|
|
3438
|
+
}
|
|
3874
3439
|
});
|
|
3875
3440
|
const exportsState = this.exportsStore();
|
|
3876
3441
|
const exportCodecCache = /* @__PURE__ */ new Map();
|
|
@@ -3917,6 +3482,55 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3917
3482
|
} catch (err) {
|
|
3918
3483
|
this.ctx.logger.warn("recorder playback data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
3919
3484
|
}
|
|
3485
|
+
try {
|
|
3486
|
+
const scrubService = new ScrubPackService({
|
|
3487
|
+
listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
3488
|
+
readSegmentBytes: async (row) => {
|
|
3489
|
+
const abs = this.resolveSegmentAbsPath(row);
|
|
3490
|
+
return new Uint8Array(await promises.readFile(abs));
|
|
3491
|
+
},
|
|
3492
|
+
cacheDir: path.join(this.ctx.dataDir, "scrub-packs"),
|
|
3493
|
+
logger: this.ctx.logger.child("ScrubPacks")
|
|
3494
|
+
});
|
|
3495
|
+
this.scrubPackService = scrubService;
|
|
3496
|
+
const served = await this.ctx.dataPlane?.serve({
|
|
3497
|
+
prefix: SCRUB_PREFIX,
|
|
3498
|
+
access: "authenticated",
|
|
3499
|
+
handler: (req, res) => this.serveScrubPack(scrubService, req, res)
|
|
3500
|
+
});
|
|
3501
|
+
this.scrubPruneInitialTimer = setTimeout(() => {
|
|
3502
|
+
this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
|
|
3503
|
+
}, SCRUB_PRUNE_INITIAL_DELAY_MS);
|
|
3504
|
+
this.scrubPruneInitialTimer.unref?.();
|
|
3505
|
+
this.scrubPruneTimer = setInterval(() => {
|
|
3506
|
+
this.scrubPackService?.prune(SCRUB_PACK_RETENTION_DAYS);
|
|
3507
|
+
}, SCRUB_PRUNE_TICK_MS);
|
|
3508
|
+
this.scrubPruneTimer.unref?.();
|
|
3509
|
+
this.ctx.logger.info("recorder scrub-pack data-plane served", { meta: {
|
|
3510
|
+
clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${SCRUB_PREFIX}` : "(no dataPlane)",
|
|
3511
|
+
internal: served?.baseUrl
|
|
3512
|
+
} });
|
|
3513
|
+
} catch (err) {
|
|
3514
|
+
this.ctx.logger.warn("scrub-pack data-plane serve failed", { meta: { error: errMsg(err) } });
|
|
3515
|
+
}
|
|
3516
|
+
try {
|
|
3517
|
+
const stillService = new StillFrameService({
|
|
3518
|
+
listLowSegments: (deviceId) => this.index.segments(deviceId, "low"),
|
|
3519
|
+
resolveAbsPath: (row) => this.resolveSegmentAbsPath(row),
|
|
3520
|
+
logger: this.ctx.logger.child("Still")
|
|
3521
|
+
});
|
|
3522
|
+
const served = await this.ctx.dataPlane?.serve({
|
|
3523
|
+
prefix: STILL_PREFIX,
|
|
3524
|
+
access: "authenticated",
|
|
3525
|
+
handler: (req, res) => this.serveStill(stillService, req, res)
|
|
3526
|
+
});
|
|
3527
|
+
this.ctx.logger.info("recorder still data-plane served", { meta: {
|
|
3528
|
+
clientBase: served ? `/addon/${RECORDER_ADDON_ID}/${STILL_PREFIX}` : "(no dataPlane)",
|
|
3529
|
+
internal: served?.baseUrl
|
|
3530
|
+
} });
|
|
3531
|
+
} catch (err) {
|
|
3532
|
+
this.ctx.logger.warn("still data-plane serve failed", { meta: { error: errMsg(err) } });
|
|
3533
|
+
}
|
|
3920
3534
|
try {
|
|
3921
3535
|
const served = await this.ctx.dataPlane?.serve({
|
|
3922
3536
|
prefix: EXPORTS_PREFIX,
|
|
@@ -3930,7 +3544,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3930
3544
|
} catch (err) {
|
|
3931
3545
|
this.ctx.logger.warn("recorder exports data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
3932
3546
|
}
|
|
3933
|
-
await this.ensureThumbnailService().serveEarly();
|
|
3934
3547
|
this.ctx.logger.info("recorder addon started (continuous write path active)", {
|
|
3935
3548
|
tags: { nodeId: this.nodeId },
|
|
3936
3549
|
meta: {
|
|
@@ -3959,8 +3572,15 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3959
3572
|
await this.controller?.stop();
|
|
3960
3573
|
this.controller = null;
|
|
3961
3574
|
this.segmentStore = null;
|
|
3962
|
-
|
|
3963
|
-
|
|
3575
|
+
if (this.scrubPruneInitialTimer !== null) {
|
|
3576
|
+
clearTimeout(this.scrubPruneInitialTimer);
|
|
3577
|
+
this.scrubPruneInitialTimer = null;
|
|
3578
|
+
}
|
|
3579
|
+
if (this.scrubPruneTimer !== null) {
|
|
3580
|
+
clearInterval(this.scrubPruneTimer);
|
|
3581
|
+
this.scrubPruneTimer = null;
|
|
3582
|
+
}
|
|
3583
|
+
this.scrubPackService = null;
|
|
3964
3584
|
if (this.exportSweepTimer !== null) {
|
|
3965
3585
|
clearInterval(this.exportSweepTimer);
|
|
3966
3586
|
this.exportSweepTimer = null;
|
|
@@ -3971,6 +3591,25 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3971
3591
|
this.recordingProvider = null;
|
|
3972
3592
|
}
|
|
3973
3593
|
/**
|
|
3594
|
+
* The eviction DOMAIN of `locationId`: every resolved recordings location that
|
|
3595
|
+
* shares its physical filesystem root, including itself. Managed locations that
|
|
3596
|
+
* alias one disk (today `recordings:default` + `recordingsLow:default` both at
|
|
3597
|
+
* `/recordings`) form one oldest-first eviction pool — otherwise disk-pressure
|
|
3598
|
+
* relief on one alias deletes the newest footage while the other alias holds the
|
|
3599
|
+
* old (the age-partition data-loss bug).
|
|
3600
|
+
*
|
|
3601
|
+
* Grouping keys by `fs.realpathSync` of the resolved root (see
|
|
3602
|
+
* `resolveEvictionDomain`), NOT the resolved string alone: a symlinked or
|
|
3603
|
+
* bind-aliased path reaching the same disk collapses to one key, closing the
|
|
3604
|
+
* loophole where two aliases with different path strings would re-partition.
|
|
3605
|
+
* When realpath throws (root missing at boot) it falls back to the resolved
|
|
3606
|
+
* string — distinct roots stay distinct domains (safe: over-partitions at
|
|
3607
|
+
* worst, never merges two disks).
|
|
3608
|
+
*/
|
|
3609
|
+
locationDomain(locationId) {
|
|
3610
|
+
return resolveEvictionDomain(this.resolvedLocations, locationId);
|
|
3611
|
+
}
|
|
3612
|
+
/**
|
|
3974
3613
|
* Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
|
|
3975
3614
|
* recordings locations and rebuild the in-memory index from `storage.list`
|
|
3976
3615
|
* for every device that has a persisted config. The list result is the
|
|
@@ -3984,6 +3623,20 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3984
3623
|
this.ctx.logger.warn("recorder: no recordings storage locations resolved — index not hydrated");
|
|
3985
3624
|
return;
|
|
3986
3625
|
}
|
|
3626
|
+
try {
|
|
3627
|
+
await this.controller?.start();
|
|
3628
|
+
} catch (err) {
|
|
3629
|
+
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
|
|
3630
|
+
}
|
|
3631
|
+
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
3632
|
+
eventBus: this.ctx.eventBus,
|
|
3633
|
+
map: this.eventMap,
|
|
3634
|
+
padPreMs: MARKER_PAD_PRE_MS,
|
|
3635
|
+
padPostMs: MARKER_PAD_POST_MS,
|
|
3636
|
+
onMotion: (deviceId, atMs) => {
|
|
3637
|
+
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
3638
|
+
}
|
|
3639
|
+
});
|
|
3987
3640
|
let configs;
|
|
3988
3641
|
try {
|
|
3989
3642
|
configs = await readDeviceConfigs(this.configStore());
|
|
@@ -3996,6 +3649,23 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
3996
3649
|
deviceCount: configs.size,
|
|
3997
3650
|
locationCount: this.resolvedLocations.length
|
|
3998
3651
|
} });
|
|
3652
|
+
const loggedDomains = /* @__PURE__ */ new Set();
|
|
3653
|
+
for (const loc of this.resolvedLocations) {
|
|
3654
|
+
const domain = this.locationDomain(loc.id);
|
|
3655
|
+
const domainKey = [...domain].toSorted().join("+");
|
|
3656
|
+
if (loggedDomains.has(domainKey)) continue;
|
|
3657
|
+
loggedDomains.add(domainKey);
|
|
3658
|
+
const acct = this.index.accountingForLocations(domain);
|
|
3659
|
+
this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
|
|
3660
|
+
locationIds: [...domain].toSorted(),
|
|
3661
|
+
root: loc.root,
|
|
3662
|
+
count: acct.count,
|
|
3663
|
+
bytes: acct.bytes,
|
|
3664
|
+
oldestMs: acct.oldestMs,
|
|
3665
|
+
newestMs: acct.newestMs
|
|
3666
|
+
} });
|
|
3667
|
+
}
|
|
3668
|
+
this.cleanupLegacyThumbsDirs();
|
|
3999
3669
|
const store = this.segmentStore;
|
|
4000
3670
|
if (store) try {
|
|
4001
3671
|
await runRedundancyJanitor({
|
|
@@ -4007,25 +3677,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
4007
3677
|
} catch (err) {
|
|
4008
3678
|
this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: errMsg(err) } });
|
|
4009
3679
|
}
|
|
4010
|
-
try {
|
|
4011
|
-
await this.controller?.start();
|
|
4012
|
-
} catch (err) {
|
|
4013
|
-
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: errMsg(err) } });
|
|
4014
|
-
}
|
|
4015
|
-
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
4016
|
-
eventBus: this.ctx.eventBus,
|
|
4017
|
-
map: this.eventMap,
|
|
4018
|
-
padPreMs: MARKER_PAD_PRE_MS,
|
|
4019
|
-
padPostMs: MARKER_PAD_POST_MS,
|
|
4020
|
-
onMotion: (deviceId, atMs) => {
|
|
4021
|
-
this.controller?.onTrigger(deviceId, "motion", atMs);
|
|
4022
|
-
}
|
|
4023
|
-
});
|
|
4024
|
-
try {
|
|
4025
|
-
await this.ensureThumbnailService().start();
|
|
4026
|
-
} catch (err) {
|
|
4027
|
-
this.ctx.logger.warn("recorder: thumbnail service start failed", { meta: { error: errMsg(err) } });
|
|
4028
|
-
}
|
|
4029
3680
|
await this.bootstrapExports();
|
|
4030
3681
|
const provider = this.recordingProvider;
|
|
4031
3682
|
if (provider && this.retentionSweeper === null) this.retentionSweeper = new RetentionSweeper({
|
|
@@ -4068,31 +3719,68 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
4068
3719
|
this.exportSweepTimer.unref?.();
|
|
4069
3720
|
}
|
|
4070
3721
|
}
|
|
4071
|
-
/**
|
|
4072
|
-
*
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
}
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
3722
|
+
/**
|
|
3723
|
+
* One-time boot cleanup of the retired sprite-sheet scrub-thumbnail dirs
|
|
3724
|
+
* (`<locationRoot>/.thumbs`). Scrub previews now use the WebCodecs keyframe
|
|
3725
|
+
* packs + the on-demand still route, so the sheets are dead weight on the
|
|
3726
|
+
* recordings share. Best-effort per location — a failed rm just retries next
|
|
3727
|
+
* boot; a missing dir is a no-op.
|
|
3728
|
+
*/
|
|
3729
|
+
async cleanupLegacyThumbsDirs() {
|
|
3730
|
+
const LEGACY_THUMBS_SUBDIR = ".thumbs";
|
|
3731
|
+
for (const loc of this.resolvedLocations) {
|
|
3732
|
+
const dir = path.join(loc.root, LEGACY_THUMBS_SUBDIR);
|
|
3733
|
+
try {
|
|
3734
|
+
await promises.rm(dir, {
|
|
3735
|
+
recursive: true,
|
|
3736
|
+
force: true
|
|
3737
|
+
});
|
|
3738
|
+
this.ctx.logger.info("recorder: removed legacy scrub-thumbnail dir", { meta: { dir } });
|
|
3739
|
+
} catch (err) {
|
|
3740
|
+
this.ctx.logger.debug("recorder: legacy thumbs cleanup skipped", { meta: {
|
|
3741
|
+
dir,
|
|
3742
|
+
error: errMsg(err)
|
|
3743
|
+
} });
|
|
4093
3744
|
}
|
|
4094
|
-
}
|
|
4095
|
-
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Still request handler: `GET /<deviceId>/<epochSec>` → `image/jpeg` (the
|
|
3749
|
+
* frame nearest the instant in the LOW recording) | 204 (no footage covers
|
|
3750
|
+
* the instant / decode produced nothing) | 400 (bad path) | 500. A fully-past
|
|
3751
|
+
* segment yields an immutable-cacheable still; the still-open live edge is
|
|
3752
|
+
* `no-store`.
|
|
3753
|
+
*/
|
|
3754
|
+
async serveStill(service, req, res) {
|
|
3755
|
+
const m = /^\/(\d+)\/(\d+)$/.exec(req.url?.split("?")[0] ?? "");
|
|
3756
|
+
if (!m) {
|
|
3757
|
+
res.writeHead(400).end();
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
const deviceId = Number(m[1]);
|
|
3761
|
+
const epochMs = Number(m[2]) * 1e3;
|
|
3762
|
+
try {
|
|
3763
|
+
const built = await service.getStill(deviceId, epochMs);
|
|
3764
|
+
if (!built) {
|
|
3765
|
+
res.writeHead(204).end();
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
res.writeHead(200, {
|
|
3769
|
+
"content-type": "image/jpeg",
|
|
3770
|
+
"content-length": built.jpeg.byteLength,
|
|
3771
|
+
"cache-control": built.immutable ? "private, max-age=31536000, immutable" : "no-store"
|
|
3772
|
+
});
|
|
3773
|
+
res.end(built.jpeg);
|
|
3774
|
+
} catch (err) {
|
|
3775
|
+
this.ctx.logger.warn("still request failed", {
|
|
3776
|
+
tags: { deviceId },
|
|
3777
|
+
meta: {
|
|
3778
|
+
epochMs,
|
|
3779
|
+
error: errMsg(err)
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
res.writeHead(500).end();
|
|
3783
|
+
}
|
|
4096
3784
|
}
|
|
4097
3785
|
/** Re-resolve recordings locations (rescanStorage picks up newly-added disks). */
|
|
4098
3786
|
async refreshLocations() {
|
|
@@ -4169,24 +3857,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
4169
3857
|
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.",
|
|
4170
3858
|
default: ""
|
|
4171
3859
|
}]
|
|
4172
|
-
}, {
|
|
4173
|
-
id: "scrub-thumbnails",
|
|
4174
|
-
title: "Scrub thumbnails",
|
|
4175
|
-
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).",
|
|
4176
|
-
columns: 1,
|
|
4177
|
-
fields: [{
|
|
4178
|
-
type: "boolean",
|
|
4179
|
-
key: "thumbnailsEnabled",
|
|
4180
|
-
label: "Serve scrub thumbnails",
|
|
4181
|
-
description: "Accumulate + serve sprite sheets. Disable to stop packing/serving thumbnails.",
|
|
4182
|
-
default: true
|
|
4183
|
-
}, {
|
|
4184
|
-
type: "boolean",
|
|
4185
|
-
key: "thumbnailBackfillEnabled",
|
|
4186
|
-
label: "Keyframe backfill",
|
|
4187
|
-
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).",
|
|
4188
|
-
default: true
|
|
4189
|
-
}]
|
|
4190
3860
|
}] });
|
|
4191
3861
|
}
|
|
4192
3862
|
/** Durable handle config-store reads/writes the deviceId→config blob on —
|
|
@@ -4231,6 +3901,58 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
4231
3901
|
});
|
|
4232
3902
|
}
|
|
4233
3903
|
/**
|
|
3904
|
+
* Absolute on-disk path for a scrub segment row. Mirrors the playback path
|
|
3905
|
+
* resolution exactly: the file playback serves for `row.path` lives at
|
|
3906
|
+
* `<root for row.locationId>/<row.path>` (same `rootById` lookup the playback
|
|
3907
|
+
* manifest uses). The scrub packs read the SAME source segments as playback.
|
|
3908
|
+
*/
|
|
3909
|
+
resolveSegmentAbsPath(row) {
|
|
3910
|
+
const loc = this.resolvedLocations.find((l) => l.id === row.locationId);
|
|
3911
|
+
if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
|
|
3912
|
+
return path.join(loc.root, row.path);
|
|
3913
|
+
}
|
|
3914
|
+
/**
|
|
3915
|
+
* Scrub-pack request handler: `GET /<deviceId>/<windowStartSec>` →
|
|
3916
|
+
* `application/octet-stream` pack | 204 (no footage) | 400 (bad path / window
|
|
3917
|
+
* not aligned to `SCRUB_WINDOW_MS`) | 500. Immutable (fully past) windows carry
|
|
3918
|
+
* a 1-year immutable cache header; the still-open current window is `no-store`.
|
|
3919
|
+
*/
|
|
3920
|
+
async serveScrubPack(service, req, res) {
|
|
3921
|
+
const m = /^\/(\d+)\/(\d+)$/.exec(req.url?.split("?")[0] ?? "");
|
|
3922
|
+
if (!m) {
|
|
3923
|
+
res.writeHead(400).end();
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
const deviceId = Number(m[1]);
|
|
3927
|
+
const windowStartMs = Number(m[2]) * 1e3;
|
|
3928
|
+
if (windowStartMs % 6e4 !== 0) {
|
|
3929
|
+
res.writeHead(400).end();
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3932
|
+
try {
|
|
3933
|
+
const built = await service.getOrBuild(deviceId, windowStartMs);
|
|
3934
|
+
if (!built) {
|
|
3935
|
+
res.writeHead(204).end();
|
|
3936
|
+
return;
|
|
3937
|
+
}
|
|
3938
|
+
res.writeHead(200, {
|
|
3939
|
+
"content-type": "application/octet-stream",
|
|
3940
|
+
"content-length": built.pack.byteLength,
|
|
3941
|
+
"cache-control": built.immutable ? "private, max-age=31536000, immutable" : "no-store"
|
|
3942
|
+
});
|
|
3943
|
+
res.end(Buffer.from(built.pack));
|
|
3944
|
+
} catch (err) {
|
|
3945
|
+
this.ctx.logger.warn("scrub pack request failed", {
|
|
3946
|
+
tags: { deviceId },
|
|
3947
|
+
meta: {
|
|
3948
|
+
windowStartMs,
|
|
3949
|
+
error: errMsg(err)
|
|
3950
|
+
}
|
|
3951
|
+
});
|
|
3952
|
+
res.writeHead(500).end();
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
4234
3956
|
* Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
|
|
4235
3957
|
* row's file (traversal-guarded by the id regex + the exact stored path),
|
|
4236
3958
|
* streams it with HTTP `Range`, and — when the FINAL byte of a full-file GET is
|