@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,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-tzhTTRq4.mjs";
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/addon/ffmpeg-args.ts
958
- /** ffmpeg argv: pull RTSP (TCP), copy video / transcode audio to AAC, write
959
- * fragmented-mp4 segments named by epoch seconds FLAT under `outDir`, plus a
960
- * rolling `live.m3u8` playlist. The recorder segment-watcher tails the
961
- * playlist and hands each finalized flat file to `SegmentStore.onFinalized`,
962
- * which stats + relocates it into the bucket tree (`segmentRelPath`). */
963
- function buildPassthroughArgs(a) {
964
- return [
965
- "-rtsp_transport",
966
- "tcp",
967
- "-user_agent",
968
- "recorder",
969
- "-i",
970
- a.rtspUrl,
971
- "-map",
972
- "0",
973
- "-c:v",
974
- "copy",
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/segment-writer.ts
998
- /** Max CONSECUTIVE rapid restarts before giving up (reset by a stable run). */
999
- var MAX_RESTARTS = 10;
1000
- /** How many trailing ffmpeg stderr lines to keep for the give-up diagnostic. */
1001
- var STDERR_TAIL_LINES = 12;
1002
- /** Restart backoff: base delay, doubled per consecutive attempt, capped. */
1003
- var RESTART_BASE_MS = 500;
1004
- var RESTART_MAX_MS = 1e4;
1005
- /** A process that ran at least this long is "stable" reset the restart count,
1006
- * so sporadic blips over a long lifetime never accumulate into a give-up. */
1007
- var STABLE_RUN_MS = 3e4;
1008
- /** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
1009
- var KILL_GRACE_MS = 500;
1010
- /** Supervises one passthrough ffmpeg for a single (camera, profile). */
1011
- var SegmentWriter = class {
1012
- cfg;
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
- proc = null;
1015
- stopped = false;
1016
- restarts = 0;
1017
- restartTimer = null;
1018
- startedAt = 0;
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
- start() {
1024
- if (this.stopped || this.proc) return;
1025
- const args = buildPassthroughArgs(this.cfg);
1026
- const proc = this.deps.spawn(this.deps.ffmpegPath ?? "ffmpeg", args);
1027
- this.proc = proc;
1028
- this.startedAt = Date.now();
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.stopped = true;
1070
- if (this.restartTimer) {
1071
- clearTimeout(this.restartTimer);
1072
- this.restartTimer = null;
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
- const proc = this.proc;
1075
- this.proc = null;
1076
- if (!proc) return;
1065
+ if (this.pending.size === 0) return;
1066
+ this.draining = true;
1077
1067
  try {
1078
- proc.kill("SIGTERM");
1079
- } catch {}
1080
- setTimeout(() => {
1081
- if (proc.exitCode === null && proc.signalCode === null) try {
1082
- proc.kill("SIGKILL");
1083
- } catch {}
1084
- }, KILL_GRACE_MS).unref?.();
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/readiness-restore.ts
1294
- var ReadinessRestore = class {
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
- pending = /* @__PURE__ */ new Map();
1297
- draining = false;
1298
- rerun = false;
1299
- unsub = null;
1300
- constructor(deps) {
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
- /** Seed the pending set, subscribe to readiness, and drain once immediately
1304
- * (covers the already-ready case). Returns when the first drain settles. */
1305
- async start(items) {
1306
- for (const [k, v] of items) this.pending.set(k, v);
1307
- if (this.unsub === null) this.unsub = this.deps.subscribeReady(() => {
1308
- this.drain();
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.unsub?.();
1323
- this.unsub = null;
1324
- this.pending.clear();
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
- if (this.pending.size === 0) return;
1340
- this.draining = true;
1405
+ const proc = this.proc;
1406
+ this.proc = null;
1407
+ if (!proc) return;
1341
1408
  try {
1342
- do {
1343
- this.rerun = false;
1344
- for (const [key, value] of Array.from(this.pending)) try {
1345
- await this.deps.attempt(key, value);
1346
- this.pending.delete(key);
1347
- } catch (err) {
1348
- this.deps.onDefer?.(key, value, err);
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}`);