@camstack/addon-pipeline 1.2.102 → 1.2.104

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 (52) hide show
  1. package/dist/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +844 -110
  5. package/dist/detection-pipeline/index.mjs +842 -108
  6. package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
  7. package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
  8. package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
  9. package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
  10. package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +8 -6
  14. package/dist/pipeline-runner/index.mjs +6 -4
  15. package/dist/{process-memory-DOjQ3MgC.js → process-memory-DY4RHcTj.js} +1 -1
  16. package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.mjs} +1 -1
  17. package/dist/recorder/index.js +660 -130
  18. package/dist/recorder/index.mjs +659 -129
  19. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  20. package/dist/remote-restream-CO36Sr30.js +36 -0
  21. package/dist/restream-intent-B4BXZra7.mjs +72 -0
  22. package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
  23. package/dist/session-decode/decode-worker-child.js +2 -2
  24. package/dist/session-decode/decode-worker-child.mjs +1 -1
  25. package/dist/stream-broker/_stub.js +1 -1
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZaqZ17-x.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BnhMnnKV.mjs +26 -0
  28. package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
  29. package/dist/stream-broker/index.js +628 -92
  30. package/dist/stream-broker/index.mjs +627 -91
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
  33. package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
  34. package/package.json +1 -1
  35. package/python/inference_pool.py +193 -1
  36. package/python/postprocessors/__init__.py +4 -0
  37. package/python/postprocessors/ctc.py +52 -0
  38. package/python/postprocessors/plate_slots.py +180 -0
  39. package/python/postprocessors/test_ctc.py +39 -0
  40. package/python/postprocessors/test_plate_slots.py +217 -0
  41. package/python/postprocessors/test_yolonas.py +83 -0
  42. package/python/postprocessors/yolonas.py +67 -0
  43. package/python/test_inference_pool_backpressure.py +6 -2
  44. package/python/test_inference_pool_coreml_cache.py +12 -4
  45. package/python/test_inference_pool_device_selection.py +12 -4
  46. package/python/test_inference_pool_layout.py +16 -5
  47. package/python/test_inference_pool_memstats.py +6 -2
  48. package/python/test_inference_pool_ov_ppp.py +12 -9
  49. package/python/test_inference_pool_preprocess.py +57 -0
  50. package/python/test_inference_pool_static_batch.py +426 -0
  51. package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
  52. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs +0 -26
