@camstack/addon-pipeline 1.1.21 → 1.1.22

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.
Files changed (32) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +1 -1
  6. package/dist/decoder-ffmpeg/index.mjs +1 -1
  7. package/dist/detection-pipeline/index.js +1 -1
  8. package/dist/detection-pipeline/index.mjs +1 -1
  9. package/dist/{dist-BiP1gPeY.js → dist-CE3a05qT.js} +228 -16
  10. package/dist/{dist-tzhTTRq4.mjs → dist-pd_-3C0T.mjs} +228 -16
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +412 -247
  14. package/dist/pipeline-runner/index.mjs +412 -247
  15. package/dist/recorder/index.js +260 -234
  16. package/dist/recorder/index.mjs +260 -234
  17. package/dist/stream-broker/_stub.js +378 -324
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D08qJTw_.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLVitFM4.mjs} +2 -2
  19. 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
  20. 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
  21. package/dist/stream-broker/{hostInit-CGnMNtPl.mjs → hostInit-B15D9Mjv.mjs} +2 -2
  22. package/dist/stream-broker/index.js +13862 -12493
  23. package/dist/stream-broker/index.mjs +13878 -12509
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CW4Qu4V6.js → MaskShapeCanvas-DI4BY7W2-BqXU75it.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHJ39tfi.js → MotionZonesSettings-NcxxQN8r-Ct2fpgCd.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-dInJ7kcz.js → PrivacyMaskSettings-APgPLF7p-a9eFVPPl.js} +1 -1
  28. package/embed-dist/assets/index-DhdVxWXL.js +80 -0
  29. package/embed-dist/index.html +1 -1
  30. package/package.json +1 -1
  31. 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
  32. package/embed-dist/assets/index-CKpVWKSG.js +0 -80
@@ -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-BiP1gPeY.js");
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/addon/ffmpeg-args.ts
959
- /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to AAC, write
960
- * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
961
- * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
962
- * playlist and hands each finalized flat file to `SegmentStore.onFinalized`,
963
- * which stats + relocates it into the bucket tree (`segmentRelPath`). */
964
- function buildPassthroughArgs(a) {
965
- return [
966
- "-rtsp_transport",
967
- "tcp",
968
- "-user_agent",
969
- "recorder",
970
- "-i",
971
- a.rtspUrl,
972
- "-map",
973
- "0",
974
- "-c:v",
975
- "copy",
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/segment-writer.ts
999
- /** Max CONSECUTIVE rapid restarts before giving up (reset by a stable run). */
1000
- var MAX_RESTARTS = 10;
1001
- /** How many trailing ffmpeg stderr lines to keep for the give-up diagnostic. */
1002
- var STDERR_TAIL_LINES = 12;
1003
- /** Restart backoff: base delay, doubled per consecutive attempt, capped. */
1004
- var RESTART_BASE_MS = 500;
1005
- var RESTART_MAX_MS = 1e4;
1006
- /** A process that ran at least this long is "stable" reset the restart count,
1007
- * so sporadic blips over a long lifetime never accumulate into a give-up. */
1008
- var STABLE_RUN_MS = 3e4;
1009
- /** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
1010
- var KILL_GRACE_MS = 500;
1011
- /** Supervises one passthrough ffmpeg for a single (camera, profile). */
1012
- var SegmentWriter = class {
1013
- cfg;
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
- proc = null;
1016
- stopped = false;
1017
- restarts = 0;
1018
- restartTimer = null;
1019
- startedAt = 0;
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
- start() {
1025
- if (this.stopped || this.proc) return;
1026
- const args = buildPassthroughArgs(this.cfg);
1027
- const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1028
- this.proc = proc;
1029
- this.startedAt = Date.now();
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.stopped = true;
1071
- if (this.restartTimer) {
1072
- clearTimeout(this.restartTimer);
1073
- this.restartTimer = null;
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
- const proc = this.proc;
1076
- this.proc = null;
1077
- if (!proc) return;
1066
+ if (this.pending.size === 0) return;
1067
+ this.draining = true;
1078
1068
  try {
1079
- proc.kill("SIGTERM");
1080
- } catch {}
1081
- setTimeout(() => {
1082
- if (proc.exitCode === null && proc.signalCode === null) try {
1083
- proc.kill("SIGKILL");
1084
- } catch {}
1085
- }, KILL_GRACE_MS).unref?.();
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/readiness-restore.ts
1295
- var ReadinessRestore = class {
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
- pending = /* @__PURE__ */ new Map();
1298
- draining = false;
1299
- rerun = false;
1300
- unsub = null;
1301
- constructor(deps) {
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
- /** Seed the pending set, subscribe to readiness, and drain once immediately
1305
- * (covers the already-ready case). Returns when the first drain settles. */
1306
- async start(items) {
1307
- for (const [k, v] of items) this.pending.set(k, v);
1308
- if (this.unsub === null) this.unsub = this.deps.subscribeReady(() => {
1309
- this.drain();
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.unsub?.();
1324
- this.unsub = null;
1325
- this.pending.clear();
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
- if (this.pending.size === 0) return;
1341
- this.draining = true;
1406
+ const proc = this.proc;
1407
+ this.proc = null;
1408
+ if (!proc) return;
1342
1409
  try {
1343
- do {
1344
- this.rerun = false;
1345
- for (const [key, value] of Array.from(this.pending)) try {
1346
- await this.deps.attempt(key, value);
1347
- this.pending.delete(key);
1348
- } catch (err) {
1349
- this.deps.onDefer?.(key, value, err);
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}`);