@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.
- package/dist/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +844 -110
- package/dist/detection-pipeline/index.mjs +842 -108
- package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
- package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
- package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
- package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
- package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -6
- package/dist/pipeline-runner/index.mjs +6 -4
- package/dist/{process-memory-DOjQ3MgC.js → process-memory-DY4RHcTj.js} +1 -1
- package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.mjs} +1 -1
- package/dist/recorder/index.js +660 -130
- package/dist/recorder/index.mjs +659 -129
- package/dist/remote-restream-BeHi78PZ.mjs +25 -0
- package/dist/remote-restream-CO36Sr30.js +36 -0
- package/dist/restream-intent-B4BXZra7.mjs +72 -0
- package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- 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
- 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
- package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
- package/dist/stream-broker/index.js +598 -82
- package/dist/stream-broker/index.mjs +597 -81
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
- package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +193 -1
- package/python/postprocessors/__init__.py +4 -0
- package/python/postprocessors/ctc.py +52 -0
- package/python/postprocessors/plate_slots.py +180 -0
- package/python/postprocessors/test_ctc.py +39 -0
- package/python/postprocessors/test_plate_slots.py +217 -0
- package/python/postprocessors/test_yolonas.py +83 -0
- package/python/postprocessors/yolonas.py +67 -0
- package/python/test_inference_pool_backpressure.py +6 -2
- package/python/test_inference_pool_coreml_cache.py +12 -4
- package/python/test_inference_pool_device_selection.py +12 -4
- package/python/test_inference_pool_layout.py +16 -5
- package/python/test_inference_pool_memstats.py +6 -2
- package/python/test_inference_pool_ov_ppp.py +12 -9
- package/python/test_inference_pool_preprocess.py +57 -0
- package/python/test_inference_pool_static_batch.py +426 -0
- package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
- 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
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
2805
|
-
* (`buildConcatPassArgs`) remuxes the
|
|
2806
|
-
* copy`. Pass 2 (`buildExportArgs` with that file as `-i`)
|
|
2807
|
-
* frames. Selecting WHILE walking `#EXT-X-DISCONTINUITY` (one
|
|
2808
|
-
* segment from `-reset_timestamps`) is what parked a 30h salone job
|
|
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
|
-
/**
|
|
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.
|
|
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/
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
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,
|
|
3386
|
+
async spawnTimelapse(id, rec, concatList, outPath, options) {
|
|
3347
3387
|
const concatPath = concatPassOutPath(outPath);
|
|
3348
3388
|
try {
|
|
3349
3389
|
const concatArgs = buildConcatPassArgs({
|
|
3350
|
-
|
|
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
|
|
6287
|
-
if (band.triggers?.audioThresholdDbfs != null && fresh(triggers
|
|
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 = (
|
|
6355
|
-
if (
|
|
6356
|
-
if (
|
|
6357
|
-
|
|
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 &&
|
|
6361
|
-
if (band.triggers?.audioThresholdDbfs != null &&
|
|
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 = (
|
|
6381
|
-
|
|
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
|
|
6386
|
-
if (
|
|
6387
|
-
return segStartMs - Math.max(...
|
|
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
|
-
|
|
6447
|
-
|
|
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 -
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
7746
|
-
this.
|
|
7747
|
-
|
|
7748
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
8087
|
-
|
|
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),
|