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