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