@camstack/addon-pipeline 1.2.38 → 1.2.39
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 +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +145 -175
- package/dist/detection-pipeline/index.mjs +120 -150
- package/dist/{dist-BvFLZyhW.js → dist-BtAvtroH.js} +260 -108
- package/dist/{dist-Du7_SNiR.mjs → dist-COgGdFMv.mjs} +260 -108
- package/dist/{step-definitions-C8tk9fPJ.mjs → event-loop-stall-monitor-naFYvcOO.mjs} +139 -2
- package/dist/{step-definitions-i8O-hxyI.js → event-loop-stall-monitor-shfwi1_b.js} +150 -1
- package/dist/lazy-sharp-B-mb8uWx.mjs +20 -0
- package/dist/lazy-sharp-CcYuXtsa.js +25 -0
- package/dist/motion-wasm/index.js +3 -3
- package/dist/motion-wasm/index.mjs +3 -3
- package/dist/pipeline-runner/index.js +3316 -2428
- package/dist/pipeline-runner/index.mjs +3315 -2428
- package/dist/recorder/index.js +102 -664
- package/dist/recorder/index.mjs +102 -658
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ByuMjhL3.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CjrKd4xX.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B59GNAJq.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-83qNzMSL.mjs +26 -0
- package/dist/stream-broker/{hostInit-CfC64ye2.mjs → hostInit-BFH17kSk.mjs} +2 -2
- package/dist/stream-broker/index.js +717 -211
- package/dist/stream-broker/index.mjs +717 -211
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Dx-Vrpyt.js → MaskShapeCanvas-DI4BY7W2-yeSHfpUy.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-9D-BWwQP.js → MotionZonesSettings-NcxxQN8r-Df4WxM6N.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-HvAetfA4.js → PrivacyMaskSettings-APgPLF7p-COHSiTGq.js} +1 -1
- package/embed-dist/assets/index-4fJzjj4H.css +2 -0
- package/embed-dist/assets/index-Ct9M2v5n.js +112 -0
- package/embed-dist/index.html +2 -2
- package/package.json +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ByHi7ZAe.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CscpvIRR.mjs +0 -26
- package/embed-dist/assets/index-9Ob2YrIf.js +0 -343
- package/embed-dist/assets/index-CAe6Dg-X.css +0 -2
- package/python/__pycache__/inference_pool.cpython-312.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-313.pyc +0 -0
- package/python/__pycache__/inference_pool.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_backpressure.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_coreml_cache.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_device_selection.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_layout.cpython-314.pyc +0 -0
- package/python/__pycache__/test_inference_pool_preprocess.cpython-314.pyc +0 -0
- package/python/__pycache__/yamnet_audio.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/__init__.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/_safety.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/arcface.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/clip.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ctc.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/saliency.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/scrfd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/softmax.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/ssd.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_clip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_ctc.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_saliency.cpython-313-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_softmax.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-312-pytest-9.1.1.pyc +0 -0
- package/python/postprocessors/__pycache__/test_yolo_nms.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yamnet.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-314.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo.cpython-39.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-312.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-313.pyc +0 -0
- package/python/postprocessors/__pycache__/yolo_seg.cpython-314.pyc +0 -0
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as selectAssignedProfileSlots, B as errMsg, C as deriveRecordingMode, I as storageEvictableCapability, K as hydrateSchema, N as recordingCapability, P as recordingExportCapability,
|
|
1
|
+
import { $ as selectAssignedProfileSlots, B as errMsg, C as deriveRecordingMode, I as storageEvictableCapability, K as hydrateSchema, N as recordingCapability, P as recordingExportCapability, V as BaseAddon, W as DeviceType, X as nodePin, c as EVENT_PAD_MS, f as OpsLogEntrySchema, ft as EventCategory, lt as record, nt as array, p as RecordingConfigSchema, u as ExportRecordSchema, ut as string } from "../dist-COgGdFMv.mjs";
|
|
2
2
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
3
3
|
import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
@@ -86,8 +86,9 @@ async function availabilityProfile(root, deviceId) {
|
|
|
86
86
|
}
|
|
87
87
|
/** The canonical recording profiles — the ONLY `<deviceId>/` subdirectories
|
|
88
88
|
* that hold segments. The camera dir also hosts per-camera artifacts that are
|
|
89
|
-
* NOT profiles (`
|
|
90
|
-
* offer them as playable
|
|
89
|
+
* NOT profiles (`events/`, plus the `strips/` trees the retired filmstrip tier
|
|
90
|
+
* left on disk); a walker that trusts readdir would offer them as playable
|
|
91
|
+
* profiles. */
|
|
91
92
|
var KNOWN_PROFILES = new Set([
|
|
92
93
|
"high",
|
|
93
94
|
"mid",
|
|
@@ -221,7 +222,7 @@ function mergeRanges(segments, gapToleranceMs) {
|
|
|
221
222
|
//#endregion
|
|
222
223
|
//#region src/recorder/addon/calendar-index.ts
|
|
223
224
|
var DAY_MS$3 = 864e5;
|
|
224
|
-
var HOUR_MS$
|
|
225
|
+
var HOUR_MS$3 = 36e5;
|
|
225
226
|
/**
|
|
226
227
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
227
228
|
*
|
|
@@ -394,7 +395,7 @@ var CalendarIndex = class {
|
|
|
394
395
|
}));
|
|
395
396
|
const days = /* @__PURE__ */ new Set();
|
|
396
397
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
397
|
-
const at = day + h * HOUR_MS$
|
|
398
|
+
const at = day + h * HOUR_MS$3;
|
|
398
399
|
if (at < fromMs || at >= toMs) continue;
|
|
399
400
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
400
401
|
}
|
|
@@ -456,7 +457,7 @@ var CalendarIndex = class {
|
|
|
456
457
|
const day = utcDayOf(utcDayMs);
|
|
457
458
|
for (let h = 0; h < 24; h++) {
|
|
458
459
|
await this.awaitQuiet();
|
|
459
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
460
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$3, true);
|
|
460
461
|
}
|
|
461
462
|
}
|
|
462
463
|
/**
|
|
@@ -477,10 +478,10 @@ var CalendarIndex = class {
|
|
|
477
478
|
*/
|
|
478
479
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
479
480
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
480
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
481
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$3) * HOUR_MS$3;
|
|
481
482
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
482
483
|
const now = this.deps.now?.() ?? Date.now();
|
|
483
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
484
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$3) * HOUR_MS$3;
|
|
484
485
|
const cached = this.hourSegs.get(key);
|
|
485
486
|
if (cached !== void 0) {
|
|
486
487
|
if (!isCurrentHour) return {
|
|
@@ -544,7 +545,7 @@ var CalendarIndex = class {
|
|
|
544
545
|
*/
|
|
545
546
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
546
547
|
const day = utcDayOf(utcDayMs);
|
|
547
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
548
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$3), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
548
549
|
}
|
|
549
550
|
/**
|
|
550
551
|
* Warm the days either side of the one being viewed, in the BACKGROUND.
|
|
@@ -628,7 +629,7 @@ var PLAYBACK_PLANE_PATH = "/addon/recorder/playback";
|
|
|
628
629
|
* deployed client ever sees a version it cannot read. */
|
|
629
630
|
var VERSION = 2;
|
|
630
631
|
/** LEB128, unsigned. Every value here is a non-negative integer. */
|
|
631
|
-
function writeVarint
|
|
632
|
+
function writeVarint(out, value) {
|
|
632
633
|
let v = Math.max(0, Math.trunc(value));
|
|
633
634
|
while (v >= 128) {
|
|
634
635
|
out.push(v & 127 | 128);
|
|
@@ -649,29 +650,29 @@ function encodeDirectory(payload) {
|
|
|
649
650
|
out.push(VERSION);
|
|
650
651
|
out.push(payload.hydrated ? 1 : 0);
|
|
651
652
|
const profile = new TextEncoder().encode(payload.profile);
|
|
652
|
-
writeVarint
|
|
653
|
+
writeVarint(out, profile.length);
|
|
653
654
|
for (const b of profile) out.push(b);
|
|
654
|
-
writeVarint
|
|
655
|
-
writeVarint
|
|
655
|
+
writeVarint(out, payload.asOfMs);
|
|
656
|
+
writeVarint(out, payload.rows.length);
|
|
656
657
|
let prevStart = 0;
|
|
657
658
|
for (const r of payload.rows) {
|
|
658
|
-
writeVarint
|
|
659
|
-
writeVarint
|
|
660
|
-
writeVarint
|
|
659
|
+
writeVarint(out, r.startMs - prevStart);
|
|
660
|
+
writeVarint(out, r.durMs);
|
|
661
|
+
writeVarint(out, r.bytes);
|
|
661
662
|
prevStart = r.startMs;
|
|
662
663
|
const mfra = r.mfra;
|
|
663
664
|
if (mfra === void 0 || mfra.samples.length === 0) {
|
|
664
|
-
writeVarint
|
|
665
|
+
writeVarint(out, 0);
|
|
665
666
|
continue;
|
|
666
667
|
}
|
|
667
|
-
writeVarint
|
|
668
|
-
writeVarint
|
|
669
|
-
writeVarint
|
|
668
|
+
writeVarint(out, mfra.samples.length);
|
|
669
|
+
writeVarint(out, mfra.trackId);
|
|
670
|
+
writeVarint(out, mfra.mfraOffset);
|
|
670
671
|
let prevTicks = 0;
|
|
671
672
|
let prevMoof = 0;
|
|
672
673
|
for (const s of mfra.samples) {
|
|
673
|
-
writeVarint
|
|
674
|
-
writeVarint
|
|
674
|
+
writeVarint(out, s.timeTicks - prevTicks);
|
|
675
|
+
writeVarint(out, s.moofOffset - prevMoof);
|
|
675
676
|
prevTicks = s.timeTicks;
|
|
676
677
|
prevMoof = s.moofOffset;
|
|
677
678
|
}
|
|
@@ -680,7 +681,7 @@ function encodeDirectory(payload) {
|
|
|
680
681
|
}
|
|
681
682
|
//#endregion
|
|
682
683
|
//#region src/recorder/addon/directory-handler.ts
|
|
683
|
-
var HOUR_MS$
|
|
684
|
+
var HOUR_MS$2 = 36e5;
|
|
684
685
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
685
686
|
* would walk the archive on the read path — the thing this replaces. */
|
|
686
687
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -727,9 +728,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
727
728
|
return;
|
|
728
729
|
}
|
|
729
730
|
const rows = [];
|
|
730
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
731
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
|
|
731
732
|
let hydrated = true;
|
|
732
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
733
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$2) {
|
|
733
734
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
734
735
|
if (!complete) hydrated = false;
|
|
735
736
|
for (const s of segments) {
|
|
@@ -989,12 +990,9 @@ async function readMfraTable(absPath, fileBytes) {
|
|
|
989
990
|
try {
|
|
990
991
|
const idx = parseMfra(tail, fileBytes, info === null ? {} : { trackId: info.videoTrackId });
|
|
991
992
|
return {
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
samples: idx.samples
|
|
996
|
-
},
|
|
997
|
-
videoTimescale: info?.videoTimescale ?? null
|
|
993
|
+
trackId: idx.trackId,
|
|
994
|
+
mfraOffset: idx.mfraOffset,
|
|
995
|
+
samples: idx.samples
|
|
998
996
|
};
|
|
999
997
|
} catch (err) {
|
|
1000
998
|
if (err instanceof MfraParseError && err.needBytes && attempt === 0) {
|
|
@@ -1008,20 +1006,6 @@ async function readMfraTable(absPath, fileBytes) {
|
|
|
1008
1006
|
await fh.close();
|
|
1009
1007
|
}
|
|
1010
1008
|
}
|
|
1011
|
-
/**
|
|
1012
|
-
* Wall-clock instants for a segment's keyframes: `-skip_frame nokey` emits
|
|
1013
|
-
* them in decode order, so frame i IS sync sample i. Without a timescale the
|
|
1014
|
-
* samples are spread evenly — a degraded position beats a missing thumbnail.
|
|
1015
|
-
*/
|
|
1016
|
-
function keyframeInstants(startMs, durMs, captured) {
|
|
1017
|
-
const { samples } = captured.table;
|
|
1018
|
-
const first = samples[0]?.timeTicks ?? 0;
|
|
1019
|
-
const ts = captured.videoTimescale;
|
|
1020
|
-
return samples.map((s, i) => {
|
|
1021
|
-
if (ts !== null && ts > 0) return Math.round(startMs + (s.timeTicks - first) / ts * 1e3);
|
|
1022
|
-
return Math.round(startMs + i / samples.length * durMs);
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
1009
|
/** How long a table stays served. Directory asks are for recent footage far
|
|
1026
1010
|
* more often than not, and a bounded window keeps this store O(day), not
|
|
1027
1011
|
* O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
|
|
@@ -1070,466 +1054,6 @@ var MfraTableStore = class {
|
|
|
1070
1054
|
}
|
|
1071
1055
|
};
|
|
1072
1056
|
//#endregion
|
|
1073
|
-
//#region src/recorder/strip-format.ts
|
|
1074
|
-
/**
|
|
1075
|
-
* The thumbnail STRIP — the fast-drag depth of the scrub ladder.
|
|
1076
|
-
*
|
|
1077
|
-
* One append-only file per camera-hour holds every keyframe of the LOW
|
|
1078
|
-
* recording as a JPEG at the low profile's NATIVE resolution. A fast drag
|
|
1079
|
-
* crosses an hour in seconds and wants ~20 scattered stills; without the strip
|
|
1080
|
-
* that is 20 keyframe reads in 20 different segments under latest-wins —
|
|
1081
|
-
* structurally incapable of painting a flow. With it, the positions a drag
|
|
1082
|
-
* crosses are a handful of ~25–40 KB ranged reads from ONE file, painted at
|
|
1083
|
-
* the capped cadence.
|
|
1084
|
-
*
|
|
1085
|
-
* TWO wire shapes live here, both halves in one module so they cannot drift:
|
|
1086
|
-
*
|
|
1087
|
-
* - the STRIP FILE (`CSS1`): [magic][version] then records of
|
|
1088
|
-
* [u32 jpegLen][u64 tsMs][jpeg]. Self-describing, so the index is
|
|
1089
|
-
* derivable from the file alone after a restart.
|
|
1090
|
-
* - the STRIP INDEX (`CSI1`): the varint answer to "what instants does this
|
|
1091
|
-
* hour hold, and which bytes paint each one" — served from RAM, consumed by
|
|
1092
|
-
* the client to turn a cursor position into one ranged read.
|
|
1093
|
-
*/
|
|
1094
|
-
/** 'CSS1' — camstack strip, v1. */
|
|
1095
|
-
var FILE_MAGIC = new Uint8Array([
|
|
1096
|
-
67,
|
|
1097
|
-
83,
|
|
1098
|
-
83,
|
|
1099
|
-
49
|
|
1100
|
-
]);
|
|
1101
|
-
var FILE_VERSION = 1;
|
|
1102
|
-
var STRIP_FILE_HEADER_BYTES = FILE_MAGIC.length + 1;
|
|
1103
|
-
function stripFileHeader() {
|
|
1104
|
-
const out = new Uint8Array(STRIP_FILE_HEADER_BYTES);
|
|
1105
|
-
out.set(FILE_MAGIC, 0);
|
|
1106
|
-
out[FILE_MAGIC.length] = FILE_VERSION;
|
|
1107
|
-
return out;
|
|
1108
|
-
}
|
|
1109
|
-
function encodeStripRecord(tsMs, jpeg) {
|
|
1110
|
-
const out = new Uint8Array(12 + jpeg.length);
|
|
1111
|
-
const view = new DataView(out.buffer);
|
|
1112
|
-
view.setUint32(0, jpeg.length);
|
|
1113
|
-
view.setBigUint64(4, BigInt(Math.trunc(tsMs)));
|
|
1114
|
-
out.set(jpeg, 12);
|
|
1115
|
-
return out;
|
|
1116
|
-
}
|
|
1117
|
-
/**
|
|
1118
|
-
* Walk a strip file's bytes into its entry list. Tolerates a TRUNCATED final
|
|
1119
|
-
* record (a crash mid-append) by stopping at it — everything before is intact
|
|
1120
|
-
* because records are append-only.
|
|
1121
|
-
*/
|
|
1122
|
-
function parseStripEntries(bytes) {
|
|
1123
|
-
if (bytes.length < STRIP_FILE_HEADER_BYTES) return [];
|
|
1124
|
-
for (let i = 0; i < FILE_MAGIC.length; i++) if (bytes[i] !== FILE_MAGIC[i]) throw new Error("CSS1: bad magic");
|
|
1125
|
-
if (bytes[FILE_MAGIC.length] !== FILE_VERSION) throw new Error(`CSS1: unsupported version ${String(bytes[FILE_MAGIC.length])}`);
|
|
1126
|
-
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1127
|
-
const entries = [];
|
|
1128
|
-
let p = STRIP_FILE_HEADER_BYTES;
|
|
1129
|
-
while (p + 12 <= bytes.length) {
|
|
1130
|
-
const len = view.getUint32(p);
|
|
1131
|
-
const tsMs = Number(view.getBigUint64(p + 4));
|
|
1132
|
-
const offset = p + 12;
|
|
1133
|
-
if (offset + len > bytes.length) break;
|
|
1134
|
-
entries.push({
|
|
1135
|
-
tsMs,
|
|
1136
|
-
offset,
|
|
1137
|
-
bytes: len
|
|
1138
|
-
});
|
|
1139
|
-
p = offset + len;
|
|
1140
|
-
}
|
|
1141
|
-
return entries;
|
|
1142
|
-
}
|
|
1143
|
-
var INDEX_VERSION = 1;
|
|
1144
|
-
function writeVarint(out, value) {
|
|
1145
|
-
let v = Math.max(0, Math.trunc(value));
|
|
1146
|
-
while (v >= 128) {
|
|
1147
|
-
out.push(v & 127 | 128);
|
|
1148
|
-
v = Math.floor(v / 128);
|
|
1149
|
-
}
|
|
1150
|
-
out.push(v);
|
|
1151
|
-
}
|
|
1152
|
-
/** Entries MUST be ascending by tsMs and offset — append order guarantees it. */
|
|
1153
|
-
function encodeStripIndex(entries) {
|
|
1154
|
-
const out = [];
|
|
1155
|
-
out.push(67, 83, 73, 49);
|
|
1156
|
-
out.push(INDEX_VERSION);
|
|
1157
|
-
writeVarint(out, entries.length);
|
|
1158
|
-
let prevTs = 0;
|
|
1159
|
-
let prevOffset = 0;
|
|
1160
|
-
for (const e of entries) {
|
|
1161
|
-
writeVarint(out, e.tsMs - prevTs);
|
|
1162
|
-
writeVarint(out, e.offset - prevOffset);
|
|
1163
|
-
writeVarint(out, e.bytes);
|
|
1164
|
-
prevTs = e.tsMs;
|
|
1165
|
-
prevOffset = e.offset;
|
|
1166
|
-
}
|
|
1167
|
-
return Uint8Array.from(out);
|
|
1168
|
-
}
|
|
1169
|
-
//#endregion
|
|
1170
|
-
//#region src/recorder/strip-store.ts
|
|
1171
|
-
/**
|
|
1172
|
-
* The strip files on disk: append at finalize, index on demand.
|
|
1173
|
-
*
|
|
1174
|
-
* Layout: `<location root>/<deviceId>/strips/YYYY/MM/DD/HH.strip` — rooted
|
|
1175
|
-
* under the CAMERA directory like every other per-camera artifact (segments,
|
|
1176
|
-
* event media). The profile walkers (`profilesOf`, `hydrateWindowFromStorage`)
|
|
1177
|
-
* whitelist the canonical profile names, so `strips/` (and `events/`) sitting
|
|
1178
|
-
* next to `high|mid|low/` is never offered as a playable profile.
|
|
1179
|
-
*
|
|
1180
|
-
* Appends are serialized per file (a per-key promise chain): two segments of
|
|
1181
|
-
* the same hour finalizing close together must not interleave their records.
|
|
1182
|
-
*/
|
|
1183
|
-
var HOUR_MS$2 = 36e5;
|
|
1184
|
-
/** Location-relative path of one camera-hour's strip. */
|
|
1185
|
-
function stripRelPath(deviceId, hourStartMs) {
|
|
1186
|
-
const d = /* @__PURE__ */ new Date(Math.floor(hourStartMs / HOUR_MS$2) * HOUR_MS$2);
|
|
1187
|
-
const p2 = (n) => String(n).padStart(2, "0");
|
|
1188
|
-
return path.join(String(deviceId), "strips", String(d.getUTCFullYear()), p2(d.getUTCMonth() + 1), p2(d.getUTCDate()), `${p2(d.getUTCHours())}.strip`);
|
|
1189
|
-
}
|
|
1190
|
-
var StripStore = class {
|
|
1191
|
-
rootFor;
|
|
1192
|
-
/** Per-file append chain — see the header comment. */
|
|
1193
|
-
appending = /* @__PURE__ */ new Map();
|
|
1194
|
-
/** Parsed entries per file, invalidated when the SIZE moves (append-only
|
|
1195
|
-
* files: same size ⟹ same content). */
|
|
1196
|
-
indexCache = /* @__PURE__ */ new Map();
|
|
1197
|
-
constructor(rootFor) {
|
|
1198
|
-
this.rootFor = rootFor;
|
|
1199
|
-
}
|
|
1200
|
-
/** Absolute path for a camera-hour, or null before locations resolve. */
|
|
1201
|
-
absPathFor(deviceId, hourStartMs) {
|
|
1202
|
-
const root = this.rootFor();
|
|
1203
|
-
if (root === null) return null;
|
|
1204
|
-
return path.join(root, stripRelPath(deviceId, hourStartMs));
|
|
1205
|
-
}
|
|
1206
|
-
/** Append thumbnails (one segment's keyframes — same hour by construction). */
|
|
1207
|
-
async append(deviceId, thumbs) {
|
|
1208
|
-
const first = thumbs[0];
|
|
1209
|
-
if (first === void 0) return;
|
|
1210
|
-
const abs = this.absPathFor(deviceId, first.tsMs);
|
|
1211
|
-
if (abs === null) return;
|
|
1212
|
-
const run = (this.appending.get(abs) ?? Promise.resolve()).then(async () => {
|
|
1213
|
-
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
1214
|
-
let created = false;
|
|
1215
|
-
try {
|
|
1216
|
-
await promises.access(abs);
|
|
1217
|
-
} catch {
|
|
1218
|
-
created = true;
|
|
1219
|
-
}
|
|
1220
|
-
const parts = created ? [stripFileHeader()] : [];
|
|
1221
|
-
for (const t of thumbs) parts.push(encodeStripRecord(t.tsMs, t.jpeg));
|
|
1222
|
-
await promises.appendFile(abs, Buffer.concat(parts.map((p) => Buffer.from(p))));
|
|
1223
|
-
this.indexCache.delete(abs);
|
|
1224
|
-
});
|
|
1225
|
-
this.appending.set(abs, run.catch(() => {}));
|
|
1226
|
-
try {
|
|
1227
|
-
await run;
|
|
1228
|
-
} finally {
|
|
1229
|
-
if (this.appending.get(abs) === run) this.appending.delete(abs);
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* The entry list for a camera-hour, or null when the hour has no strip.
|
|
1234
|
-
* Cached by file size — append-only files cannot change under a same-size
|
|
1235
|
-
* cache.
|
|
1236
|
-
*/
|
|
1237
|
-
async index(deviceId, hourStartMs) {
|
|
1238
|
-
const abs = this.absPathFor(deviceId, hourStartMs);
|
|
1239
|
-
if (abs === null) return null;
|
|
1240
|
-
let size;
|
|
1241
|
-
try {
|
|
1242
|
-
size = (await promises.stat(abs)).size;
|
|
1243
|
-
} catch {
|
|
1244
|
-
return null;
|
|
1245
|
-
}
|
|
1246
|
-
const cached = this.indexCache.get(abs);
|
|
1247
|
-
if (cached && cached.size === size) return cached.entries;
|
|
1248
|
-
const entries = parseStripEntries(new Uint8Array(await promises.readFile(abs)));
|
|
1249
|
-
this.indexCache.set(abs, {
|
|
1250
|
-
size,
|
|
1251
|
-
entries
|
|
1252
|
-
});
|
|
1253
|
-
return entries;
|
|
1254
|
-
}
|
|
1255
|
-
/** A regeneration clears the device's whole strip tree first. */
|
|
1256
|
-
async removeDevice(deviceId) {
|
|
1257
|
-
const root = this.rootFor();
|
|
1258
|
-
if (root === null) return;
|
|
1259
|
-
for (const key of [...this.indexCache.keys()]) if (key.includes(path.join(String(deviceId), "strips") + path.sep)) this.indexCache.delete(key);
|
|
1260
|
-
await promises.rm(path.join(root, String(deviceId), "strips"), {
|
|
1261
|
-
recursive: true,
|
|
1262
|
-
force: true
|
|
1263
|
-
}).catch(() => {});
|
|
1264
|
-
}
|
|
1265
|
-
/** Eviction removed an hour's footage — its strip goes with it. */
|
|
1266
|
-
async removeHour(deviceId, hourStartMs) {
|
|
1267
|
-
const abs = this.absPathFor(deviceId, hourStartMs);
|
|
1268
|
-
if (abs === null) return;
|
|
1269
|
-
this.indexCache.delete(abs);
|
|
1270
|
-
await promises.rm(abs, { force: true }).catch(() => {});
|
|
1271
|
-
}
|
|
1272
|
-
};
|
|
1273
|
-
//#endregion
|
|
1274
|
-
//#region src/recorder/strip-capture.ts
|
|
1275
|
-
/**
|
|
1276
|
-
* Keyframe thumbnails from a JUST-FINALIZED low segment — one short-lived
|
|
1277
|
-
* ffmpeg per segment, `-skip_frame nokey`, so only the 2–4 keyframes are ever
|
|
1278
|
-
* decoded (~15 ms of CPU on a 360p segment still sitting in page cache).
|
|
1279
|
-
*
|
|
1280
|
-
* WHY NOT A SECOND OUTPUT ON THE RECORDER'S FFMPEG
|
|
1281
|
-
* ────────────────────────────────────────────────
|
|
1282
|
-
* The write path is the one process that must never be destabilised, and an
|
|
1283
|
-
* image pipe has no per-frame PTS — pairing frames to instants would need NUT
|
|
1284
|
-
* parsing or stderr scraping. At finalize the mfra capture has ALREADY read
|
|
1285
|
-
* the sync-sample table: `-skip_frame nokey` emits keyframes in decode order,
|
|
1286
|
-
* so frame i IS sample i and the timestamps come for free. A failed capture
|
|
1287
|
-
* costs one hour's drag a thumbnail, nothing else.
|
|
1288
|
-
*
|
|
1289
|
-
* Bounded: at most `MAX_CONCURRENT` ffmpegs, and a backlog beyond
|
|
1290
|
-
* `MAX_QUEUED` drops the OLDEST waiting job — the strip is a best-effort
|
|
1291
|
-
* accelerator, and a stampede of finalizes (20 cameras, one segment cadence)
|
|
1292
|
-
* must never queue decode work behind the array.
|
|
1293
|
-
*/
|
|
1294
|
-
var MAX_CONCURRENT = 2;
|
|
1295
|
-
var MAX_QUEUED = 32;
|
|
1296
|
-
/** A one-shot decode of 2–4 keyframes must never wedge a slot. */
|
|
1297
|
-
var TIMEOUT_MS = 15e3;
|
|
1298
|
-
/** Split an MJPEG byte stream into its JPEGs on SOI markers (FFD8 FFE0/DB…).
|
|
1299
|
-
* ffmpeg's image2pipe emits baseline JPEGs back to back; scanning for the
|
|
1300
|
-
* next SOI right after an EOI is unambiguous for its output. */
|
|
1301
|
-
function splitJpegs(stream) {
|
|
1302
|
-
const out = [];
|
|
1303
|
-
let start = -1;
|
|
1304
|
-
for (let i = 0; i + 1 < stream.length; i++) if (stream[i] === 255 && stream[i + 1] === 216 && (start === -1 || wasEoi(stream, i))) {
|
|
1305
|
-
if (start !== -1) out.push(stream.subarray(start, i));
|
|
1306
|
-
start = i;
|
|
1307
|
-
}
|
|
1308
|
-
if (start !== -1) out.push(stream.subarray(start));
|
|
1309
|
-
return out;
|
|
1310
|
-
}
|
|
1311
|
-
/** Whether the two bytes immediately before `i` are an EOI — the only place a
|
|
1312
|
-
* new SOI can legitimately begin inside a concatenated stream. */
|
|
1313
|
-
function wasEoi(stream, i) {
|
|
1314
|
-
return i >= 2 && stream[i - 2] === 255 && stream[i - 1] === 217;
|
|
1315
|
-
}
|
|
1316
|
-
var StripCapture = class {
|
|
1317
|
-
deps;
|
|
1318
|
-
queue = [];
|
|
1319
|
-
running = 0;
|
|
1320
|
-
constructor(deps = {}) {
|
|
1321
|
-
this.deps = deps;
|
|
1322
|
-
}
|
|
1323
|
-
/**
|
|
1324
|
-
* The segment's keyframe JPEGs in decode order, or null when the capture was
|
|
1325
|
-
* dropped (backlog) or failed. Never throws — the strip is best-effort.
|
|
1326
|
-
*/
|
|
1327
|
-
capture(absPath) {
|
|
1328
|
-
return new Promise((resolve) => {
|
|
1329
|
-
this.queue.push({
|
|
1330
|
-
absPath,
|
|
1331
|
-
resolve
|
|
1332
|
-
});
|
|
1333
|
-
while (this.queue.length > MAX_QUEUED) {
|
|
1334
|
-
const dropped = this.queue.shift();
|
|
1335
|
-
dropped?.resolve(null);
|
|
1336
|
-
this.deps.onLog?.("strip:dropped", { path: dropped?.absPath ?? "" });
|
|
1337
|
-
}
|
|
1338
|
-
this.pump();
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
pump() {
|
|
1342
|
-
while (this.running < MAX_CONCURRENT) {
|
|
1343
|
-
const job = this.queue.shift();
|
|
1344
|
-
if (job === void 0) return;
|
|
1345
|
-
this.running++;
|
|
1346
|
-
(this.deps.runFfmpeg ?? this.runFfmpeg)(job.absPath).then((stream) => job.resolve(splitJpegs(stream))).catch((err) => {
|
|
1347
|
-
this.deps.onLog?.("strip:capture-failed", {
|
|
1348
|
-
path: job.absPath,
|
|
1349
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1350
|
-
});
|
|
1351
|
-
job.resolve(null);
|
|
1352
|
-
}).finally(() => {
|
|
1353
|
-
this.running--;
|
|
1354
|
-
this.pump();
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
runFfmpeg = (absPath) => {
|
|
1359
|
-
return new Promise((resolve, reject) => {
|
|
1360
|
-
const child = spawn(this.deps.ffmpegPath ?? "ffmpeg", [
|
|
1361
|
-
"-hide_banner",
|
|
1362
|
-
"-loglevel",
|
|
1363
|
-
"error",
|
|
1364
|
-
"-skip_frame",
|
|
1365
|
-
"nokey",
|
|
1366
|
-
"-i",
|
|
1367
|
-
absPath,
|
|
1368
|
-
"-c:v",
|
|
1369
|
-
"mjpeg",
|
|
1370
|
-
"-q:v",
|
|
1371
|
-
"7",
|
|
1372
|
-
"-f",
|
|
1373
|
-
"image2pipe",
|
|
1374
|
-
"-"
|
|
1375
|
-
], { stdio: [
|
|
1376
|
-
"ignore",
|
|
1377
|
-
"pipe",
|
|
1378
|
-
"pipe"
|
|
1379
|
-
] });
|
|
1380
|
-
const chunks = [];
|
|
1381
|
-
const errChunks = [];
|
|
1382
|
-
const timer = setTimeout(() => {
|
|
1383
|
-
child.kill("SIGKILL");
|
|
1384
|
-
reject(/* @__PURE__ */ new Error(`strip capture timed out after ${TIMEOUT_MS}ms`));
|
|
1385
|
-
}, TIMEOUT_MS);
|
|
1386
|
-
timer.unref?.();
|
|
1387
|
-
child.stdout.on("data", (c) => chunks.push(c));
|
|
1388
|
-
child.stderr.on("data", (c) => errChunks.push(c));
|
|
1389
|
-
child.on("error", (err) => {
|
|
1390
|
-
clearTimeout(timer);
|
|
1391
|
-
reject(err);
|
|
1392
|
-
});
|
|
1393
|
-
child.on("close", (code) => {
|
|
1394
|
-
clearTimeout(timer);
|
|
1395
|
-
if (code === 0) resolve(new Uint8Array(Buffer.concat(chunks)));
|
|
1396
|
-
else reject(/* @__PURE__ */ new Error(`ffmpeg exited ${String(code)}: ${Buffer.concat(errChunks).toString("utf8").slice(0, 300)}`));
|
|
1397
|
-
});
|
|
1398
|
-
});
|
|
1399
|
-
};
|
|
1400
|
-
};
|
|
1401
|
-
//#endregion
|
|
1402
|
-
//#region src/recorder/addon/strip-routes.ts
|
|
1403
|
-
/**
|
|
1404
|
-
* The strip's two questions, answered on the playback plane:
|
|
1405
|
-
*
|
|
1406
|
-
* `GET /<deviceId>/strip-index?hour=<hourStartMs>` → `CSI1` (what instants
|
|
1407
|
-
* the hour holds, and which bytes paint each one)
|
|
1408
|
-
* `GET /<deviceId>/strip?hour=<hourStartMs>` + `Range` → the strip file's
|
|
1409
|
-
* bytes (the client asks for exactly the records it needs)
|
|
1410
|
-
*
|
|
1411
|
-
* Same prefix, same auth, same fetch seam as the segments and the directory —
|
|
1412
|
-
* one plane, one credential path. The strip files live under `<deviceId>/strips/`
|
|
1413
|
-
* (the per-camera layout; the profile walkers whitelist high|mid|low so the
|
|
1414
|
-
* availability pick never misreads it), and these routes are their only door.
|
|
1415
|
-
*/
|
|
1416
|
-
var INDEX_PATH = /^\/(\d+)\/strip-index$/;
|
|
1417
|
-
var FILE_PATH = /^\/(\d+)\/strip$/;
|
|
1418
|
-
/** `bytes=a-b` → [a, b] clamped to `size`, or null (serve the whole file). */
|
|
1419
|
-
function parseRange(header, size) {
|
|
1420
|
-
if (!header) return null;
|
|
1421
|
-
const m = /^bytes=(\d+)-(\d+)?$/.exec(header);
|
|
1422
|
-
if (!m) return null;
|
|
1423
|
-
const start = Number(m[1]);
|
|
1424
|
-
const end = m[2] === void 0 ? size - 1 : Math.min(Number(m[2]), size - 1);
|
|
1425
|
-
if (start > end || start >= size) return null;
|
|
1426
|
-
return [start, end];
|
|
1427
|
-
}
|
|
1428
|
-
function withStripRoutes(deps, next) {
|
|
1429
|
-
return async (req, res) => {
|
|
1430
|
-
const raw = req.url ?? "/";
|
|
1431
|
-
const qs = raw.indexOf("?");
|
|
1432
|
-
const pathname = qs === -1 ? raw : raw.slice(0, qs);
|
|
1433
|
-
const params = new URLSearchParams(qs === -1 ? "" : raw.slice(qs + 1));
|
|
1434
|
-
const asIndex = INDEX_PATH.exec(pathname);
|
|
1435
|
-
const asFile = asIndex === null ? FILE_PATH.exec(pathname) : null;
|
|
1436
|
-
if (asIndex === null && asFile === null) {
|
|
1437
|
-
await next(req, res);
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
const deviceId = Number((asIndex ?? asFile)[1]);
|
|
1441
|
-
const hour = Number(params.get("hour"));
|
|
1442
|
-
if (!Number.isFinite(hour) || hour <= 0) {
|
|
1443
|
-
res.statusCode = 400;
|
|
1444
|
-
res.end("strip: hour required");
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
try {
|
|
1448
|
-
if (asIndex !== null) {
|
|
1449
|
-
const entries = await deps.store.index(deviceId, hour);
|
|
1450
|
-
if (entries === null) {
|
|
1451
|
-
res.statusCode = 204;
|
|
1452
|
-
res.end();
|
|
1453
|
-
return;
|
|
1454
|
-
}
|
|
1455
|
-
const body = encodeStripIndex(entries);
|
|
1456
|
-
res.statusCode = 200;
|
|
1457
|
-
res.setHeader("content-type", "application/octet-stream");
|
|
1458
|
-
res.setHeader("content-length", String(body.length));
|
|
1459
|
-
res.setHeader("content-encoding", "identity");
|
|
1460
|
-
const immutable = hour + 36e5 < Date.now();
|
|
1461
|
-
res.setHeader("cache-control", immutable ? "private, max-age=31536000, immutable" : "private, max-age=2");
|
|
1462
|
-
res.end(Buffer.from(body));
|
|
1463
|
-
deps.onLog?.("strip:index-served", {
|
|
1464
|
-
deviceId,
|
|
1465
|
-
hour,
|
|
1466
|
-
entries: entries.length
|
|
1467
|
-
});
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
const abs = deps.store.absPathFor(deviceId, hour);
|
|
1471
|
-
if (abs === null) {
|
|
1472
|
-
res.statusCode = 204;
|
|
1473
|
-
res.end();
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
let size;
|
|
1477
|
-
try {
|
|
1478
|
-
size = (await promises.stat(abs)).size;
|
|
1479
|
-
} catch {
|
|
1480
|
-
res.statusCode = 204;
|
|
1481
|
-
res.end();
|
|
1482
|
-
return;
|
|
1483
|
-
}
|
|
1484
|
-
const range = parseRange(req.headers.range, size);
|
|
1485
|
-
const [start, end] = range ?? [0, size - 1];
|
|
1486
|
-
res.statusCode = range === null ? 200 : 206;
|
|
1487
|
-
res.setHeader("content-type", "application/octet-stream");
|
|
1488
|
-
res.setHeader("content-length", String(end - start + 1));
|
|
1489
|
-
res.setHeader("accept-ranges", "bytes");
|
|
1490
|
-
res.setHeader("content-encoding", "identity");
|
|
1491
|
-
if (range !== null) res.setHeader("content-range", `bytes ${start}-${end}/${size}`);
|
|
1492
|
-
const stream = createReadStream(abs, {
|
|
1493
|
-
start,
|
|
1494
|
-
end
|
|
1495
|
-
});
|
|
1496
|
-
stream.on("error", () => res.destroy());
|
|
1497
|
-
stream.pipe(res);
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
deps.onLog?.("strip:failed", {
|
|
1500
|
-
deviceId,
|
|
1501
|
-
hour,
|
|
1502
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1503
|
-
});
|
|
1504
|
-
res.statusCode = 500;
|
|
1505
|
-
res.end("strip: failed");
|
|
1506
|
-
}
|
|
1507
|
-
};
|
|
1508
|
-
}
|
|
1509
|
-
//#endregion
|
|
1510
|
-
//#region src/recorder/addon/strip-actions.ts
|
|
1511
|
-
/**
|
|
1512
|
-
* Recorder custom actions: strip regeneration.
|
|
1513
|
-
*
|
|
1514
|
-
* `regenerateStrips` clears a camera's thumbnail strips and rebuilds them from
|
|
1515
|
-
* the low segments already on disk — which is also the BACKFILL for footage
|
|
1516
|
-
* recorded before strips existed (or before the operator opted the camera in).
|
|
1517
|
-
*
|
|
1518
|
-
* A custom action, not a cap method: the generated AppRouter sits at
|
|
1519
|
-
* TypeScript's expansion limit, so caps cannot gain methods (docs/roadmap.md).
|
|
1520
|
-
*/
|
|
1521
|
-
var RegenerateStripsInputSchema = object({
|
|
1522
|
-
deviceId: number().int().nonnegative(),
|
|
1523
|
-
/** Days back from now to rebuild (default {@link REGEN_DEFAULT_DAYS}). */
|
|
1524
|
-
days: number().int().positive().max(60).optional()
|
|
1525
|
-
});
|
|
1526
|
-
var recorderStripActions = defineCustomActions({ regenerateStrips: customAction(RegenerateStripsInputSchema, object({
|
|
1527
|
-
/** False when a regeneration for this device is already running. */
|
|
1528
|
-
started: boolean(),
|
|
1529
|
-
/** Segments queued for thumbnail extraction (0 when not started). */
|
|
1530
|
-
segments: number()
|
|
1531
|
-
}), { kind: "mutation" }) });
|
|
1532
|
-
//#endregion
|
|
1533
1057
|
//#region src/recorder/segment-path.ts
|
|
1534
1058
|
function segmentRelPath(deviceId, profile, startMs, durMs, bytes) {
|
|
1535
1059
|
if (profile.includes("/")) throw new Error(`segmentRelPath: profile must not contain '/': ${profile}`);
|
|
@@ -4254,6 +3778,41 @@ var DEFAULT_WATCH_INTERVAL_MS = 2e3;
|
|
|
4254
3778
|
/** Periodic re-evaluation to catch band boundaries crossed with no config change. */
|
|
4255
3779
|
var TICK_MS = 3e4;
|
|
4256
3780
|
/**
|
|
3781
|
+
* Bound on the broker lease-release RPC (`releaseStreamWithCodec`) during
|
|
3782
|
+
* teardown. The UDS request default is 60s, but the runner supervisor's
|
|
3783
|
+
* SIGTERM→SIGKILL grace (`$process.stop`/`restart` in the kernel
|
|
3784
|
+
* process-service) is 5s: one release hanging on a restarting broker — the
|
|
3785
|
+
* normal state during a redeploy — blew the grace and the supervisor
|
|
3786
|
+
* SIGKILLed the recorder mid-shutdown (observed 2026-08-01 04:08–04:24,
|
|
3787
|
+
* `signal=SIGKILL state=stopped` × 4). The SIGKILL orphans the ffmpeg
|
|
3788
|
+
* writers (they keep double-writing `.rec-tmp` against the respawned
|
|
3789
|
+
* runner's fresh writers) and releases NO lease at all — strictly worse
|
|
3790
|
+
* than dropping this one RPC. A timed-out release is safe to abandon:
|
|
3791
|
+
* killing the writer drops the source's only RTSP consumer, so the broker
|
|
3792
|
+
* idles the source out on its own.
|
|
3793
|
+
*/
|
|
3794
|
+
var RELEASE_RPC_TIMEOUT_MS = 2e3;
|
|
3795
|
+
/**
|
|
3796
|
+
* Race `promise` against a deadline. Handlers stay attached to the losing
|
|
3797
|
+
* promise, so a post-deadline settlement can never surface as a process-level
|
|
3798
|
+
* `unhandledRejection`.
|
|
3799
|
+
*/
|
|
3800
|
+
function withDeadline(promise, ms, label) {
|
|
3801
|
+
return new Promise((resolve, reject) => {
|
|
3802
|
+
const timer = setTimeout(() => {
|
|
3803
|
+
reject(/* @__PURE__ */ new Error(`${label} timed out after ${ms}ms`));
|
|
3804
|
+
}, ms);
|
|
3805
|
+
timer.unref?.();
|
|
3806
|
+
promise.then((value) => {
|
|
3807
|
+
clearTimeout(timer);
|
|
3808
|
+
resolve(value);
|
|
3809
|
+
}, (err) => {
|
|
3810
|
+
clearTimeout(timer);
|
|
3811
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
3812
|
+
});
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
/**
|
|
4257
3816
|
* Floor for "this writer is pinned but producing nothing".
|
|
4258
3817
|
*
|
|
4259
3818
|
* The give-up path only fires when ffmpeg EXITS. On 2026-07-29 the opposite
|
|
@@ -4463,7 +4022,18 @@ var RecordingController = class {
|
|
|
4463
4022
|
lastMotionMs: source === "motion" ? atMs : prev.lastMotionMs,
|
|
4464
4023
|
lastAudioMs: source === "audio" ? atMs : prev.lastAudioMs
|
|
4465
4024
|
});
|
|
4466
|
-
|
|
4025
|
+
try {
|
|
4026
|
+
await this.evaluateDevice(deviceId);
|
|
4027
|
+
} catch (err) {
|
|
4028
|
+
this.deps.logger.warn("recorder: trigger attach deferred — stream not available yet (retries on next broker ready/tick)", {
|
|
4029
|
+
tags: { deviceId },
|
|
4030
|
+
meta: {
|
|
4031
|
+
source,
|
|
4032
|
+
atMs,
|
|
4033
|
+
error: errMsg(err)
|
|
4034
|
+
}
|
|
4035
|
+
});
|
|
4036
|
+
}
|
|
4467
4037
|
}
|
|
4468
4038
|
/**
|
|
4469
4039
|
* Decide-and-converge for one device: if its active band demands recording now
|
|
@@ -4632,9 +4202,9 @@ var RecordingController = class {
|
|
|
4632
4202
|
r.writer.stop();
|
|
4633
4203
|
} catch {}
|
|
4634
4204
|
try {
|
|
4635
|
-
await this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, nodePin(this.deps.ownerNodeId));
|
|
4205
|
+
await withDeadline(this.deps.api.streamBroker.releaseStreamWithCodec.mutate({ pipelineKey: r.pipelineKey }, nodePin(this.deps.ownerNodeId)), RELEASE_RPC_TIMEOUT_MS, "releaseStreamWithCodec");
|
|
4636
4206
|
} catch (err) {
|
|
4637
|
-
this.deps.logger.warn("recorder: releaseStreamWithCodec failed", {
|
|
4207
|
+
this.deps.logger.warn("recorder: releaseStreamWithCodec failed — lease dropped unreleased (broker idles the source once the writer is gone)", {
|
|
4638
4208
|
tags: { deviceId },
|
|
4639
4209
|
meta: {
|
|
4640
4210
|
profile: r.profile,
|
|
@@ -4650,7 +4220,7 @@ var RecordingController = class {
|
|
|
4650
4220
|
if (!recordings) return;
|
|
4651
4221
|
this.active.delete(deviceId);
|
|
4652
4222
|
for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
|
|
4653
|
-
|
|
4223
|
+
await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
|
|
4654
4224
|
this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
|
|
4655
4225
|
tags: { deviceId },
|
|
4656
4226
|
meta: { pipelineKeys: recordings.map((r) => r.pipelineKey) }
|
|
@@ -4665,7 +4235,7 @@ var RecordingController = class {
|
|
|
4665
4235
|
}
|
|
4666
4236
|
this.restore?.stop();
|
|
4667
4237
|
this.restore = null;
|
|
4668
|
-
|
|
4238
|
+
await Promise.all(Array.from(this.active.keys()).map((deviceId) => this.detachDevice(deviceId)));
|
|
4669
4239
|
}
|
|
4670
4240
|
};
|
|
4671
4241
|
/** Build a `stream-broker` readiness scope for one node. */
|
|
@@ -4886,7 +4456,7 @@ var RelocateEngine = class {
|
|
|
4886
4456
|
fromLocationId: from.id,
|
|
4887
4457
|
toLocationId: to.id,
|
|
4888
4458
|
deviceId: input.deviceId ?? null,
|
|
4889
|
-
entities: input.entities ? [...input.entities] : ["segments"
|
|
4459
|
+
entities: input.entities ? [...input.entities] : ["segments"],
|
|
4890
4460
|
filesMoved: 0,
|
|
4891
4461
|
bytesMoved: 0,
|
|
4892
4462
|
filesTotal: null,
|
|
@@ -4904,8 +4474,7 @@ var RelocateEngine = class {
|
|
|
4904
4474
|
const bytesPerMs = throttleMbps * 1024 * 1024 / 1e3;
|
|
4905
4475
|
try {
|
|
4906
4476
|
const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).sort((a, b) => a.startMs - b.startMs);
|
|
4907
|
-
|
|
4908
|
-
job.filesTotal = segRows.length + stripFiles.length;
|
|
4477
|
+
job.filesTotal = segRows.length;
|
|
4909
4478
|
for (const row of segRows) {
|
|
4910
4479
|
if (job.cancelRequested) break;
|
|
4911
4480
|
await this.moveSegment(row, from, to);
|
|
@@ -4914,15 +4483,6 @@ var RelocateEngine = class {
|
|
|
4914
4483
|
if (job.filesMoved % PROGRESS_EVERY_FILES === 0) this.deps.onProgress?.(snapshot(job));
|
|
4915
4484
|
await sleep(row.bytes / bytesPerMs);
|
|
4916
4485
|
}
|
|
4917
|
-
for (const rel of stripFiles) {
|
|
4918
|
-
if (job.cancelRequested) break;
|
|
4919
|
-
const src = path.join(from.root, rel);
|
|
4920
|
-
const bytes = (await promises.stat(src)).size;
|
|
4921
|
-
await moveFile(src, path.join(to.root, rel), bytes);
|
|
4922
|
-
job.filesMoved++;
|
|
4923
|
-
job.bytesMoved += bytes;
|
|
4924
|
-
await sleep(bytes / bytesPerMs);
|
|
4925
|
-
}
|
|
4926
4486
|
job.state = job.cancelRequested ? "cancelled" : "done";
|
|
4927
4487
|
} catch (err) {
|
|
4928
4488
|
job.state = "failed";
|
|
@@ -4947,29 +4507,6 @@ var RelocateEngine = class {
|
|
|
4947
4507
|
});
|
|
4948
4508
|
}
|
|
4949
4509
|
};
|
|
4950
|
-
/** The location-relative strip files under `<root>/<dev>/strips/…`, optionally
|
|
4951
|
-
* scoped to one device. Missing dirs are data (no strips), not failures. */
|
|
4952
|
-
async function listStripFiles(root, deviceId) {
|
|
4953
|
-
const devices = [];
|
|
4954
|
-
if (deviceId !== null) devices.push(String(deviceId));
|
|
4955
|
-
else try {
|
|
4956
|
-
for (const e of await promises.readdir(root)) if (/^\d+$/.test(e)) devices.push(e);
|
|
4957
|
-
} catch {
|
|
4958
|
-
return [];
|
|
4959
|
-
}
|
|
4960
|
-
const out = [];
|
|
4961
|
-
for (const dev of devices) {
|
|
4962
|
-
const stripDir = path.join(root, dev, "strips");
|
|
4963
|
-
let entries;
|
|
4964
|
-
try {
|
|
4965
|
-
entries = await promises.readdir(stripDir, { recursive: true });
|
|
4966
|
-
} catch {
|
|
4967
|
-
continue;
|
|
4968
|
-
}
|
|
4969
|
-
for (const e of entries) if (e.endsWith(".strip")) out.push(path.join(dev, "strips", e.split(path.sep).join("/")));
|
|
4970
|
-
}
|
|
4971
|
-
return out;
|
|
4972
|
-
}
|
|
4973
4510
|
//#endregion
|
|
4974
4511
|
//#region src/recorder/addon/footage-render.ts
|
|
4975
4512
|
/**
|
|
@@ -5422,14 +4959,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
5422
4959
|
/** Sync-sample tables captured at finalize, served by the directory so a
|
|
5423
4960
|
* playback seek needs no mfra tail fetch. RAM-only, TTL-bounded. */
|
|
5424
4961
|
mfraTables = new MfraTableStore();
|
|
5425
|
-
/** Keyframe thumbnail strips (fast-drag scrub depth), one file per
|
|
5426
|
-
* camera-hour under the LOW recordings root. */
|
|
5427
|
-
stripStore = new StripStore(() => {
|
|
5428
|
-
const writable = this.resolvedLocations.filter((l) => !l.readOnly);
|
|
5429
|
-
return (writable.find((l) => l.type === "recordingsLow") ?? writable[0])?.root ?? null;
|
|
5430
|
-
});
|
|
5431
|
-
/** Bounded one-shot ffmpeg pool feeding the strip at finalize. */
|
|
5432
|
-
stripCapture = new StripCapture({ onLog: (msg, data) => this.ctx.logger.debug(msg, { meta: data }) });
|
|
5433
4962
|
/** Continuous + events band write-path controller (ffmpeg writers + watchers). */
|
|
5434
4963
|
controller = null;
|
|
5435
4964
|
/** Per-device motion/audio markers for playback (B3 events feed the controller). */
|
|
@@ -5503,23 +5032,12 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
5503
5032
|
},
|
|
5504
5033
|
onEvicted: (deviceId, atMs) => {
|
|
5505
5034
|
this.calendar.invalidate(deviceId, atMs);
|
|
5506
|
-
this.stripStore.removeHour(deviceId, atMs);
|
|
5507
5035
|
},
|
|
5508
5036
|
onIndexed: (row, absPath) => {
|
|
5509
|
-
readMfraTable(absPath, row.bytes).then(
|
|
5510
|
-
this.mfraTables.record(row.deviceId, row.profile, row.startMs,
|
|
5511
|
-
if (row.profile !== "low") return;
|
|
5512
|
-
if ((await loadDeviceConfig(this.configStore(), row.deviceId)).stripsEnabled !== true) return;
|
|
5513
|
-
const jpegs = await this.stripCapture.capture(absPath);
|
|
5514
|
-
if (jpegs === null || jpegs.length === 0) return;
|
|
5515
|
-
const instants = keyframeInstants(row.startMs, row.durMs, captured);
|
|
5516
|
-
const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
|
|
5517
|
-
tsMs: instants[i] ?? row.startMs,
|
|
5518
|
-
jpeg
|
|
5519
|
-
}));
|
|
5520
|
-
await this.stripStore.append(row.deviceId, thumbs);
|
|
5037
|
+
readMfraTable(absPath, row.bytes).then((table) => {
|
|
5038
|
+
this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
|
|
5521
5039
|
}).catch((err) => {
|
|
5522
|
-
this.ctx.logger.debug("mfra
|
|
5040
|
+
this.ctx.logger.debug("mfra capture failed", {
|
|
5523
5041
|
tags: { deviceId: row.deviceId },
|
|
5524
5042
|
meta: {
|
|
5525
5043
|
path: row.path,
|
|
@@ -5711,14 +5229,11 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
5711
5229
|
downloadUrlFor: (id) => `/addon/${RECORDER_ADDON_ID}/${EXPORTS_PREFIX}/${id}.mp4`
|
|
5712
5230
|
});
|
|
5713
5231
|
try {
|
|
5714
|
-
const handler =
|
|
5715
|
-
store: this.stripStore,
|
|
5716
|
-
onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
|
|
5717
|
-
}, withDirectoryRoute({
|
|
5232
|
+
const handler = withDirectoryRoute({
|
|
5718
5233
|
source: this.calendar,
|
|
5719
5234
|
mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
|
|
5720
5235
|
onLog: (msg, meta) => this.ctx.logger.debug(msg, { meta })
|
|
5721
|
-
}, createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() }))
|
|
5236
|
+
}, createFileDataPlaneHandler({ getRoots: () => this.playbackRoots() }));
|
|
5722
5237
|
const served = await this.ctx.dataPlane?.serve({
|
|
5723
5238
|
prefix: PLAYBACK_PREFIX,
|
|
5724
5239
|
access: "authenticated",
|
|
@@ -5773,24 +5288,20 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
5773
5288
|
}
|
|
5774
5289
|
});
|
|
5775
5290
|
warnLostBands(this.configStore(), this.ctx.logger);
|
|
5776
|
-
return {
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
],
|
|
5791
|
-
customActions: recorderStripActions,
|
|
5792
|
-
actionHandlers: { regenerateStrips: async (input) => this.regenerateStrips(RegenerateStripsInputSchema.parse(input)) }
|
|
5793
|
-
};
|
|
5291
|
+
return { providers: [
|
|
5292
|
+
{
|
|
5293
|
+
capability: recordingCapability,
|
|
5294
|
+
provider: recordingProvider
|
|
5295
|
+
},
|
|
5296
|
+
{
|
|
5297
|
+
capability: storageEvictableCapability,
|
|
5298
|
+
provider: evictableProvider
|
|
5299
|
+
},
|
|
5300
|
+
{
|
|
5301
|
+
capability: recordingExportCapability,
|
|
5302
|
+
provider: exportProvider
|
|
5303
|
+
}
|
|
5304
|
+
] };
|
|
5794
5305
|
}
|
|
5795
5306
|
async onShutdown() {
|
|
5796
5307
|
this.eventUnsub?.();
|
|
@@ -6159,73 +5670,6 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
6159
5670
|
if (loc === void 0) throw new Error(`recorder scrub: unknown recordings location ${row.locationId}`);
|
|
6160
5671
|
return path.join(loc.root, row.path);
|
|
6161
5672
|
}
|
|
6162
|
-
/** Devices with a strip regeneration in flight — one at a time per device. */
|
|
6163
|
-
stripRegenRunning = /* @__PURE__ */ new Set();
|
|
6164
|
-
/**
|
|
6165
|
-
* Clear one camera's strips and rebuild them from the low segments on disk —
|
|
6166
|
-
* the backfill for footage recorded before strips were enabled. Walks the
|
|
6167
|
-
* RECENT window (default {@link REGEN_DEFAULT_DAYS} days), newest hour
|
|
6168
|
-
* first, so the part scrubbing actually visits is ready soonest. Background;
|
|
6169
|
-
* the one-shot ffmpeg pool bounds the decode concurrency.
|
|
6170
|
-
*/
|
|
6171
|
-
async regenerateStrips(input) {
|
|
6172
|
-
const { deviceId } = input;
|
|
6173
|
-
if (this.stripRegenRunning.has(deviceId)) return {
|
|
6174
|
-
started: false,
|
|
6175
|
-
segments: 0
|
|
6176
|
-
};
|
|
6177
|
-
const days = input.days ?? 3;
|
|
6178
|
-
const cutoff = Date.now() - days * 864e5;
|
|
6179
|
-
const rows = this.index.segments(deviceId, "low").filter((r) => r.startMs >= cutoff).toSorted((a, b) => b.startMs - a.startMs);
|
|
6180
|
-
this.stripRegenRunning.add(deviceId);
|
|
6181
|
-
await this.stripStore.removeDevice(deviceId);
|
|
6182
|
-
(async () => {
|
|
6183
|
-
let done = 0;
|
|
6184
|
-
let failed = 0;
|
|
6185
|
-
for (const row of rows) {
|
|
6186
|
-
try {
|
|
6187
|
-
const abs = this.resolveSegmentAbsPath(row);
|
|
6188
|
-
const captured = await readMfraTable(abs, row.bytes);
|
|
6189
|
-
const jpegs = await this.stripCapture.capture(abs);
|
|
6190
|
-
if (jpegs === null || jpegs.length === 0) {
|
|
6191
|
-
failed++;
|
|
6192
|
-
continue;
|
|
6193
|
-
}
|
|
6194
|
-
const instants = keyframeInstants(row.startMs, row.durMs, captured);
|
|
6195
|
-
const thumbs = jpegs.slice(0, instants.length).map((jpeg, i) => ({
|
|
6196
|
-
tsMs: instants[i] ?? row.startMs,
|
|
6197
|
-
jpeg
|
|
6198
|
-
}));
|
|
6199
|
-
await this.stripStore.append(deviceId, thumbs);
|
|
6200
|
-
done++;
|
|
6201
|
-
} catch {
|
|
6202
|
-
failed++;
|
|
6203
|
-
}
|
|
6204
|
-
if ((done + failed) % 500 === 0) this.ctx.logger.info("strip regeneration progress", {
|
|
6205
|
-
tags: { deviceId },
|
|
6206
|
-
meta: {
|
|
6207
|
-
done,
|
|
6208
|
-
failed,
|
|
6209
|
-
total: rows.length
|
|
6210
|
-
}
|
|
6211
|
-
});
|
|
6212
|
-
}
|
|
6213
|
-
this.stripRegenRunning.delete(deviceId);
|
|
6214
|
-
this.ctx.logger.info("strip regeneration complete", {
|
|
6215
|
-
tags: { deviceId },
|
|
6216
|
-
meta: {
|
|
6217
|
-
done,
|
|
6218
|
-
failed,
|
|
6219
|
-
total: rows.length,
|
|
6220
|
-
days
|
|
6221
|
-
}
|
|
6222
|
-
});
|
|
6223
|
-
})();
|
|
6224
|
-
return {
|
|
6225
|
-
started: true,
|
|
6226
|
-
segments: rows.length
|
|
6227
|
-
};
|
|
6228
|
-
}
|
|
6229
5673
|
/**
|
|
6230
5674
|
* Dedicated export MP4 download handler. Resolves `<exportId>.mp4` to a `ready`
|
|
6231
5675
|
* row's file (traversal-guarded by the id regex + the exact stored path),
|
|
@@ -6312,4 +5756,4 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
6312
5756
|
}
|
|
6313
5757
|
};
|
|
6314
5758
|
//#endregion
|
|
6315
|
-
export {
|
|
5759
|
+
export { RecorderV2Addon as default };
|