@camstack/addon-pipeline 1.1.21 → 1.1.23
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/audio-codec-ffmpeg/index.js +1 -1
- package/dist/audio-codec-ffmpeg/index.mjs +1 -1
- package/dist/decoder-ffmpeg/index.js +1 -1
- package/dist/decoder-ffmpeg/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +1 -1
- package/dist/detection-pipeline/index.mjs +1 -1
- package/dist/{dist-BiP1gPeY.js → dist-CE3a05qT.js} +228 -16
- package/dist/{dist-tzhTTRq4.mjs → dist-pd_-3C0T.mjs} +228 -16
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +412 -247
- package/dist/pipeline-runner/index.mjs +412 -247
- package/dist/recorder/index.js +260 -234
- package/dist/recorder/index.mjs +260 -234
- package/dist/stream-broker/_stub.js +378 -324
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BVFAEkIB.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BFeBlYRQ.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-C0QtG7oR.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BC1Jt6G8.mjs} +1 -1
- package/dist/stream-broker/{hostInit-CGnMNtPl.mjs → hostInit-DHej3Ep9.mjs} +2 -2
- package/dist/stream-broker/index.js +13788 -12378
- package/dist/stream-broker/index.mjs +13907 -12497
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js → MaskShapeCanvas-DI4BY7W2-BqXU75it.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHJ39tfi.js → MotionZonesSettings-NcxxQN8r-Ct2fpgCd.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js → PrivacyMaskSettings-APgPLF7p-a9eFVPPl.js} +1 -1
- package/embed-dist/assets/index-DhdVxWXL.js +80 -0
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BSWrW78G.mjs +0 -26
- package/embed-dist/assets/index-CKpVWKSG.js +0 -80
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as selectAssignedProfileSlots, B as BaseAddon, F as storageEvictableCapability, J as hydrateSchema, K as createDurableState, N as recordingCapability, O as migrateConfigToBands, U as DeviceType, W as EventCategory, d as RecordingConfigSchema, lt as record, ut as string, z as errMsg } from "../dist-
|
|
1
|
+
import { $ as selectAssignedProfileSlots, B as BaseAddon, F as storageEvictableCapability, J as hydrateSchema, K as createDurableState, N as recordingCapability, O as migrateConfigToBands, U as DeviceType, W as EventCategory, d as RecordingConfigSchema, lt as record, ut as string, z as errMsg } from "../dist-pd_-3C0T.mjs";
|
|
2
2
|
import { t as createFileDataPlaneHandler } from "../model-download-service-C-IHWnXx-3Mmeob3l.mjs";
|
|
3
3
|
import { promises } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
@@ -954,134 +954,129 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
|
|
|
954
954
|
}
|
|
955
955
|
}
|
|
956
956
|
//#endregion
|
|
957
|
-
//#region src/recorder/
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
"-c:a",
|
|
976
|
-
"aac",
|
|
977
|
-
"-f",
|
|
978
|
-
"segment",
|
|
979
|
-
"-segment_time",
|
|
980
|
-
String(a.segmentSeconds),
|
|
981
|
-
"-segment_format",
|
|
982
|
-
"mp4",
|
|
983
|
-
"-segment_format_options",
|
|
984
|
-
"movflags=+frag_keyframe+empty_moov+default_base_moof",
|
|
985
|
-
"-reset_timestamps",
|
|
986
|
-
"1",
|
|
987
|
-
"-strftime",
|
|
988
|
-
"1",
|
|
989
|
-
"-segment_list",
|
|
990
|
-
`${a.outDir}/live.m3u8`,
|
|
991
|
-
"-segment_list_type",
|
|
992
|
-
"m3u8",
|
|
993
|
-
`${a.outDir}/%s.m4s`
|
|
994
|
-
];
|
|
957
|
+
//#region src/recorder/band-engine.ts
|
|
958
|
+
function toMin(hhmm) {
|
|
959
|
+
const [h, m] = hhmm.split(":");
|
|
960
|
+
return Number(h) * 60 + Number(m);
|
|
961
|
+
}
|
|
962
|
+
function bandActiveAt(band, date) {
|
|
963
|
+
const wd = date.getDay();
|
|
964
|
+
const prevWd = (wd + 6) % 7;
|
|
965
|
+
const t = date.getHours() * 60 + date.getMinutes();
|
|
966
|
+
const s = toMin(band.start);
|
|
967
|
+
const e = toMin(band.end);
|
|
968
|
+
const applies = (d) => band.days.length === 0 || band.days.includes(d);
|
|
969
|
+
if (s < e) return applies(wd) && t >= s && t < e;
|
|
970
|
+
if (s > e) return applies(wd) && t >= s || applies(prevWd) && t < e;
|
|
971
|
+
return applies(wd);
|
|
972
|
+
}
|
|
973
|
+
function activeBandAt(config, date) {
|
|
974
|
+
return config.bands.find((b) => bandActiveAt(b, date)) ?? null;
|
|
995
975
|
}
|
|
996
976
|
//#endregion
|
|
997
|
-
//#region src/recorder/addon/
|
|
998
|
-
/**
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
/**
|
|
1011
|
-
|
|
1012
|
-
|
|
977
|
+
//#region src/recorder/addon/band-decision.ts
|
|
978
|
+
/**
|
|
979
|
+
* Is an `events` band's live demand window open at `atMs`? True iff a trigger
|
|
980
|
+
* the band listens for fired within `postBufferSec` of now. `preBufferSec` is
|
|
981
|
+
* deliberately NOT consulted here (see file header).
|
|
982
|
+
*/
|
|
983
|
+
function eventsBandDemanded(band, triggers, atMs) {
|
|
984
|
+
const postMs = (band.postBufferSec ?? 0) * 1e3;
|
|
985
|
+
const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
|
|
986
|
+
if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
|
|
987
|
+
if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Should recording (any mode) be active for this device at `at`? The live
|
|
992
|
+
* attach/detach gate: enabled + a band covers `at` + that band demands now
|
|
993
|
+
* (continuous always; events within `postBufferSec` of a qualifying trigger).
|
|
994
|
+
*/
|
|
995
|
+
function shouldRecordAt(config, triggers, at) {
|
|
996
|
+
if (!config.enabled) return false;
|
|
997
|
+
const bands = config.bands;
|
|
998
|
+
if (!bands || bands.length === 0) return false;
|
|
999
|
+
const band = activeBandAt({ bands }, at);
|
|
1000
|
+
if (!band) return false;
|
|
1001
|
+
if (band.mode === "continuous") return true;
|
|
1002
|
+
return eventsBandDemanded(band, triggers, at.getTime());
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Should a finalized segment `[segStartMs, segEndMs]` recorded under an
|
|
1006
|
+
* `events` band be KEPT (else discarded)? Kept iff it overlaps any qualifying
|
|
1007
|
+
* trigger's full window `[trigger - preBufferSec, trigger + postBufferSec]`.
|
|
1008
|
+
* This is where `preBufferSec` retroactively retains pre-trigger footage.
|
|
1009
|
+
*/
|
|
1010
|
+
function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
|
|
1011
|
+
const preMs = (band.preBufferSec ?? 0) * 1e3;
|
|
1012
|
+
const postMs = (band.postBufferSec ?? 0) * 1e3;
|
|
1013
|
+
const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
|
|
1014
|
+
if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
|
|
1015
|
+
if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/recorder/addon/readiness-restore.ts
|
|
1020
|
+
var ReadinessRestore = class {
|
|
1013
1021
|
deps;
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
constructor(cfg, deps) {
|
|
1020
|
-
this.cfg = cfg;
|
|
1022
|
+
pending = /* @__PURE__ */ new Map();
|
|
1023
|
+
draining = false;
|
|
1024
|
+
rerun = false;
|
|
1025
|
+
unsub = null;
|
|
1026
|
+
constructor(deps) {
|
|
1021
1027
|
this.deps = deps;
|
|
1022
1028
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
const
|
|
1027
|
-
this.
|
|
1028
|
-
|
|
1029
|
-
const stderrTail = [];
|
|
1030
|
-
proc.stderr?.on("data", (chunk) => {
|
|
1031
|
-
const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
|
|
1032
|
-
for (const line of text.split("\n")) {
|
|
1033
|
-
const trimmed = line.trim();
|
|
1034
|
-
if (!trimmed) continue;
|
|
1035
|
-
stderrTail.push(trimmed);
|
|
1036
|
-
if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
|
|
1037
|
-
}
|
|
1038
|
-
});
|
|
1039
|
-
proc.on("exit", (code) => {
|
|
1040
|
-
this.proc = null;
|
|
1041
|
-
if (this.stopped) return;
|
|
1042
|
-
const ranMs = Date.now() - this.startedAt;
|
|
1043
|
-
if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
|
|
1044
|
-
if (this.restarts >= MAX_RESTARTS) {
|
|
1045
|
-
this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
|
|
1046
|
-
outDir: this.cfg.outDir,
|
|
1047
|
-
code,
|
|
1048
|
-
stderrTail: stderrTail.join(" | ")
|
|
1049
|
-
} });
|
|
1050
|
-
this.stopped = true;
|
|
1051
|
-
this.deps.onGaveUp?.();
|
|
1052
|
-
return;
|
|
1053
|
-
}
|
|
1054
|
-
this.restarts++;
|
|
1055
|
-
const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
|
|
1056
|
-
this.deps.logger.info("SegmentWriter restarting", { meta: {
|
|
1057
|
-
outDir: this.cfg.outDir,
|
|
1058
|
-
attempt: this.restarts,
|
|
1059
|
-
delayMs,
|
|
1060
|
-
ranMs
|
|
1061
|
-
} });
|
|
1062
|
-
this.restartTimer = setTimeout(() => {
|
|
1063
|
-
this.restartTimer = null;
|
|
1064
|
-
this.start();
|
|
1065
|
-
}, delayMs);
|
|
1029
|
+
/** Seed the pending set, subscribe to readiness, and drain once immediately
|
|
1030
|
+
* (covers the already-ready case). Returns when the first drain settles. */
|
|
1031
|
+
async start(items) {
|
|
1032
|
+
for (const [k, v] of items) this.pending.set(k, v);
|
|
1033
|
+
if (this.unsub === null) this.unsub = this.deps.subscribeReady(() => {
|
|
1034
|
+
this.drain();
|
|
1066
1035
|
});
|
|
1036
|
+
await this.drain();
|
|
1067
1037
|
}
|
|
1038
|
+
/** Add (or replace) one pending item without re-subscribing. Does NOT drain. */
|
|
1039
|
+
add(key, value) {
|
|
1040
|
+
this.pending.set(key, value);
|
|
1041
|
+
}
|
|
1042
|
+
/** Remove one pending item (e.g. the camera was disabled before it attached). */
|
|
1043
|
+
remove(key) {
|
|
1044
|
+
this.pending.delete(key);
|
|
1045
|
+
}
|
|
1046
|
+
/** Unsubscribe from readiness. Call on shutdown. */
|
|
1068
1047
|
stop() {
|
|
1069
|
-
this.
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1048
|
+
this.unsub?.();
|
|
1049
|
+
this.unsub = null;
|
|
1050
|
+
this.pending.clear();
|
|
1051
|
+
}
|
|
1052
|
+
get pendingCount() {
|
|
1053
|
+
return this.pending.size;
|
|
1054
|
+
}
|
|
1055
|
+
hasPending(key) {
|
|
1056
|
+
return this.pending.has(key);
|
|
1057
|
+
}
|
|
1058
|
+
/** Attempt every still-pending item; repeat once if a readiness signal arrived
|
|
1059
|
+
* mid-flight. Re-entrant and self-coalescing. */
|
|
1060
|
+
async drain() {
|
|
1061
|
+
if (this.draining) {
|
|
1062
|
+
this.rerun = true;
|
|
1063
|
+
return;
|
|
1073
1064
|
}
|
|
1074
|
-
|
|
1075
|
-
this.
|
|
1076
|
-
if (!proc) return;
|
|
1065
|
+
if (this.pending.size === 0) return;
|
|
1066
|
+
this.draining = true;
|
|
1077
1067
|
try {
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1068
|
+
do {
|
|
1069
|
+
this.rerun = false;
|
|
1070
|
+
for (const [key, value] of Array.from(this.pending)) try {
|
|
1071
|
+
await this.deps.attempt(key, value);
|
|
1072
|
+
this.pending.delete(key);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
this.deps.onDefer?.(key, value, err);
|
|
1075
|
+
}
|
|
1076
|
+
} while (this.rerun && this.pending.size > 0);
|
|
1077
|
+
} finally {
|
|
1078
|
+
this.draining = false;
|
|
1079
|
+
}
|
|
1085
1080
|
}
|
|
1086
1081
|
};
|
|
1087
1082
|
//#endregion
|
|
@@ -1290,132 +1285,137 @@ function startSegmentWatcher(deps) {
|
|
|
1290
1285
|
} };
|
|
1291
1286
|
}
|
|
1292
1287
|
//#endregion
|
|
1293
|
-
//#region src/recorder/addon/
|
|
1294
|
-
|
|
1288
|
+
//#region src/recorder/addon/ffmpeg-args.ts
|
|
1289
|
+
/** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to AAC, write
|
|
1290
|
+
* fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
|
|
1291
|
+
* rolling `live.m3u8` playlist. The recorder segment-watcher tails the
|
|
1292
|
+
* playlist and hands each finalized flat file to `SegmentStore.onFinalized`,
|
|
1293
|
+
* which stats + relocates it into the bucket tree (`segmentRelPath`). */
|
|
1294
|
+
function buildPassthroughArgs(a) {
|
|
1295
|
+
return [
|
|
1296
|
+
"-rtsp_transport",
|
|
1297
|
+
"tcp",
|
|
1298
|
+
"-user_agent",
|
|
1299
|
+
"recorder",
|
|
1300
|
+
"-i",
|
|
1301
|
+
a.rtspUrl,
|
|
1302
|
+
"-map",
|
|
1303
|
+
"0",
|
|
1304
|
+
"-c:v",
|
|
1305
|
+
"copy",
|
|
1306
|
+
"-c:a",
|
|
1307
|
+
"aac",
|
|
1308
|
+
"-f",
|
|
1309
|
+
"segment",
|
|
1310
|
+
"-segment_time",
|
|
1311
|
+
String(a.segmentSeconds),
|
|
1312
|
+
"-segment_format",
|
|
1313
|
+
"mp4",
|
|
1314
|
+
"-segment_format_options",
|
|
1315
|
+
"movflags=+frag_keyframe+empty_moov+default_base_moof",
|
|
1316
|
+
"-reset_timestamps",
|
|
1317
|
+
"1",
|
|
1318
|
+
"-strftime",
|
|
1319
|
+
"1",
|
|
1320
|
+
"-segment_list",
|
|
1321
|
+
`${a.outDir}/live.m3u8`,
|
|
1322
|
+
"-segment_list_type",
|
|
1323
|
+
"m3u8",
|
|
1324
|
+
`${a.outDir}/%s.m4s`
|
|
1325
|
+
];
|
|
1326
|
+
}
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/recorder/addon/segment-writer.ts
|
|
1329
|
+
/** Max CONSECUTIVE rapid restarts before giving up (reset by a stable run). */
|
|
1330
|
+
var MAX_RESTARTS = 10;
|
|
1331
|
+
/** How many trailing ffmpeg stderr lines to keep for the give-up diagnostic. */
|
|
1332
|
+
var STDERR_TAIL_LINES = 12;
|
|
1333
|
+
/** Restart backoff: base delay, doubled per consecutive attempt, capped. */
|
|
1334
|
+
var RESTART_BASE_MS = 500;
|
|
1335
|
+
var RESTART_MAX_MS = 1e4;
|
|
1336
|
+
/** A process that ran at least this long is "stable" → reset the restart count,
|
|
1337
|
+
* so sporadic blips over a long lifetime never accumulate into a give-up. */
|
|
1338
|
+
var STABLE_RUN_MS = 3e4;
|
|
1339
|
+
/** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
|
|
1340
|
+
var KILL_GRACE_MS = 500;
|
|
1341
|
+
/** Supervises one passthrough ffmpeg for a single (camera, profile). */
|
|
1342
|
+
var SegmentWriter = class {
|
|
1343
|
+
cfg;
|
|
1295
1344
|
deps;
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1345
|
+
proc = null;
|
|
1346
|
+
stopped = false;
|
|
1347
|
+
restarts = 0;
|
|
1348
|
+
restartTimer = null;
|
|
1349
|
+
startedAt = 0;
|
|
1350
|
+
constructor(cfg, deps) {
|
|
1351
|
+
this.cfg = cfg;
|
|
1301
1352
|
this.deps = deps;
|
|
1302
1353
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1354
|
+
start() {
|
|
1355
|
+
if (this.stopped || this.proc) return;
|
|
1356
|
+
const args = buildPassthroughArgs(this.cfg);
|
|
1357
|
+
const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
|
|
1358
|
+
this.proc = proc;
|
|
1359
|
+
this.startedAt = Date.now();
|
|
1360
|
+
const stderrTail = [];
|
|
1361
|
+
proc.stderr?.on("data", (chunk) => {
|
|
1362
|
+
const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
|
|
1363
|
+
for (const line of text.split("\n")) {
|
|
1364
|
+
const trimmed = line.trim();
|
|
1365
|
+
if (!trimmed) continue;
|
|
1366
|
+
stderrTail.push(trimmed);
|
|
1367
|
+
if (stderrTail.length > STDERR_TAIL_LINES) stderrTail.shift();
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
proc.on("exit", (code) => {
|
|
1371
|
+
this.proc = null;
|
|
1372
|
+
if (this.stopped) return;
|
|
1373
|
+
const ranMs = Date.now() - this.startedAt;
|
|
1374
|
+
if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
|
|
1375
|
+
if (this.restarts >= MAX_RESTARTS) {
|
|
1376
|
+
this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
|
|
1377
|
+
outDir: this.cfg.outDir,
|
|
1378
|
+
code,
|
|
1379
|
+
stderrTail: stderrTail.join(" | ")
|
|
1380
|
+
} });
|
|
1381
|
+
this.stopped = true;
|
|
1382
|
+
this.deps.onGaveUp?.();
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
this.restarts++;
|
|
1386
|
+
const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
|
|
1387
|
+
this.deps.logger.info("SegmentWriter restarting", { meta: {
|
|
1388
|
+
outDir: this.cfg.outDir,
|
|
1389
|
+
attempt: this.restarts,
|
|
1390
|
+
delayMs,
|
|
1391
|
+
ranMs
|
|
1392
|
+
} });
|
|
1393
|
+
this.restartTimer = setTimeout(() => {
|
|
1394
|
+
this.restartTimer = null;
|
|
1395
|
+
this.start();
|
|
1396
|
+
}, delayMs);
|
|
1309
1397
|
});
|
|
1310
|
-
await this.drain();
|
|
1311
|
-
}
|
|
1312
|
-
/** Add (or replace) one pending item without re-subscribing. Does NOT drain. */
|
|
1313
|
-
add(key, value) {
|
|
1314
|
-
this.pending.set(key, value);
|
|
1315
|
-
}
|
|
1316
|
-
/** Remove one pending item (e.g. the camera was disabled before it attached). */
|
|
1317
|
-
remove(key) {
|
|
1318
|
-
this.pending.delete(key);
|
|
1319
1398
|
}
|
|
1320
|
-
/** Unsubscribe from readiness. Call on shutdown. */
|
|
1321
1399
|
stop() {
|
|
1322
|
-
this.
|
|
1323
|
-
this.
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
get pendingCount() {
|
|
1327
|
-
return this.pending.size;
|
|
1328
|
-
}
|
|
1329
|
-
hasPending(key) {
|
|
1330
|
-
return this.pending.has(key);
|
|
1331
|
-
}
|
|
1332
|
-
/** Attempt every still-pending item; repeat once if a readiness signal arrived
|
|
1333
|
-
* mid-flight. Re-entrant and self-coalescing. */
|
|
1334
|
-
async drain() {
|
|
1335
|
-
if (this.draining) {
|
|
1336
|
-
this.rerun = true;
|
|
1337
|
-
return;
|
|
1400
|
+
this.stopped = true;
|
|
1401
|
+
if (this.restartTimer) {
|
|
1402
|
+
clearTimeout(this.restartTimer);
|
|
1403
|
+
this.restartTimer = null;
|
|
1338
1404
|
}
|
|
1339
|
-
|
|
1340
|
-
this.
|
|
1405
|
+
const proc = this.proc;
|
|
1406
|
+
this.proc = null;
|
|
1407
|
+
if (!proc) return;
|
|
1341
1408
|
try {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
} while (this.rerun && this.pending.size > 0);
|
|
1351
|
-
} finally {
|
|
1352
|
-
this.draining = false;
|
|
1353
|
-
}
|
|
1409
|
+
proc.kill("SIGTERM");
|
|
1410
|
+
} catch {}
|
|
1411
|
+
setTimeout(() => {
|
|
1412
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
1413
|
+
proc.kill("SIGKILL");
|
|
1414
|
+
} catch {}
|
|
1415
|
+
}, KILL_GRACE_MS).unref?.();
|
|
1354
1416
|
}
|
|
1355
1417
|
};
|
|
1356
1418
|
//#endregion
|
|
1357
|
-
//#region src/recorder/band-engine.ts
|
|
1358
|
-
function toMin(hhmm) {
|
|
1359
|
-
const [h, m] = hhmm.split(":");
|
|
1360
|
-
return Number(h) * 60 + Number(m);
|
|
1361
|
-
}
|
|
1362
|
-
function bandActiveAt(band, date) {
|
|
1363
|
-
const wd = date.getDay();
|
|
1364
|
-
const prevWd = (wd + 6) % 7;
|
|
1365
|
-
const t = date.getHours() * 60 + date.getMinutes();
|
|
1366
|
-
const s = toMin(band.start);
|
|
1367
|
-
const e = toMin(band.end);
|
|
1368
|
-
const applies = (d) => band.days.length === 0 || band.days.includes(d);
|
|
1369
|
-
if (s < e) return applies(wd) && t >= s && t < e;
|
|
1370
|
-
if (s > e) return applies(wd) && t >= s || applies(prevWd) && t < e;
|
|
1371
|
-
return applies(wd);
|
|
1372
|
-
}
|
|
1373
|
-
function activeBandAt(config, date) {
|
|
1374
|
-
return config.bands.find((b) => bandActiveAt(b, date)) ?? null;
|
|
1375
|
-
}
|
|
1376
|
-
//#endregion
|
|
1377
|
-
//#region src/recorder/addon/band-decision.ts
|
|
1378
|
-
/**
|
|
1379
|
-
* Is an `events` band's live demand window open at `atMs`? True iff a trigger
|
|
1380
|
-
* the band listens for fired within `postBufferSec` of now. `preBufferSec` is
|
|
1381
|
-
* deliberately NOT consulted here (see file header).
|
|
1382
|
-
*/
|
|
1383
|
-
function eventsBandDemanded(band, triggers, atMs) {
|
|
1384
|
-
const postMs = (band.postBufferSec ?? 0) * 1e3;
|
|
1385
|
-
const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
|
|
1386
|
-
if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
|
|
1387
|
-
if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
|
|
1388
|
-
return false;
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* Should recording (any mode) be active for this device at `at`? The live
|
|
1392
|
-
* attach/detach gate: enabled + a band covers `at` + that band demands now
|
|
1393
|
-
* (continuous always; events within `postBufferSec` of a qualifying trigger).
|
|
1394
|
-
*/
|
|
1395
|
-
function shouldRecordAt(config, triggers, at) {
|
|
1396
|
-
if (!config.enabled) return false;
|
|
1397
|
-
const bands = config.bands;
|
|
1398
|
-
if (!bands || bands.length === 0) return false;
|
|
1399
|
-
const band = activeBandAt({ bands }, at);
|
|
1400
|
-
if (!band) return false;
|
|
1401
|
-
if (band.mode === "continuous") return true;
|
|
1402
|
-
return eventsBandDemanded(band, triggers, at.getTime());
|
|
1403
|
-
}
|
|
1404
|
-
/**
|
|
1405
|
-
* Should a finalized segment `[segStartMs, segEndMs]` recorded under an
|
|
1406
|
-
* `events` band be KEPT (else discarded)? Kept iff it overlaps any qualifying
|
|
1407
|
-
* trigger's full window `[trigger - preBufferSec, trigger + postBufferSec]`.
|
|
1408
|
-
* This is where `preBufferSec` retroactively retains pre-trigger footage.
|
|
1409
|
-
*/
|
|
1410
|
-
function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers) {
|
|
1411
|
-
const preMs = (band.preBufferSec ?? 0) * 1e3;
|
|
1412
|
-
const postMs = (band.postBufferSec ?? 0) * 1e3;
|
|
1413
|
-
const overlaps = (lastMs) => lastMs != null && segStartMs <= lastMs + postMs && segEndMs >= lastMs - preMs;
|
|
1414
|
-
if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
|
|
1415
|
-
if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
|
|
1416
|
-
return false;
|
|
1417
|
-
}
|
|
1418
|
-
//#endregion
|
|
1419
1419
|
//#region src/recorder/addon/recording-controller.ts
|
|
1420
1420
|
/**
|
|
1421
1421
|
* recorder recording controller — the B2 continuous-band write path.
|
|
@@ -1449,6 +1449,17 @@ var TICK_MS = 3e4;
|
|
|
1449
1449
|
var RecordingController = class {
|
|
1450
1450
|
deps;
|
|
1451
1451
|
active = /* @__PURE__ */ new Map();
|
|
1452
|
+
/**
|
|
1453
|
+
* Devices whose attach is IN FLIGHT. `evaluateDevice` only `active.set`s after
|
|
1454
|
+
* a chain of awaits (loadConfig → resolveProfiles → getStreamWithCodec → mkdir
|
|
1455
|
+
* → spawn); without this guard two evaluate passes racing in the same tick
|
|
1456
|
+
* (rapid motion triggers, or a tick concurrent with give-up recovery) both pass
|
|
1457
|
+
* the `active.has` check and each spawn a DUPLICATE writer set that double-writes
|
|
1458
|
+
* the same rec-tmp dir → colliding/28-byte segments + relocate ENOENT. Set
|
|
1459
|
+
* synchronously right after the `active.has` check (no await between), cleared in
|
|
1460
|
+
* a `finally`. Mirrors the decoder-ffmpeg session-creation coalescing guard.
|
|
1461
|
+
*/
|
|
1462
|
+
attaching = /* @__PURE__ */ new Set();
|
|
1452
1463
|
/** Latest qualifying trigger timestamps per device — drives `events`-band gating. */
|
|
1453
1464
|
triggers = /* @__PURE__ */ new Map();
|
|
1454
1465
|
restore = null;
|
|
@@ -1583,6 +1594,21 @@ var RecordingController = class {
|
|
|
1583
1594
|
return;
|
|
1584
1595
|
}
|
|
1585
1596
|
if (this.active.has(deviceId)) return;
|
|
1597
|
+
if (this.attaching.has(deviceId)) return;
|
|
1598
|
+
this.attaching.add(deviceId);
|
|
1599
|
+
try {
|
|
1600
|
+
await this.performAttach(deviceId, config);
|
|
1601
|
+
} finally {
|
|
1602
|
+
this.attaching.delete(deviceId);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* The attach body — extracted so the {@link attaching} in-flight guard in
|
|
1607
|
+
* `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
|
|
1608
|
+
* SegmentWriter + watcher per profile, and records the set in `active`. A throw
|
|
1609
|
+
* (broker not ready / no sources) rolls back partial attaches and re-throws.
|
|
1610
|
+
*/
|
|
1611
|
+
async performAttach(deviceId, config) {
|
|
1586
1612
|
this.restore?.add(deviceId, true);
|
|
1587
1613
|
const profiles = await this.resolveProfiles(deviceId, config.profiles);
|
|
1588
1614
|
if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
|