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