@@ -1,6 +1,7 @@
1
- const require_dist = require("../dist-C11WuNUP.js");
1
+ const require_dist = require("../dist-gLAVhbvO.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
- const require_addon_utils = require("../addon-utils-CLc6yHCN.js");
3
+ const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
+ const require_addon_utils = require("../addon-utils-DRbRzrDy.js");
4
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
5
6
  let node_crypto = require("node:crypto");
6
7
  let node_child_process = require("node:child_process");
@@ -2802,12 +2803,23 @@ function withStreamTimeDenseRanges(input) {
2802
2803
  * `outputFps`, always silent.
2803
2804
  * Distinct from `buildPassthroughArgs` (live segmentation) — do not conflate.
2804
2805
  *
2805
- * A **timelapse of a long HLS concat** is two argv sets, not one. Pass 1
2806
- * (`buildConcatPassArgs`) remuxes the playlist to a single mpegts with `-c
2807
- * copy`. Pass 2 (`buildExportArgs` with that file as `-i`) then selects
2808
- * frames. Selecting WHILE walking `#EXT-X-DISCONTINUITY` (one per 10s
2809
- * segment from `-reset_timestamps`) is what parked a 30h salone job at
2810
- * `rendering 0%` / 0% CPU — ffmpeg never left the concat demuxer.
2806
+ * A **timelapse of a long segment concat** is two argv sets, not one. Pass 1
2807
+ * (`buildConcatPassArgs`) remuxes the export's FFCONCAT list to a single
2808
+ * mpegts with `-c copy`. Pass 2 (`buildExportArgs` with that file as `-i`)
2809
+ * then selects frames. Selecting WHILE walking `#EXT-X-DISCONTINUITY` (one
2810
+ * per 10s segment from `-reset_timestamps`) is what parked a 30h salone job
2811
+ * at `rendering 0%` / 0% CPU — ffmpeg never left the concat demuxer.
2812
+ *
2813
+ * Pass 1 reads the ffconcat list, NOT the HLS playlist, because of WHOSE
2814
+ * clock the concatenation runs on. The dense-range map
2815
+ * (`export-dense-map.ts`) sums the segments' DECLARED `durMs`; the HLS
2816
+ * demuxer stitches segments by their CONTENT length. On footage whose PTS
2817
+ * span disagrees with its declared duration the two clocks drift apart
2818
+ * cumulatively — measured ~495 s by the end of camera 615's 10 h night of
2819
+ * 2026-08-20, which put every short dense range over a stretch that held no
2820
+ * detection. The concat demuxer's `duration` directive pins each segment's
2821
+ * start to the SAME cumulative sum the map computes, so the disagreement is
2822
+ * bounded per segment and never accumulates.
2811
2823
  */
2812
2824
  /** Widest [min,max] speed a single ffmpeg `atempo` filter accepts. Outside this
2813
2825
  * band audio is dropped rather than chained (kept simple + predictable). */
@@ -2882,11 +2894,36 @@ function concatPassOutPath(outPath) {
2882
2894
  const base = node_path.default.basename(outPath, node_path.default.extname(outPath));
2883
2895
  return node_path.default.join(dir, `${base}.concat.ts`);
2884
2896
  }
2885
- /** Pass 1 of a timelapse: remux the HLS playlist into one continuous file. */
2897
+ /** ffconcat `file` directive value: single-quoted, embedded quotes escaped the
2898
+ * shell way (`'\''`), which is what ffmpeg's concat demuxer parses. */
2899
+ function concatFileDirective(uri) {
2900
+ return `file '${uri.replaceAll("'", "'\\''")}'`;
2901
+ }
2902
+ /**
2903
+ * The FFCONCAT list pass 1 reads — one `file` + `duration` pair per segment.
2904
+ *
2905
+ * The `duration` line is load-bearing: it is what makes the concat demuxer
2906
+ * start segment k+1 at `Σ durMs[0..k]`, the exact cumulative sum
2907
+ * `recordedMsBefore` computes when the dense ranges are mapped. A segment
2908
+ * whose content is shorter than its declared duration leaves a sub-second
2909
+ * PTS hole; one that is longer overlaps the next start and the mpegts muxer
2910
+ * clamps it — both bounded per segment, neither cumulative. The HLS demuxer
2911
+ * this replaces had no declared clock at all and drifted without bound.
2912
+ */
2913
+ function buildExportConcatList(segments) {
2914
+ return `ffconcat version 1.0\n${segments.map((s) => `${concatFileDirective(s.uri)}\nduration ${sec(s.durMs / 1e3)}`).map((l) => `${l}\n`).join("")}`;
2915
+ }
2916
+ /** Pass 1 of a timelapse: remux the ffconcat list into one continuous file.
2917
+ * `-safe 0` because the list's paths are absolute — which is exactly what the
2918
+ * per-export source list contains (`export-source-playlist.ts`). */
2886
2919
  function buildConcatPassArgs(input) {
2887
2920
  return [
2921
+ "-f",
2922
+ "concat",
2923
+ "-safe",
2924
+ "0",
2888
2925
  "-i",
2889
- input.inputPlaylist,
2926
+ input.inputConcatList,
2890
2927
  "-c",
2891
2928
  "copy",
2892
2929
  "-an",
@@ -2905,7 +2942,7 @@ function buildExportArgs(input) {
2905
2942
  return [
2906
2943
  ...inArgs,
2907
2944
  "-vf",
2908
- `select='${selectPredicate(everySec, options.timelapse.dense)}',setpts=N/FRAME_RATE/TB,fps=${fps}`,
2945
+ `select='${selectPredicate(everySec, options.timelapse.dense)}',setpts=N/(${fps}*TB),fps=${fps}`,
2909
2946
  "-an",
2910
2947
  ...X264,
2911
2948
  ...FASTSTART,
@@ -3258,16 +3295,16 @@ var ExportEngine = class {
3258
3295
  return;
3259
3296
  }
3260
3297
  try {
3261
- await this.spawnRender(id, rec, playlist.path, this.streamTimeOptions(rec, playlist.segments));
3298
+ await this.spawnRender(id, rec, playlist, this.streamTimeOptions(rec, playlist.segments));
3262
3299
  } finally {
3263
- if (this.deps.cleanupPlaylist) try {
3264
- await this.deps.cleanupPlaylist(playlist.path);
3300
+ if (this.deps.cleanupPlaylist) for (const file of [playlist.path, playlist.concatPath]) try {
3301
+ await this.deps.cleanupPlaylist(file);
3265
3302
  } catch (err) {
3266
3303
  this.deps.logger.warn("export: source playlist cleanup failed", {
3267
3304
  tags: { deviceId: rec.deviceId },
3268
3305
  meta: {
3269
3306
  exportId: id,
3270
- playlist: playlist.path,
3307
+ playlist: file,
3271
3308
  error: err instanceof Error ? err.message : String(err)
3272
3309
  }
3273
3310
  });
@@ -3318,16 +3355,16 @@ var ExportEngine = class {
3318
3355
  exportId: id,
3319
3356
  deviceId: rec.deviceId,
3320
3357
  profile: rec.profile,
3321
- playlist,
3358
+ playlist: playlist.path,
3322
3359
  outPath
3323
3360
  }
3324
3361
  });
3325
3362
  if (options.timelapse) {
3326
- await this.spawnTimelapse(id, rec, playlist, outPath, options);
3363
+ await this.spawnTimelapse(id, rec, playlist.concatPath, outPath, options);
3327
3364
  return;
3328
3365
  }
3329
3366
  const args = buildExportArgs({
3330
- inputPlaylist: playlist,
3367
+ inputPlaylist: playlist.path,
3331
3368
  outPath,
3332
3369
  options
3333
3370
  });
@@ -3339,16 +3376,19 @@ var ExportEngine = class {
3339
3376
  await this.finish(id, ran.code, outPath, ran.stderrTail);
3340
3377
  }
3341
3378
  /**
3342
- * Concat the HLS pieces into one continuous file, THEN select frames.
3379
+ * Concat the recorded pieces into one continuous file, THEN select frames.
3343
3380
  * The select filter on a discontinuity-heavy playlist is what parked a
3344
3381
  * 30h job at `rendering 0%` — pass 1 is `-c copy` into mpegts so pass 2
3345
- * sees a single timestamp line.
3382
+ * sees a single timestamp line. The input is the export's FFCONCAT list
3383
+ * (declared per-segment durations), never the HLS playlist, so that single
3384
+ * timestamp line runs on the SAME clock the dense map summed — see
3385
+ * `export-ffmpeg-args.ts` for what the HLS demuxer's content-stitching cost.
3346
3386
  */
3347
- async spawnTimelapse(id, rec, playlist, outPath, options) {
3387
+ async spawnTimelapse(id, rec, concatList, outPath, options) {
3348
3388
  const concatPath = concatPassOutPath(outPath);
3349
3389
  try {
3350
3390
  const concatArgs = buildConcatPassArgs({
3351
- inputPlaylist: playlist,
3391
+ inputConcatList: concatList,
3352
3392
  outPath: concatPath
3353
3393
  });
3354
3394
  const concatRan = await this.runFfmpeg(id, rec.deviceId, concatArgs, {
@@ -4670,6 +4710,13 @@ function exportSourcePlaylistPath(exportsDir, deviceId, exportId) {
4670
4710
  return node_path.default.join(exportsDir, String(deviceId), `${exportId}.src.m3u8`);
4671
4711
  }
4672
4712
  /**
4713
+ * Absolute path of the same export's FFCONCAT list — the timelapse pass-1
4714
+ * input (`buildConcatPassArgs`). Sibling of the m3u8, same uniqueness rules.
4715
+ */
4716
+ function exportSourceConcatPath(exportsDir, deviceId, exportId) {
4717
+ return node_path.default.join(exportsDir, String(deviceId), `${exportId}.src.ffconcat`);
4718
+ }
4719
+ /**
4673
4720
  * Write the export's source playlist and return it, or null when no segment of
4674
4721
  * the requested profile is present on disk for the range (the engine then fails
4675
4722
  * the job with 'no footage for range').
@@ -4678,10 +4725,16 @@ async function writeExportSourcePlaylist(input) {
4678
4725
  const { segments } = await collectRangeSegments(input.deps, input.deviceId, input.profile, input.fromMs, input.toMs, "absolute");
4679
4726
  if (segments.length === 0) return null;
4680
4727
  const playlistPath = exportSourcePlaylistPath(input.exportsDir, input.deviceId, input.exportId);
4728
+ const concatPath = exportSourceConcatPath(input.exportsDir, input.deviceId, input.exportId);
4681
4729
  await node_fs.promises.mkdir(node_path.default.dirname(playlistPath), { recursive: true });
4682
4730
  await node_fs.promises.writeFile(playlistPath, buildVariantPlaylist(segments), "utf8");
4731
+ await node_fs.promises.writeFile(concatPath, buildExportConcatList(segments.map((s) => ({
4732
+ uri: s.uri,
4733
+ durMs: s.durMs
4734
+ }))), "utf8");
4683
4735
  return {
4684
4736
  path: playlistPath,
4737
+ concatPath,
4685
4738
  segments: segments.map((s) => ({
4686
4739
  startMs: s.startMs,
4687
4740
  durMs: s.durMs
@@ -4799,6 +4852,7 @@ async function renderFootage(renderDeps, input, ext, buildArgs) {
4799
4852
  };
4800
4853
  } finally {
4801
4854
  await node_fs.promises.rm(playlist.path, { force: true }).catch(() => {});
4855
+ await node_fs.promises.rm(playlist.concatPath, { force: true }).catch(() => {});
4802
4856
  await node_fs.promises.rm(outPath, { force: true }).catch(() => {});
4803
4857
  }
4804
4858
  }
@@ -6276,6 +6330,82 @@ function eventsBandCanEverDemand(band) {
6276
6330
  if (band.mode !== "events") return true;
6277
6331
  return (band.triggers?.motion === true || band.triggers?.audioThresholdDbfs !== void 0) && resolveBandBufferMs(band).postMs > 0;
6278
6332
  }
6333
+ /** No trigger has been seen for this device yet. */
6334
+ var EMPTY_TRIGGER_STATE = {
6335
+ motion: [],
6336
+ audio: []
6337
+ };
6338
+ /**
6339
+ * How long a window is retained PAST the end of its own keep span
6340
+ * (`lastMs + postBufferSec`).
6341
+ *
6342
+ * A window may still be needed by a segment that has not been finalized yet.
6343
+ * The oldest such segment started one `segmentSeconds` ago, was named up to
6344
+ * {@link FIRST_SEGMENT_GRACE_MS} after the writer spawned, and is handed to the
6345
+ * keep gate after the finalize lag. 120 s covers a 60 s segment plus both, with
6346
+ * room to spare, while bounding a busy camera to a handful of windows.
6347
+ */
6348
+ var TRIGGER_HISTORY_MARGIN_MS = 12e4;
6349
+ /** The windows for a source, in the order they opened. */
6350
+ function windowsFor(state, source) {
6351
+ return source === "motion" ? state.motion : state.audio;
6352
+ }
6353
+ function withWindows(state, source, windows) {
6354
+ return source === "motion" ? {
6355
+ ...state,
6356
+ motion: windows
6357
+ } : {
6358
+ ...state,
6359
+ audio: windows
6360
+ };
6361
+ }
6362
+ /**
6363
+ * Fold one qualifying trigger into the history, immutably. It EXTENDS the newest
6364
+ * window when it lands within `postBufferSec` of it (same episode — the recorder
6365
+ * never stopped in between), and opens a new one otherwise.
6366
+ *
6367
+ * An out-of-order trigger (`atMs` older than the newest window's `lastMs`, which
6368
+ * a bus lag spike can produce) never rewinds `lastMs`: it widens `firstMs` if it
6369
+ * predates the episode, so the keep span can only ever grow.
6370
+ */
6371
+ function recordTrigger(state, source, atMs, mergeGapMs) {
6372
+ const windows = windowsFor(state, source);
6373
+ const newest = windows[windows.length - 1];
6374
+ const merged = newest !== void 0 && atMs - newest.lastMs <= mergeGapMs && atMs >= newest.firstMs - mergeGapMs ? [...windows.slice(0, -1), {
6375
+ firstMs: Math.min(newest.firstMs, atMs),
6376
+ lastMs: Math.max(newest.lastMs, atMs)
6377
+ }] : [...windows, {
6378
+ firstMs: atMs,
6379
+ lastMs: atMs
6380
+ }];
6381
+ const evicted = Math.max(0, merged.length - 32);
6382
+ return {
6383
+ state: withWindows(state, source, evicted > 0 ? merged.slice(evicted) : merged),
6384
+ evicted
6385
+ };
6386
+ }
6387
+ /**
6388
+ * Drop windows whose keep span ended more than {@link TRIGGER_HISTORY_MARGIN_MS}
6389
+ * ago — nothing still to be finalized can intersect them. Pure; the caller
6390
+ * clocks it. Returns the SAME object when nothing expired, so a caller can skip
6391
+ * a write.
6392
+ */
6393
+ function pruneTriggerHistory(state, postMs, nowMs) {
6394
+ const cutoffMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
6395
+ const live = (w) => w.lastMs >= cutoffMs;
6396
+ const motion = state.motion.filter(live);
6397
+ const audio = state.audio.filter(live);
6398
+ if (motion.length === state.motion.length && audio.length === state.audio.length) return state;
6399
+ return {
6400
+ motion,
6401
+ audio
6402
+ };
6403
+ }
6404
+ /** Most recent trigger for a source, or null when none is retained. */
6405
+ function latestTriggerMs(state, source) {
6406
+ const windows = windowsFor(state, source);
6407
+ return windows[windows.length - 1]?.lastMs ?? null;
6408
+ }
6279
6409
  /**
6280
6410
  * Is an `events` band's live demand window open at `atMs`? True iff a trigger
6281
6411
  * the band listens for fired within `postBufferSec` of now. `preBufferSec` is
@@ -6284,8 +6414,8 @@ function eventsBandCanEverDemand(band) {
6284
6414
  function eventsBandDemanded(band, triggers, atMs) {
6285
6415
  const { postMs } = resolveBandBufferMs(band);
6286
6416
  const fresh = (lastMs) => lastMs != null && atMs - lastMs <= postMs;
6287
- if (band.triggers?.motion && fresh(triggers.lastMotionMs)) return true;
6288
- if (band.triggers?.audioThresholdDbfs != null && fresh(triggers.lastAudioMs)) return true;
6417
+ if (band.triggers?.motion && fresh(latestTriggerMs(triggers, "motion"))) return true;
6418
+ if (band.triggers?.audioThresholdDbfs != null && fresh(latestTriggerMs(triggers, "audio"))) return true;
6289
6419
  return false;
6290
6420
  }
6291
6421
  /**
@@ -6352,14 +6482,13 @@ function attachRetainDeadlineMs(attach) {
6352
6482
  function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach) {
6353
6483
  const { preMs, postMs } = resolveBandBufferMs(band);
6354
6484
  const deadlineMs = attachRetainDeadlineMs(attach);
6355
- const overlaps = (lastMs) => {
6356
- if (lastMs == null) return false;
6357
- if (segEndMs < lastMs - preMs) return false;
6358
- if (segStartMs <= lastMs + postMs) return true;
6359
- return attach.attachedAtMs <= lastMs + postMs && segStartMs <= deadlineMs;
6485
+ const overlaps = (window) => {
6486
+ if (segEndMs < window.firstMs - preMs) return false;
6487
+ if (segStartMs <= window.lastMs + postMs) return true;
6488
+ return attach.attachedAtMs <= window.lastMs + postMs && segStartMs <= deadlineMs;
6360
6489
  };
6361
- if (band.triggers?.motion && overlaps(triggers.lastMotionMs)) return true;
6362
- if (band.triggers?.audioThresholdDbfs != null && overlaps(triggers.lastAudioMs)) return true;
6490
+ if (band.triggers?.motion && triggers.motion.some(overlaps)) return true;
6491
+ if (band.triggers?.audioThresholdDbfs != null && triggers.audio.some(overlaps)) return true;
6363
6492
  return false;
6364
6493
  }
6365
6494
  /**
@@ -6378,14 +6507,137 @@ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach)
6378
6507
  function segmentMissedByMs(segStartMs, band, triggers, attach) {
6379
6508
  const { postMs } = resolveBandBufferMs(band);
6380
6509
  const deadlineMs = attachRetainDeadlineMs(attach);
6381
- const latestKeptStart = (lastMs) => {
6382
- if (lastMs == null) return null;
6383
- const windowEndMs = lastMs + postMs;
6510
+ const latestKeptStart = (window) => {
6511
+ const windowEndMs = window.lastMs + postMs;
6384
6512
  return attach.attachedAtMs <= windowEndMs ? Math.max(windowEndMs, deadlineMs) : windowEndMs;
6385
6513
  };
6386
- const bounds = [band.triggers?.motion === true ? latestKeptStart(triggers.lastMotionMs) : null, band.triggers?.audioThresholdDbfs != null ? latestKeptStart(triggers.lastAudioMs) : null].filter((ms) => ms != null);
6387
- if (bounds.length === 0) return null;
6388
- return segStartMs - Math.max(...bounds);
6514
+ const considered = [...band.triggers?.motion === true ? triggers.motion : [], ...band.triggers?.audioThresholdDbfs != null ? triggers.audio : []];
6515
+ if (considered.length === 0) return null;
6516
+ return segStartMs - Math.max(...considered.map(latestKeptStart));
6517
+ }
6518
+ /**
6519
+ * Fold DURABLE trigger rows (the analytics store's motion timestamps) into the
6520
+ * history, immutably.
6521
+ *
6522
+ * ## Why this exists next to {@link recordTrigger}
6523
+ *
6524
+ * `recordTrigger` is fed by the event bus, and an event is telemetry that may
6525
+ * be DROPPED (D8/D11). What it feeds is not telemetry: a window the gate never
6526
+ * learned about means every segment of that episode is unlinked, and footage
6527
+ * deleted is gone. D11's own rule for that shape is "an event plus an RPC
6528
+ * reconcile", and this is the reconcile half — the same merge, over rows that
6529
+ * were persisted rather than broadcast.
6530
+ *
6531
+ * It is deliberately the SAME merge and not a second policy: a reconcile that
6532
+ * built windows by a different rule than the fast path would produce keep spans
6533
+ * that depend on which path saw the trigger, which is the two-authorities
6534
+ * failure D62 names. `recordTrigger` is called per row.
6535
+ *
6536
+ * Rows are sorted before folding: a store is free to answer newest-first, and
6537
+ * while `recordTrigger` tolerates one out-of-order arrival, a whole batch in
6538
+ * reverse would open a window per row instead of merging one episode.
6539
+ * Non-finite and non-positive timestamps are dropped rather than folded — a
6540
+ * window opened at `NaN` compares false against every segment for ever.
6541
+ */
6542
+ function foldDurableTriggers(state, source, timestamps, mergeGapMs) {
6543
+ const rows = timestamps.filter((t) => Number.isFinite(t) && t > 0).toSorted((a, b) => a - b);
6544
+ let next = state;
6545
+ let recovered = 0;
6546
+ let evicted = 0;
6547
+ for (const atMs of rows) {
6548
+ const before = windowsFor(next, source);
6549
+ const folded = recordTrigger(next, source, atMs, mergeGapMs);
6550
+ evicted += folded.evicted;
6551
+ const after = windowsFor(folded.state, source);
6552
+ if (folded.evicted > 0 || after.length !== before.length || after.some((w, i) => w.firstMs !== before[i]?.firstMs || w.lastMs !== before[i]?.lastMs)) recovered++;
6553
+ next = folded.state;
6554
+ }
6555
+ return {
6556
+ state: recovered === 0 && evicted === 0 ? state : next,
6557
+ recovered,
6558
+ evicted
6559
+ };
6560
+ }
6561
+ //#endregion
6562
+ //#region src/recorder/addon/battery-wake.ts
6563
+ /**
6564
+ * Waking a BATTERY camera for a recording trigger.
6565
+ *
6566
+ * ## Why the recorder needs this at all
6567
+ *
6568
+ * A battery camera is asleep between events. Every other demand consumer in
6569
+ * this system already wakes it — the live/WebRTC path does, through
6570
+ * `battery.wakeForStream` (D173) — but the recorder did not, so an `events`
6571
+ * band pointed at a battery camera attached to a broker with no source and
6572
+ * produced nothing. Measured on camera 3629 (audit 2026-08-20): **48.9 % of its
6573
+ * episodes recorded zero footage.**
6574
+ *
6575
+ * A recording trigger IS demand, in exactly the sense D173 defines: something
6576
+ * the operator asked for is happening NOW and needs media. It therefore takes
6577
+ * the demand path — which bypasses the provider's proactive-wake cooldown while
6578
+ * still stamping it — and not the proactive path.
6579
+ *
6580
+ * ## What this module deliberately does NOT do
6581
+ *
6582
+ * - It does not re-derive presence. `deriveBatteryPresence` is THE derivation
6583
+ * (D52); a caller writing `sleeping === true` has undone the third state that
6584
+ * exists so a nap stops being rendered as breakage.
6585
+ * - It does not guess battery-operation. An UNKNOWN verdict never wakes (D49):
6586
+ * a failed registry read must not look like a battery camera.
6587
+ * - It does not wait for the broker to settle. The recorder already has that
6588
+ * machinery — a failed attach queues on the readiness-restore queue and is
6589
+ * retried on the next broker-ready transition, with the periodic reconcile as
6590
+ * backstop. Duplicating `battery-wake-on-play.ts`'s settle loop here would be
6591
+ * a second implementation of a convergence this addon already owns.
6592
+ */
6593
+ /**
6594
+ * Bound on the wake itself, matched to the live path's
6595
+ * `WAKE_FOR_STREAM_TIMEOUT_MS`. Deliberately the same number: a battery camera
6596
+ * does not wake faster because a recorder asked instead of a viewer, and two
6597
+ * different bounds for one physical act is the kind of drift that makes a
6598
+ * timeout unattributable.
6599
+ */
6600
+ var WAKE_FOR_RECORDING_TIMEOUT_MS = 8e3;
6601
+ /**
6602
+ * Wake `deviceId` if it is a sleeping battery camera. Never throws — a wake
6603
+ * that fails must degrade to "attach anyway and let the existing retry
6604
+ * machinery converge", never to a rejection escaping into the trigger path
6605
+ * (that path is fire-and-forget and an escape prints a runner CRASH marker).
6606
+ */
6607
+ async function wakeForRecording(deviceId, deps) {
6608
+ const isBattery = await deps.isBatteryDevice(deviceId).catch(() => null);
6609
+ if (isBattery === false) return { outcome: "not-battery" };
6610
+ if (isBattery === null) return { outcome: "unknown" };
6611
+ const facts = await deps.readBatteryFacts(deviceId).catch(() => null);
6612
+ const presence = require_dist.deriveBatteryPresence({
6613
+ status: facts === null ? null : {
6614
+ sleeping: facts.sleeping,
6615
+ lastContactAt: facts.lastContactAt
6616
+ },
6617
+ nowMs: deps.nowMs()
6618
+ });
6619
+ if (presence === "awake") return { outcome: "already-awake" };
6620
+ if (presence === "unreachable") return { outcome: "unreachable" };
6621
+ try {
6622
+ const wake = await deps.wakeForStream(deviceId, WAKE_FOR_RECORDING_TIMEOUT_MS);
6623
+ return {
6624
+ outcome: wake.awoke ? "woken" : "did-not-wake",
6625
+ durationMs: wake.durationMs
6626
+ };
6627
+ } catch (err) {
6628
+ return {
6629
+ outcome: "wake-failed",
6630
+ error: err instanceof Error ? err.message : String(err)
6631
+ };
6632
+ }
6633
+ }
6634
+ /**
6635
+ * Did this outcome leave the camera unable to produce footage for the episode
6636
+ * that asked? Those are the branches that DROP work, and the caller warns on
6637
+ * them — camera 3629 lost half its episodes without a single line saying so.
6638
+ */
6639
+ function wakeOutcomeLosesFootage(outcome) {
6640
+ return outcome === "unreachable" || outcome === "did-not-wake" || outcome === "wake-failed";
6389
6641
  }
6390
6642
  /** Local-calendar instant for `HH:MM` on the given local Y/M/D (day may overflow). */
6391
6643
  function localInstantMs(year, month, day, hhmm) {
@@ -6444,8 +6696,10 @@ function eventsWindowCloseMs(band, triggers, nowMs) {
6444
6696
  if (band.mode !== "events") return null;
6445
6697
  const { postMs } = resolveBandBufferMs(band);
6446
6698
  const ends = [];
6447
- if (band.triggers?.motion === true && triggers.lastMotionMs != null) ends.push(triggers.lastMotionMs + postMs);
6448
- if (band.triggers?.audioThresholdDbfs != null && triggers.lastAudioMs != null) ends.push(triggers.lastAudioMs + postMs);
6699
+ const motionMs = latestTriggerMs(triggers, "motion");
6700
+ const audioMs = latestTriggerMs(triggers, "audio");
6701
+ if (band.triggers?.motion === true && motionMs != null) ends.push(motionMs + postMs);
6702
+ if (band.triggers?.audioThresholdDbfs != null && audioMs != null) ends.push(audioMs + postMs);
6449
6703
  const open = ends.filter((ms) => ms >= nowMs);
6450
6704
  if (open.length === 0) return null;
6451
6705
  return Math.max(...open) + 1;
@@ -6656,70 +6910,6 @@ var ReadinessRestore = class {
6656
6910
  }
6657
6911
  }
6658
6912
  };
6659
- /** Adaptive bound for the recorder's attach parallelism. */
6660
- var SpawnPressureGovernor = class {
6661
- deps;
6662
- limit;
6663
- min;
6664
- recoverAfterMs;
6665
- /** When the limit last MOVED — the clock both directions are measured from. */
6666
- lastChangeAt;
6667
- underPressure = false;
6668
- constructor(deps) {
6669
- this.deps = deps;
6670
- this.min = Math.max(1, deps.minConcurrency ?? 1);
6671
- this.limit = Math.max(this.min, deps.maxConcurrency);
6672
- this.recoverAfterMs = deps.recoverAfterMs ?? 3e4;
6673
- this.lastChangeAt = this.now();
6674
- }
6675
- /**
6676
- * How many attaches may overlap right now.
6677
- *
6678
- * Reading is what drives recovery: there is no timer to leak, no interval to
6679
- * lose to a runner respawn, and a recorder that stops attaching entirely
6680
- * simply keeps its reduced bound until it tries again — which is correct.
6681
- */
6682
- get concurrency() {
6683
- const max = Math.max(this.min, this.deps.maxConcurrency);
6684
- if (this.limit >= max) return max;
6685
- const elapsed = this.now() - this.lastChangeAt;
6686
- if (elapsed < this.recoverAfterMs) return this.limit;
6687
- const lanes = Math.floor(elapsed / this.recoverAfterMs);
6688
- this.limit = Math.min(max, this.limit + lanes);
6689
- this.lastChangeAt += lanes * this.recoverAfterMs;
6690
- if (this.limit >= max && this.underPressure) {
6691
- this.underPressure = false;
6692
- this.deps.logger?.info("recorder: spawn pressure cleared — attach concurrency restored", { meta: { concurrency: this.limit } });
6693
- } else this.deps.logger?.info("recorder: recovering attach concurrency", { meta: {
6694
- concurrency: this.limit,
6695
- max
6696
- } });
6697
- return this.limit;
6698
- }
6699
- /**
6700
- * A transient spawn failure happened on `deviceId`'s writer.
6701
- *
6702
- * Halves the CURRENT limit, not the max: two waves of pressure inside one
6703
- * recovery window must compound, or a host under sustained load is asked for
6704
- * the same too-large batch over and over.
6705
- */
6706
- report(deviceId) {
6707
- const before = this.limit;
6708
- this.limit = Math.max(this.min, Math.floor(this.limit / 2));
6709
- this.lastChangeAt = this.now();
6710
- this.underPressure = true;
6711
- this.deps.logger?.warn("recorder: spawn pressure — reducing attach concurrency", {
6712
- tags: { deviceId },
6713
- meta: {
6714
- from: before,
6715
- to: this.limit
6716
- }
6717
- });
6718
- }
6719
- now() {
6720
- return this.deps.now?.() ?? Date.now();
6721
- }
6722
- };
6723
6913
  //#endregion
6724
6914
  //#region src/recorder/addon/segment-watcher.ts
6725
6915
  /**
@@ -6762,17 +6952,6 @@ var EPOCH_NAME_RE = /^(\d+)\.m4s$/;
6762
6952
  */
6763
6953
  var PENDING_GRACE_MS = 3e4;
6764
6954
  /**
6765
- * How far before the writer's own start a segment may be named and still be
6766
- * treated as belonging to THIS run.
6767
- *
6768
- * ffmpeg names segments with whole-second `%s`, so the first segment of a run
6769
- * carries the second that was already in progress when the process spawned —
6770
- * up to ~1 s "before" the writer started. Without this tolerance the very first
6771
- * segment of every recording would be classified as a previous run's leftover
6772
- * and dropped.
6773
- */
6774
- var PREVIOUS_RUN_SKEW_MS$1 = 1e3;
6775
- /**
6776
6955
  * Decide whether a missing flat file is a late flush or a previous run's entry.
6777
6956
  *
6778
6957
  * ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
@@ -6793,7 +6972,7 @@ var PREVIOUS_RUN_SKEW_MS$1 = 1e3;
6793
6972
  * entries a playlist does name.
6794
6973
  */
6795
6974
  function classifyMissingEntry(input) {
6796
- return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS$1 ? "stale-previous-run" : "pending";
6975
+ return input.segmentStartMs < input.writerStartedMs - 1e3 ? "stale-previous-run" : "pending";
6797
6976
  }
6798
6977
  /** Whether a pending entry has outlived its grace. Pure — the caller clocks it. */
6799
6978
  function shouldGiveUpOnPending(input) {
@@ -7026,7 +7205,7 @@ function buildPassthroughArgs(a) {
7026
7205
  "-rtsp_transport",
7027
7206
  "tcp",
7028
7207
  "-user_agent",
7029
- "recorder",
7208
+ a.consumerTag,
7030
7209
  "-i",
7031
7210
  a.rtspUrl,
7032
7211
  "-map",
@@ -7218,6 +7397,70 @@ var SegmentWriter = class {
7218
7397
  await exitWait;
7219
7398
  }
7220
7399
  };
7400
+ /** Adaptive bound for the recorder's attach parallelism. */
7401
+ var SpawnPressureGovernor = class {
7402
+ deps;
7403
+ limit;
7404
+ min;
7405
+ recoverAfterMs;
7406
+ /** When the limit last MOVED — the clock both directions are measured from. */
7407
+ lastChangeAt;
7408
+ underPressure = false;
7409
+ constructor(deps) {
7410
+ this.deps = deps;
7411
+ this.min = Math.max(1, deps.minConcurrency ?? 1);
7412
+ this.limit = Math.max(this.min, deps.maxConcurrency);
7413
+ this.recoverAfterMs = deps.recoverAfterMs ?? 3e4;
7414
+ this.lastChangeAt = this.now();
7415
+ }
7416
+ /**
7417
+ * How many attaches may overlap right now.
7418
+ *
7419
+ * Reading is what drives recovery: there is no timer to leak, no interval to
7420
+ * lose to a runner respawn, and a recorder that stops attaching entirely
7421
+ * simply keeps its reduced bound until it tries again — which is correct.
7422
+ */
7423
+ get concurrency() {
7424
+ const max = Math.max(this.min, this.deps.maxConcurrency);
7425
+ if (this.limit >= max) return max;
7426
+ const elapsed = this.now() - this.lastChangeAt;
7427
+ if (elapsed < this.recoverAfterMs) return this.limit;
7428
+ const lanes = Math.floor(elapsed / this.recoverAfterMs);
7429
+ this.limit = Math.min(max, this.limit + lanes);
7430
+ this.lastChangeAt += lanes * this.recoverAfterMs;
7431
+ if (this.limit >= max && this.underPressure) {
7432
+ this.underPressure = false;
7433
+ this.deps.logger?.info("recorder: spawn pressure cleared — attach concurrency restored", { meta: { concurrency: this.limit } });
7434
+ } else this.deps.logger?.info("recorder: recovering attach concurrency", { meta: {
7435
+ concurrency: this.limit,
7436
+ max
7437
+ } });
7438
+ return this.limit;
7439
+ }
7440
+ /**
7441
+ * A transient spawn failure happened on `deviceId`'s writer.
7442
+ *
7443
+ * Halves the CURRENT limit, not the max: two waves of pressure inside one
7444
+ * recovery window must compound, or a host under sustained load is asked for
7445
+ * the same too-large batch over and over.
7446
+ */
7447
+ report(deviceId) {
7448
+ const before = this.limit;
7449
+ this.limit = Math.max(this.min, Math.floor(this.limit / 2));
7450
+ this.lastChangeAt = this.now();
7451
+ this.underPressure = true;
7452
+ this.deps.logger?.warn("recorder: spawn pressure — reducing attach concurrency", {
7453
+ tags: { deviceId },
7454
+ meta: {
7455
+ from: before,
7456
+ to: this.limit
7457
+ }
7458
+ });
7459
+ }
7460
+ now() {
7461
+ return this.deps.now?.() ?? Date.now();
7462
+ }
7463
+ };
7221
7464
  //#endregion
7222
7465
  //#region src/recorder/addon/recording-controller.ts
7223
7466
  /**
@@ -7363,6 +7606,21 @@ function passSlowAfterMs(intervalMs) {
7363
7606
  * promise, so a post-deadline settlement can never surface as a process-level
7364
7607
  * `unhandledRejection`.
7365
7608
  */
7609
+ /**
7610
+ * Bound on the two registry reads the battery-wake policy makes. They run
7611
+ * before any broker call on every attach, battery camera or not, so they are
7612
+ * the cheapest place for an unresponsive durable store to cost a recording.
7613
+ * Short on purpose: the answer is a cached registry field, not a camera.
7614
+ */
7615
+ var BATTERY_READ_TIMEOUT_MS = 3e3;
7616
+ /**
7617
+ * How stale a trigger may be on arrival before it is worth a warn. A recording
7618
+ * window is opened from `now`, but its POST-buffer is measured from the trigger
7619
+ * instant, so every millisecond of lag is a millisecond of the operator's
7620
+ * window already gone. 2 s is well clear of normal bus jitter and well under
7621
+ * the shipped 30 s post-buffer.
7622
+ */
7623
+ var TRIGGER_LAG_WARN_MS = 2e3;
7366
7624
  function withDeadline(promise, ms, label) {
7367
7625
  return new Promise((resolve, reject) => {
7368
7626
  const timer = setTimeout(() => {
@@ -7429,7 +7687,28 @@ var RecordingController = class {
7429
7687
  /** Every attach admitted before a maintenance pause. The pause waits these
7430
7688
  * promises before it snapshots/drains active writers. */
7431
7689
  attachingTasks = /* @__PURE__ */ new Map();
7432
- /** Latest qualifying trigger timestamps per device drives `events`-band gating. */
7690
+ /** `deviceId:profile` keys whose current attach has already logged its first
7691
+ * segment — the measurement is per ATTACH, not per segment. Cleared on
7692
+ * teardown with the rest of the per-attach state. */
7693
+ firstSegmentSeen = /* @__PURE__ */ new Set();
7694
+ /**
7695
+ * Monotonic attach counter, the last component of the CONSUMER TAG this
7696
+ * recorder puts on its RTSP dial (`recorder/<deviceId>/<profile>/<n>`).
7697
+ *
7698
+ * The tag is the join key between the broker's pre-roll accounting and the
7699
+ * files on disk. `deviceId/profile` alone is not enough: a device that
7700
+ * re-attaches names the same pair again, and two attaches minutes apart both
7701
+ * matching a broker line is exactly the ambiguity that made the 2026-08-20
7702
+ * measurements unreconcilable. A wall-clock stamp would collide under a fake
7703
+ * clock (and this controller's clock is injected); a counter cannot.
7704
+ */
7705
+ attachSeq = 0;
7706
+ /** In-flight battery wake per device — concurrent attaches join it rather
7707
+ * than issuing a second wake at the same radio. */
7708
+ batteryWakes = /* @__PURE__ */ new Map();
7709
+ /** Qualifying trigger HISTORY per device — drives `events`-band gating. A
7710
+ * bounded list of merged windows, NOT the last timestamp: see
7711
+ * {@link TriggerState}. */
7433
7712
  triggers = /* @__PURE__ */ new Map();
7434
7713
  /**
7435
7714
  * Last sign of life per `deviceId:profile` — seeded at attach (so the grace
@@ -7640,6 +7919,7 @@ var RecordingController = class {
7640
7919
  async reconcile() {
7641
7920
  if (this.stopped) return;
7642
7921
  await runBounded(await this.trackedDeviceIds(), this.spawnPressure.concurrency, async (id) => {
7922
+ await this.reconcileTriggerHistory(id);
7643
7923
  try {
7644
7924
  await this.evaluateDevice(id);
7645
7925
  } catch (err) {
@@ -7651,6 +7931,62 @@ var RecordingController = class {
7651
7931
  });
7652
7932
  }
7653
7933
  /**
7934
+ * Rebuild this device's motion trigger windows from the DURABLE rows.
7935
+ *
7936
+ * The trigger event is the fast path and stays so — this never opens a
7937
+ * recording any sooner. What it covers is the case the event path cannot: a
7938
+ * trigger the bus dropped, or one that arrived after its own window had
7939
+ * closed (a `busLagMs` of 142 173 has been measured on this cluster). In both
7940
+ * the keep gate has NO window for an episode that happened, so every segment
7941
+ * of it is discarded and the footage is gone for ever — durable state resting
7942
+ * on telemetry, which D8/D11 forbid.
7943
+ *
7944
+ * Motion only. Audio triggers have no durable counterpart the recorder can
7945
+ * ask for at the same granularity, and a reconcile that silently covered one
7946
+ * source and not the other would read as covering both.
7947
+ *
7948
+ * Never throws: this runs at the head of the reconcile beat and an escape
7949
+ * here would cancel the evaluate for the whole device.
7950
+ */
7951
+ async reconcileTriggerHistory(deviceId) {
7952
+ const load = this.deps.loadMotionEvents;
7953
+ if (load === void 0 || this.stopped) return;
7954
+ try {
7955
+ const config = await this.deps.loadConfig(deviceId);
7956
+ if (config.enabled !== true) return;
7957
+ const nowMs = this.now();
7958
+ const band = activeBandAt({ bands: config.bands ?? [] }, new Date(nowMs));
7959
+ if (!band || band.mode !== "events" || band.triggers?.motion !== true) return;
7960
+ const { postMs } = resolveBandBufferMs(band);
7961
+ const sinceMs = nowMs - postMs - TRIGGER_HISTORY_MARGIN_MS;
7962
+ const rows = await load(deviceId, sinceMs, nowMs);
7963
+ if (this.stopped || rows.length === 0) return;
7964
+ const folded = foldDurableTriggers(pruneTriggerHistory(this.triggersFor(deviceId), postMs, this.now()), "motion", rows, postMs);
7965
+ this.triggers.set(deviceId, folded.state);
7966
+ if (folded.recovered > 0) this.deps.logger.info("recorder: trigger windows recovered from the durable rows — the bus had not delivered them", {
7967
+ tags: { deviceId },
7968
+ meta: {
7969
+ rows: rows.length,
7970
+ recovered: folded.recovered,
7971
+ sinceMs,
7972
+ postMs
7973
+ }
7974
+ });
7975
+ if (folded.evicted > 0) this.deps.logger.warn("recorder: trigger history full during reconcile — oldest window(s) evicted", {
7976
+ tags: { deviceId },
7977
+ meta: {
7978
+ evicted: folded.evicted,
7979
+ maxWindows: 32
7980
+ }
7981
+ });
7982
+ } catch (err) {
7983
+ this.deps.logger.warn("recorder: trigger reconcile failed — retries on the next beat", {
7984
+ tags: { deviceId },
7985
+ meta: { error: require_dist.errMsg(err) }
7986
+ });
7987
+ }
7988
+ }
7989
+ /**
7654
7990
  * Re-evaluate one device after a config change (called from `setDeviceConfig`).
7655
7991
  * Failures propagate so the caller can surface them, but the persisted config
7656
7992
  * has already been written by the time this runs.
@@ -7723,10 +8059,7 @@ var RecordingController = class {
7723
8059
  }
7724
8060
  /** Current trigger state for a device (cold default = no triggers seen). */
7725
8061
  triggersFor(deviceId) {
7726
- return this.triggers.get(deviceId) ?? {
7727
- lastMotionMs: null,
7728
- lastAudioMs: null
7729
- };
8062
+ return this.triggers.get(deviceId) ?? EMPTY_TRIGGER_STATE;
7730
8063
  }
7731
8064
  /**
7732
8065
  * Record a qualifying trigger (motion/audio) for a device and converge: an
@@ -7743,11 +8076,17 @@ var RecordingController = class {
7743
8076
  */
7744
8077
  async onTrigger(deviceId, source, atMs) {
7745
8078
  if (this.stopped) return;
7746
- const prev = this.triggersFor(deviceId);
7747
- this.triggers.set(deviceId, {
7748
- lastMotionMs: source === "motion" ? atMs : prev.lastMotionMs,
7749
- lastAudioMs: source === "audio" ? atMs : prev.lastAudioMs
8079
+ const receivedLagMs = this.now() - atMs;
8080
+ if (receivedLagMs > TRIGGER_LAG_WARN_MS) this.deps.logger.warn("recorder: trigger arrived late — part of its window is already spent", {
8081
+ tags: { deviceId },
8082
+ meta: {
8083
+ source,
8084
+ atMs,
8085
+ receivedLagMs
8086
+ }
7750
8087
  });
8088
+ this.recordTriggerWindow(deviceId, source, atMs, await this.postBufferMsFor(deviceId, atMs));
8089
+ if (this.stopped) return;
7751
8090
  try {
7752
8091
  await this.evaluateDevice(deviceId);
7753
8092
  } catch (err) {
@@ -7762,6 +8101,48 @@ var RecordingController = class {
7762
8101
  }
7763
8102
  }
7764
8103
  /**
8104
+ * The post-buffer that governs this device's trigger history right now: it is
8105
+ * both the MERGE GAP (a trigger landing inside the open live window belongs to
8106
+ * the running episode) and the retention horizon. Falls back to the shipped
8107
+ * default when the config cannot be read — a trigger must never be lost to a
8108
+ * failed config read, and a default window is the conservative choice: it
8109
+ * keeps footage rather than dropping it.
8110
+ */
8111
+ async postBufferMsFor(deviceId, atMs) {
8112
+ try {
8113
+ const band = activeBandAt({ bands: (await this.deps.loadConfig(deviceId)).bands ?? [] }, new Date(atMs));
8114
+ if (band) return resolveBandBufferMs(band).postMs;
8115
+ } catch (err) {
8116
+ this.deps.logger.warn("recorder: trigger history using the DEFAULT post-buffer — config read failed", {
8117
+ tags: { deviceId },
8118
+ meta: {
8119
+ atMs,
8120
+ error: require_dist.errMsg(err)
8121
+ }
8122
+ });
8123
+ }
8124
+ return require_dist.DEFAULT_EVENTS_BAND_BUFFER_SEC.postBufferSec * 1e3;
8125
+ }
8126
+ /**
8127
+ * Fold a qualifying trigger into this device's history and prune what can no
8128
+ * longer keep anything. Synchronous and self-contained so the write cannot
8129
+ * interleave with an await.
8130
+ */
8131
+ recordTriggerWindow(deviceId, source, atMs, postMs) {
8132
+ const { state, evicted } = recordTrigger(pruneTriggerHistory(this.triggersFor(deviceId), postMs, this.now()), source, atMs, postMs);
8133
+ this.triggers.set(deviceId, state);
8134
+ if (evicted > 0) this.deps.logger.warn("recorder: trigger history full — oldest window(s) evicted, their footage may be discarded", {
8135
+ tags: { deviceId },
8136
+ meta: {
8137
+ source,
8138
+ atMs,
8139
+ postMs,
8140
+ evicted,
8141
+ maxWindows: 32
8142
+ }
8143
+ });
8144
+ }
8145
+ /**
7765
8146
  * Decide-and-converge for one device: if its active band demands recording now
7766
8147
  * (continuous always; events within `postBufferSec` of a trigger) and the
7767
8148
  * device is enabled, ensure its writer set is running; otherwise stop it.
@@ -7785,7 +8166,13 @@ var RecordingController = class {
7785
8166
  if (this.skippedProfiles.has(deviceId)) await this.fillMissingProfiles(deviceId, config);
7786
8167
  return;
7787
8168
  }
7788
- if (this.attaching.has(deviceId)) return;
8169
+ if (this.attaching.has(deviceId)) {
8170
+ this.deps.logger.debug("recorder: evaluate coalesced into an in-flight attach", {
8171
+ tags: { deviceId },
8172
+ meta: { reconcileTickMs: RECONCILE_TICK_MS }
8173
+ });
8174
+ return;
8175
+ }
7789
8176
  this.attaching.add(deviceId);
7790
8177
  const attaching = this.performAttach(deviceId, config);
7791
8178
  this.attachingTasks.set(deviceId, attaching);
@@ -7851,9 +8238,74 @@ var RecordingController = class {
7851
8238
  * ZERO footage for hours. If every profile fails, throw so the restore queue
7852
8239
  * retries the device.
7853
8240
  */
8241
+ /**
8242
+ * Wake `deviceId` if it is a sleeping battery camera, once per device at a
8243
+ * time. Concurrent attaches (two profiles, a trigger racing the reconcile)
8244
+ * join the in-flight wake instead of issuing a second one at the radio.
8245
+ */
8246
+ async wakeBatteryDevice(deviceId) {
8247
+ const inFlight = this.batteryWakes.get(deviceId);
8248
+ if (inFlight) {
8249
+ await inFlight;
8250
+ return;
8251
+ }
8252
+ const wake = this.runBatteryWake(deviceId);
8253
+ this.batteryWakes.set(deviceId, wake);
8254
+ try {
8255
+ await wake;
8256
+ } finally {
8257
+ this.batteryWakes.delete(deviceId);
8258
+ }
8259
+ }
8260
+ /** One wake attempt, fully reported. Absorbs its own failures. */
8261
+ async runBatteryWake(deviceId) {
8262
+ const api = this.deps.api;
8263
+ const result = await wakeForRecording(deviceId, {
8264
+ isBatteryDevice: async (id) => {
8265
+ const dev = await withDeadline(api.deviceManager.getDevice.query({ deviceId: id }), BATTERY_READ_TIMEOUT_MS, "getDevice").catch(() => null);
8266
+ if (!dev) return null;
8267
+ return dev.features.includes(require_dist.DeviceFeature.BatteryOperated);
8268
+ },
8269
+ readBatteryFacts: async (id) => {
8270
+ const slice = await withDeadline(api.deviceState.getCapSlice.query({
8271
+ deviceId: id,
8272
+ capName: require_dist.batteryCapability.name
8273
+ }), BATTERY_READ_TIMEOUT_MS, "getCapSlice").catch(() => null);
8274
+ const parsed = require_dist.BatteryStatusSchema.safeParse(slice);
8275
+ if (!parsed.success) return null;
8276
+ return {
8277
+ sleeping: parsed.data.sleeping,
8278
+ lastContactAt: parsed.data.lastContactAt
8279
+ };
8280
+ },
8281
+ wakeForStream: async (id, timeoutMs) => api.battery.wakeForStream.mutate({
8282
+ deviceId: id,
8283
+ timeoutMs
8284
+ }),
8285
+ nowMs: () => this.now()
8286
+ });
8287
+ if (result.outcome === "not-battery" || result.outcome === "unknown") return;
8288
+ const meta = {
8289
+ outcome: result.outcome,
8290
+ ...result.durationMs !== void 0 ? { wakeMs: result.durationMs } : {},
8291
+ ...result.error !== void 0 ? { error: result.error } : {}
8292
+ };
8293
+ if (wakeOutcomeLosesFootage(result.outcome)) {
8294
+ this.deps.logger.warn("recorder: battery camera not woken for this trigger — the episode may record NOTHING", {
8295
+ tags: { deviceId },
8296
+ meta
8297
+ });
8298
+ return;
8299
+ }
8300
+ this.deps.logger.info("recorder: battery camera ready for recording", {
8301
+ tags: { deviceId },
8302
+ meta
8303
+ });
8304
+ }
7854
8305
  async performAttach(deviceId, config) {
7855
8306
  this.restore?.add(deviceId, true);
7856
8307
  this.failFastIfBrokerCold(deviceId);
8308
+ await this.wakeBatteryDevice(deviceId);
7857
8309
  const profiles = await this.resolveProfiles(deviceId, config.profiles);
7858
8310
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
7859
8311
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
@@ -7976,6 +8428,7 @@ var RecordingController = class {
7976
8428
  * that feeds finalized flat segments into `SegmentStore.onFinalized`.
7977
8429
  */
7978
8430
  async attachProfile(deviceId, profile, segmentSeconds) {
8431
+ const attachProfileStartMs = this.now();
7979
8432
  const placement = await this.resolvePlacement(deviceId, profile);
7980
8433
  const source = await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.getStreamWithCodec.mutate({
7981
8434
  deviceId,
@@ -7985,14 +8438,28 @@ var RecordingController = class {
7985
8438
  ...this.deps.consumerHostname !== void 0 ? { hostname: this.deps.consumerHostname } : {},
7986
8439
  tag: `recorder:${deviceId}/${profile}`
7987
8440
  }, require_dist.nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "getStreamWithCodec"));
8441
+ const streamAcquiredMs = this.now();
8442
+ const rtspUrl = require_restream_intent.withRecordingIntent(source.url);
8443
+ this.attachSeq++;
8444
+ const consumerTag = `recorder/${deviceId}/${profile}/${this.attachSeq}`;
8445
+ if (rtspUrl === source.url) this.deps.logger.info("recorder: dial carries no recording intent — no pre-roll", {
8446
+ tags: { deviceId },
8447
+ meta: {
8448
+ profile,
8449
+ url: source.url
8450
+ }
8451
+ });
8452
+ const placementDoneMs = this.now();
7988
8453
  const outDir = node_path.default.join(placement.root, STAGING_DIR_NAME, String(deviceId), profile);
7989
8454
  await node_fs.promises.mkdir(outDir, { recursive: true });
8455
+ const mkdirDoneMs = this.now();
7990
8456
  const deviceLog = this.deps.logger.withTags({ deviceId });
7991
8457
  const writer = new SegmentWriter({
7992
- rtspUrl: source.url,
8458
+ rtspUrl,
7993
8459
  outDir,
7994
8460
  segmentSeconds,
7995
- videoCodec: source.videoCodec
8461
+ videoCodec: source.videoCodec,
8462
+ consumerTag
7996
8463
  }, {
7997
8464
  spawn: this.deps.spawn,
7998
8465
  logger: deviceLog,
@@ -8003,6 +8470,18 @@ var RecordingController = class {
8003
8470
  });
8004
8471
  const writerStartedMs = this.now();
8005
8472
  writer.start();
8473
+ this.deps.logger.info("recorder: writer spawned", {
8474
+ tags: { deviceId },
8475
+ meta: {
8476
+ profile,
8477
+ consumer: consumerTag,
8478
+ placementMs: placementDoneMs - attachProfileStartMs,
8479
+ getStreamMs: streamAcquiredMs - placementDoneMs,
8480
+ mkdirMs: mkdirDoneMs - streamAcquiredMs,
8481
+ attachProfileMs: writerStartedMs - attachProfileStartMs,
8482
+ segmentSeconds
8483
+ }
8484
+ });
8006
8485
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
8007
8486
  return {
8008
8487
  profile,
@@ -8014,6 +8493,22 @@ var RecordingController = class {
8014
8493
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
8015
8494
  onFinalized: async (startMs, durMs, flatAbsPath) => {
8016
8495
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
8496
+ if (startMs >= writerStartedMs - 1e3 && !this.firstSegmentSeen.has(idleKey(deviceId, profile))) {
8497
+ this.firstSegmentSeen.add(idleKey(deviceId, profile));
8498
+ this.deps.logger.info("recorder: first segment of this attach", {
8499
+ tags: { deviceId },
8500
+ meta: {
8501
+ profile,
8502
+ consumer: consumerTag,
8503
+ file: node_path.default.basename(flatAbsPath),
8504
+ durMs,
8505
+ spawnToFirstSegmentMs: startMs - writerStartedMs,
8506
+ finalizeLagMs: this.now() - (startMs + durMs),
8507
+ graceMs: FIRST_SEGMENT_GRACE_MS,
8508
+ segmentSeconds
8509
+ }
8510
+ });
8511
+ }
8017
8512
  await this.handleFinalizedSegment(deviceId, profile, placement, {
8018
8513
  attachedAtMs: writerStartedMs,
8019
8514
  segmentSeconds
@@ -8084,8 +8579,14 @@ var RecordingController = class {
8084
8579
  profile,
8085
8580
  startMs,
8086
8581
  durMs,
8087
- lastMotionMs: triggers.lastMotionMs,
8088
- lastAudioMs: triggers.lastAudioMs,
8582
+ motionWindows: triggers.motion.map((w) => ({
8583
+ firstMs: w.firstMs,
8584
+ lastMs: w.lastMs
8585
+ })),
8586
+ audioWindows: triggers.audio.map((w) => ({
8587
+ firstMs: w.firstMs,
8588
+ lastMs: w.lastMs
8589
+ })),
8089
8590
  postMs,
8090
8591
  preMs,
8091
8592
  attachedAtMs: attach.attachedAtMs,
@@ -8159,6 +8660,7 @@ var RecordingController = class {
8159
8660
  for (const r of recordings) {
8160
8661
  this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
8161
8662
  this.attachRetained.delete(idleKey(deviceId, r.profile));
8663
+ this.firstSegmentSeen.delete(idleKey(deviceId, r.profile));
8162
8664
  }
8163
8665
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
8164
8666
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
@@ -9644,6 +10146,16 @@ var MARKER_PAD_PRE_MS = require_dist.EVENT_PAD_MS.preMs;
9644
10146
  var MARKER_PAD_POST_MS = require_dist.EVENT_PAD_MS.postMs;
9645
10147
  /** This addon's manifest id + the playback data-plane prefix — together they
9646
10148
  * form the hub reverse-proxy client path `/addon/recorder/playback`. */
10149
+ /**
10150
+ * Row cap for one device's trigger reconcile query.
10151
+ *
10152
+ * The window asked for is `postBufferSec + TRIGGER_HISTORY_MARGIN_MS` (165 s at
10153
+ * the shipped defaults) and the rows all merge into a handful of windows, so
10154
+ * this bounds a pathological detector — not normal traffic. Well under the
10155
+ * cap's own `MAX_EVENT_QUERY_LIMIT`, because a truncated answer here loses the
10156
+ * OLDEST rows, which are the ones carrying an episode's start edge.
10157
+ */
10158
+ var TRIGGER_RECONCILE_ROW_LIMIT = 2e3;
9647
10159
  var RECORDER_ADDON_ID = "recorder";
9648
10160
  var PLAYBACK_PREFIX = "playback";
9649
10161
  /** Data-plane prefix for on-demand single stills →
@@ -9947,6 +10459,24 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
9947
10459
  segmentStore: this.segmentStore,
9948
10460
  loadConfig: (deviceId) => loadDeviceConfig(this.configStore(), deviceId),
9949
10461
  enabledDeviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
10462
+ /**
10463
+ * The DURABLE half of the trigger path (D8/D11). `pipelineAnalytics` rows
10464
+ * are single-timestamp SQLite records — the same upstream motion signal
10465
+ * the bus carries, persisted rather than broadcast, so a dropped or
10466
+ * badly-delayed event cannot cost the keep gate an episode.
10467
+ *
10468
+ * `slim` because only `timestamp` is read: the full projection carries a
10469
+ * regions array per row and this runs per camera on every reconcile beat.
10470
+ */
10471
+ loadMotionEvents: async (deviceId, sinceMs, untilMs) => {
10472
+ return (await this.ctx.api.pipelineAnalytics.getMotionEvents.query({
10473
+ deviceId,
10474
+ since: sinceMs,
10475
+ until: untilMs,
10476
+ limit: TRIGGER_RECONCILE_ROW_LIMIT,
10477
+ projection: "slim"
10478
+ })).map((event) => event.timestamp);
10479
+ },
9950
10480
  locations: () => this.resolvedLocations,
9951
10481
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
9952
10482
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),