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