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