@camstack/addon-post-analysis 1.2.272 → 1.2.274
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/{dist-BkBZcyAK.js → dist-B9V4PRZO.js} +3720 -624
- package/dist/{dist-4BOLDrB1.mjs → dist-DDko6srJ.mjs} +3715 -607
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D2l0wRbH.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CNrdmO1k.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-kX2Fkx3d.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D-4roee-.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DW0buByp.mjs → hostInit-BDwkPDdw.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +705 -1104
- package/dist/pipeline-analytics/index.mjs +703 -1102
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -4
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BePOftX5.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DJJiuYPX.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-B9V4PRZO.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -41716,6 +41716,160 @@ var TrackResidentState = class {
|
|
|
41716
41716
|
if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
|
|
41717
41717
|
}
|
|
41718
41718
|
};
|
|
41719
|
+
/**
|
|
41720
|
+
* The shipped limits.
|
|
41721
|
+
*
|
|
41722
|
+
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
41723
|
+
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
41724
|
+
* camera that cannot clear these has genuinely not been watched enough.
|
|
41725
|
+
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
41726
|
+
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
41727
|
+
*/
|
|
41728
|
+
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
41729
|
+
minTracks: 12,
|
|
41730
|
+
minSamples: 200,
|
|
41731
|
+
maxTracks: 200,
|
|
41732
|
+
minIntervalSec: .02,
|
|
41733
|
+
maxIntervalSec: 3,
|
|
41734
|
+
quantile: .25,
|
|
41735
|
+
floorFactor: .02,
|
|
41736
|
+
ceilFactor: .25,
|
|
41737
|
+
minBar: .001,
|
|
41738
|
+
maxBar: 1
|
|
41739
|
+
};
|
|
41740
|
+
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
41741
|
+
* would read as a measured zero (D393). */
|
|
41742
|
+
function quantileOf(values, p) {
|
|
41743
|
+
if (values.length === 0) return NaN;
|
|
41744
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41745
|
+
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
41746
|
+
const lo = Math.floor(idx);
|
|
41747
|
+
const hi = Math.ceil(idx);
|
|
41748
|
+
const at = sorted[lo];
|
|
41749
|
+
const next = sorted[hi];
|
|
41750
|
+
if (at === void 0 || next === void 0) return NaN;
|
|
41751
|
+
return at + (next - at) * (idx - lo);
|
|
41752
|
+
}
|
|
41753
|
+
/**
|
|
41754
|
+
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
41755
|
+
*
|
|
41756
|
+
* A pair of consecutive positions contributes a sample only when the interval
|
|
41757
|
+
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
41758
|
+
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
41759
|
+
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
41760
|
+
* no size is unknown geometry, and unknown is never a sample.
|
|
41761
|
+
*/
|
|
41762
|
+
function collectStillnessSamples(tracks, limits) {
|
|
41763
|
+
const speeds = [];
|
|
41764
|
+
const intervals = [];
|
|
41765
|
+
const diagonals = [];
|
|
41766
|
+
let trackCount = 0;
|
|
41767
|
+
const read = tracks.slice(0, limits.maxTracks);
|
|
41768
|
+
for (const track of read) {
|
|
41769
|
+
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
41770
|
+
let contributed = false;
|
|
41771
|
+
for (let i = 1; i < positions.length; i++) {
|
|
41772
|
+
const prev = positions[i - 1];
|
|
41773
|
+
const cur = positions[i];
|
|
41774
|
+
if (prev === void 0 || cur === void 0) continue;
|
|
41775
|
+
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
41776
|
+
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
41777
|
+
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
41778
|
+
if (!(diagonal > 0)) continue;
|
|
41779
|
+
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
41780
|
+
speeds.push(travelled / diagonal / dtSec);
|
|
41781
|
+
intervals.push(dtSec);
|
|
41782
|
+
diagonals.push(diagonal);
|
|
41783
|
+
contributed = true;
|
|
41784
|
+
}
|
|
41785
|
+
if (contributed) trackCount++;
|
|
41786
|
+
}
|
|
41787
|
+
return {
|
|
41788
|
+
speeds,
|
|
41789
|
+
intervals,
|
|
41790
|
+
diagonals,
|
|
41791
|
+
trackCount,
|
|
41792
|
+
tracksRead: read.length
|
|
41793
|
+
};
|
|
41794
|
+
}
|
|
41795
|
+
function measure$1(samples) {
|
|
41796
|
+
return {
|
|
41797
|
+
trackCount: samples.trackCount,
|
|
41798
|
+
sampleCount: samples.speeds.length,
|
|
41799
|
+
p25: quantileOf(samples.speeds, .25),
|
|
41800
|
+
p50: quantileOf(samples.speeds, .5),
|
|
41801
|
+
p90: quantileOf(samples.speeds, .9),
|
|
41802
|
+
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
41803
|
+
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
41804
|
+
};
|
|
41805
|
+
}
|
|
41806
|
+
/**
|
|
41807
|
+
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
41808
|
+
*
|
|
41809
|
+
* The order of the gates is the order of the questions: is there anything at
|
|
41810
|
+
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
41811
|
+
* camera ever actually move. The last one is not pedantry — the whole method
|
|
41812
|
+
* scales the bar against the camera's own travel, so with no travel there is
|
|
41813
|
+
* nothing to scale against and the default is the honest answer.
|
|
41814
|
+
*/
|
|
41815
|
+
function calibrateStillnessSpeed(samples, limits) {
|
|
41816
|
+
const measurement = measure$1(samples);
|
|
41817
|
+
if (samples.tracksRead === 0) return {
|
|
41818
|
+
ok: false,
|
|
41819
|
+
reason: "no-tracks",
|
|
41820
|
+
measurement
|
|
41821
|
+
};
|
|
41822
|
+
if (samples.trackCount < limits.minTracks) return {
|
|
41823
|
+
ok: false,
|
|
41824
|
+
reason: "too-few-tracks",
|
|
41825
|
+
measurement
|
|
41826
|
+
};
|
|
41827
|
+
if (samples.speeds.length < limits.minSamples) return {
|
|
41828
|
+
ok: false,
|
|
41829
|
+
reason: "too-few-samples",
|
|
41830
|
+
measurement
|
|
41831
|
+
};
|
|
41832
|
+
if (!(measurement.p90 > 0)) return {
|
|
41833
|
+
ok: false,
|
|
41834
|
+
reason: "no-motion-evidence",
|
|
41835
|
+
measurement
|
|
41836
|
+
};
|
|
41837
|
+
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
41838
|
+
const floor = measurement.p90 * limits.floorFactor;
|
|
41839
|
+
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
41840
|
+
let bar = rawBar;
|
|
41841
|
+
let clampedBy = "none";
|
|
41842
|
+
if (bar < floor) {
|
|
41843
|
+
bar = floor;
|
|
41844
|
+
clampedBy = "floor";
|
|
41845
|
+
} else if (bar > ceiling) {
|
|
41846
|
+
bar = ceiling;
|
|
41847
|
+
clampedBy = "ceiling";
|
|
41848
|
+
}
|
|
41849
|
+
if (bar < limits.minBar) {
|
|
41850
|
+
bar = limits.minBar;
|
|
41851
|
+
clampedBy = "min";
|
|
41852
|
+
} else if (bar > limits.maxBar) {
|
|
41853
|
+
bar = limits.maxBar;
|
|
41854
|
+
clampedBy = "max";
|
|
41855
|
+
}
|
|
41856
|
+
return {
|
|
41857
|
+
ok: true,
|
|
41858
|
+
bar,
|
|
41859
|
+
rawBar,
|
|
41860
|
+
clampedBy,
|
|
41861
|
+
measurement
|
|
41862
|
+
};
|
|
41863
|
+
}
|
|
41864
|
+
/** One line an operator can read: what was measured, from how much, and what
|
|
41865
|
+
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
41866
|
+
function describeStillnessCalibration(outcome) {
|
|
41867
|
+
const m = outcome.measurement;
|
|
41868
|
+
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
41869
|
+
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
41870
|
+
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
41871
|
+
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
41872
|
+
}
|
|
41719
41873
|
//#endregion
|
|
41720
41874
|
//#region src/pipeline-analytics/pipeline/plate-clustering.ts
|
|
41721
41875
|
/**
|
|
@@ -47714,389 +47868,182 @@ async function retentionScopeDeviceIds(deps, onError) {
|
|
|
47714
47868
|
return [...ids];
|
|
47715
47869
|
}
|
|
47716
47870
|
//#endregion
|
|
47717
|
-
//#region src/pipeline-analytics/
|
|
47718
|
-
/**
|
|
47719
|
-
* Videoclip visits — the grain a clip should have on an events-mode camera.
|
|
47720
|
-
*
|
|
47721
|
-
* Disk writes one fMP4 per ffmpeg roll (~8–12 s). The recorder calendar merges
|
|
47722
|
-
* only gaps ≤ 5 s, so a single pantry walk shows up as several availability
|
|
47723
|
-
* ranges with ~8 s GOP holes between them. Analytics then mints one 15 s clip
|
|
47724
|
-
* per motion heartbeat. A *visit* is the keep-window those fragments belong
|
|
47725
|
-
* to: cluster availability with {@link VISIT_MERGE_GAP_MS}, annotate with
|
|
47726
|
-
* overlapping events. No second copy of the media; the files stay the source
|
|
47727
|
-
* of truth.
|
|
47728
|
-
*/
|
|
47729
|
-
/** Synthetic event-id slot in `analytics:<start>:<end>:<id>` for a visit clip. */
|
|
47730
|
-
var VISIT_EVENT_ID = "visit";
|
|
47731
|
-
function clusterFootageVisits(ranges, gapMs = require_dist.VISIT_MERGE_GAP_MS) {
|
|
47732
|
-
if (ranges.length === 0) return [];
|
|
47733
|
-
const sorted = [...ranges].toSorted((a, b) => a.startMs - b.startMs);
|
|
47734
|
-
const visits = [];
|
|
47735
|
-
let pieces = [sorted[0]];
|
|
47736
|
-
let endMs = sorted[0].endMs;
|
|
47737
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
47738
|
-
const next = sorted[i];
|
|
47739
|
-
if (next.startMs <= endMs + gapMs) {
|
|
47740
|
-
pieces.push(next);
|
|
47741
|
-
endMs = Math.max(endMs, next.endMs);
|
|
47742
|
-
continue;
|
|
47743
|
-
}
|
|
47744
|
-
visits.push(finishVisit(pieces, endMs));
|
|
47745
|
-
pieces = [next];
|
|
47746
|
-
endMs = next.endMs;
|
|
47747
|
-
}
|
|
47748
|
-
visits.push(finishVisit(pieces, endMs));
|
|
47749
|
-
return visits;
|
|
47750
|
-
}
|
|
47751
|
-
function finishVisit(pieces, endMs) {
|
|
47752
|
-
const holes = [];
|
|
47753
|
-
for (let i = 1; i < pieces.length; i++) {
|
|
47754
|
-
const prevEnd = pieces[i - 1].endMs;
|
|
47755
|
-
const nextStart = pieces[i].startMs;
|
|
47756
|
-
if (nextStart > prevEnd) holes.push({
|
|
47757
|
-
startMs: prevEnd,
|
|
47758
|
-
endMs: nextStart
|
|
47759
|
-
});
|
|
47760
|
-
}
|
|
47761
|
-
return {
|
|
47762
|
-
startMs: pieces[0].startMs,
|
|
47763
|
-
endMs,
|
|
47764
|
-
ranges: pieces,
|
|
47765
|
-
holes
|
|
47766
|
-
};
|
|
47767
|
-
}
|
|
47768
|
-
var KIND_ORDER = [
|
|
47769
|
-
"object",
|
|
47770
|
-
"motion",
|
|
47771
|
-
"audio"
|
|
47772
|
-
];
|
|
47773
|
-
function eventOverlapsVisit(event, visit, pad) {
|
|
47774
|
-
if (event.timestamp >= visit.startMs && event.timestamp <= visit.endMs) return true;
|
|
47775
|
-
const windowStart = event.timestamp - pad.preMs;
|
|
47776
|
-
const windowEnd = event.timestamp + pad.postMs;
|
|
47777
|
-
return windowStart < visit.endMs && windowEnd > visit.startMs;
|
|
47778
|
-
}
|
|
47779
|
-
function dominantKind(attached) {
|
|
47780
|
-
for (const kind of KIND_ORDER) if (attached.some((a) => a.kind === kind)) return kind;
|
|
47781
|
-
return "motion";
|
|
47782
|
-
}
|
|
47871
|
+
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
47783
47872
|
/**
|
|
47784
|
-
*
|
|
47785
|
-
*
|
|
47873
|
+
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
47874
|
+
*
|
|
47875
|
+
* ## Why an `addons.custom` action and not a cap method
|
|
47876
|
+
*
|
|
47877
|
+
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
47878
|
+
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
47879
|
+
* closure, so a new one is not callable until a server train ships. Here the
|
|
47880
|
+
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
47881
|
+
* envelope, so `camstack deploy` is the whole delivery.
|
|
47786
47882
|
*
|
|
47787
|
-
*
|
|
47788
|
-
*
|
|
47789
|
-
*
|
|
47790
|
-
*
|
|
47883
|
+
* ## Why `apply` is explicit and not a fallback
|
|
47884
|
+
*
|
|
47885
|
+
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
47886
|
+
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
47887
|
+
* the next frame on. Those are different enough in consequence that a button
|
|
47888
|
+
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
47889
|
+
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
47890
|
+
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
47891
|
+
* are visible in the form instead of being a toast that scrolls away.
|
|
47791
47892
|
*/
|
|
47792
|
-
|
|
47793
|
-
|
|
47794
|
-
|
|
47795
|
-
|
|
47796
|
-
|
|
47797
|
-
|
|
47893
|
+
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
47894
|
+
* self-describing and a new reason is a deliberate edit in both places. */
|
|
47895
|
+
var StillnessRefusalSchema = require_dist._enum([
|
|
47896
|
+
"no-tracks",
|
|
47897
|
+
"too-few-tracks",
|
|
47898
|
+
"too-few-samples",
|
|
47899
|
+
"no-motion-evidence"
|
|
47900
|
+
]);
|
|
47901
|
+
var StillnessMeasurementSchema = require_dist.object({
|
|
47902
|
+
/** Tracks that contributed at least one speed sample. */
|
|
47903
|
+
trackCount: require_dist.number().int(),
|
|
47904
|
+
/** Tracks the pass actually read, after the cost bound. */
|
|
47905
|
+
tracksRead: require_dist.number().int(),
|
|
47906
|
+
sampleCount: require_dist.number().int(),
|
|
47907
|
+
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
47908
|
+
* nothing to measure — never 0, which would read as a measured stillness
|
|
47909
|
+
* (D393). */
|
|
47910
|
+
p25: require_dist.number().nullable(),
|
|
47911
|
+
p50: require_dist.number().nullable(),
|
|
47912
|
+
p90: require_dist.number().nullable(),
|
|
47913
|
+
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
47914
|
+
medianIntervalSec: require_dist.number().nullable(),
|
|
47915
|
+
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
47916
|
+
medianSubjectDiagonalPx: require_dist.number().nullable()
|
|
47917
|
+
});
|
|
47918
|
+
var StillnessCalibrateInputSchema = require_dist.object({
|
|
47919
|
+
deviceId: require_dist.number().int(),
|
|
47920
|
+
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
47921
|
+
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
47922
|
+
sinceHours: require_dist.number().min(.25).max(720).default(72),
|
|
47923
|
+
/** Hard cost bound on tracks read, newest first. */
|
|
47924
|
+
maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
|
|
47925
|
+
/**
|
|
47926
|
+
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
47927
|
+
* all for a refused run.
|
|
47928
|
+
*/
|
|
47929
|
+
apply: require_dist.boolean().default(false)
|
|
47930
|
+
});
|
|
47931
|
+
var StillnessCalibrateResultSchema = require_dist.object({
|
|
47932
|
+
deviceId: require_dist.number().int(),
|
|
47933
|
+
/** The window actually read (epoch ms). */
|
|
47934
|
+
since: require_dist.number().int(),
|
|
47935
|
+
until: require_dist.number().int(),
|
|
47936
|
+
accepted: require_dist.boolean(),
|
|
47937
|
+
/** Why it refused. Absent on an accepted run. */
|
|
47938
|
+
reason: StillnessRefusalSchema.optional(),
|
|
47939
|
+
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
47940
|
+
* never 0, which the settings layer reads as "uncalibrated". */
|
|
47941
|
+
bar: require_dist.number().optional(),
|
|
47942
|
+
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
47943
|
+
rawBar: require_dist.number().optional(),
|
|
47944
|
+
clampedBy: require_dist._enum([
|
|
47945
|
+
"none",
|
|
47946
|
+
"floor",
|
|
47947
|
+
"ceiling",
|
|
47948
|
+
"min",
|
|
47949
|
+
"max"
|
|
47950
|
+
]).optional(),
|
|
47951
|
+
measurement: StillnessMeasurementSchema,
|
|
47952
|
+
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
47953
|
+
* before any write, so the operator sees both numbers. */
|
|
47954
|
+
previousBar: require_dist.number().nullable(),
|
|
47955
|
+
/** Whether the camera's settings were actually written. */
|
|
47956
|
+
applied: require_dist.boolean(),
|
|
47957
|
+
/** The one-line note stored on the camera (accepted or refused). */
|
|
47958
|
+
note: require_dist.string()
|
|
47959
|
+
});
|
|
47960
|
+
var stillnessCalibrationActions = require_dist.defineCustomActions({
|
|
47798
47961
|
/**
|
|
47799
|
-
*
|
|
47800
|
-
*
|
|
47801
|
-
*
|
|
47802
|
-
* kind label instead of carrying an empty array every consumer length-checks.
|
|
47962
|
+
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
47963
|
+
* set, and because the report describes how the camera's subjects move,
|
|
47964
|
+
* which is not a viewer-level fact.
|
|
47803
47965
|
*/
|
|
47804
|
-
|
|
47805
|
-
|
|
47806
|
-
|
|
47807
|
-
|
|
47808
|
-
const name = a.event.className;
|
|
47809
|
-
if (typeof name !== "string" || name.length === 0) continue;
|
|
47810
|
-
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
47811
|
-
}
|
|
47812
|
-
return [...counts.entries()].toSorted((x, y) => y[1] - x[1]).slice(0, 3).map(([name]) => name);
|
|
47813
|
-
}
|
|
47814
|
-
function visitsToClips(visits, events, pad, vouched) {
|
|
47815
|
-
const tagged = [
|
|
47816
|
-
...events.object.map((event) => ({
|
|
47817
|
-
kind: "object",
|
|
47818
|
-
event
|
|
47819
|
-
})),
|
|
47820
|
-
...events.motion.map((event) => ({
|
|
47821
|
-
kind: "motion",
|
|
47822
|
-
event
|
|
47823
|
-
})),
|
|
47824
|
-
...events.audio.map((event) => ({
|
|
47825
|
-
kind: "audio",
|
|
47826
|
-
event
|
|
47827
|
-
}))
|
|
47828
|
-
];
|
|
47829
|
-
return visits.map((visit) => {
|
|
47830
|
-
const attached = tagged.filter((t) => eventOverlapsVisit(t.event, visit, pad));
|
|
47831
|
-
const kind = dominantKind(attached);
|
|
47832
|
-
const withThumb = attached.find((a) => a.kind === kind && a.event.mediaUrl && vouched.has(a.event.id)) ?? attached.find((a) => a.event.mediaUrl && vouched.has(a.event.id));
|
|
47833
|
-
const stillAtMs = stillInstantMs(visit.ranges);
|
|
47834
|
-
const labels = dominantLabels(attached);
|
|
47835
|
-
return {
|
|
47836
|
-
id: `analytics:${visit.startMs}:${visit.endMs}:${VISIT_EVENT_ID}`,
|
|
47837
|
-
source: "analytics",
|
|
47838
|
-
kind,
|
|
47839
|
-
timeRange: {
|
|
47840
|
-
startMs: visit.startMs,
|
|
47841
|
-
endMs: visit.endMs
|
|
47842
|
-
},
|
|
47843
|
-
...withThumb?.event.mediaUrl !== void 0 ? { thumbnail: withThumb.event.mediaUrl } : {},
|
|
47844
|
-
...stillAtMs !== void 0 ? { stillAtMs } : {},
|
|
47845
|
-
eventIds: attached.slice(0, 24).map((a) => a.event.id),
|
|
47846
|
-
eventCount: attached.length,
|
|
47847
|
-
holes: [...visit.holes],
|
|
47848
|
-
...labels.length > 0 ? { labels } : {}
|
|
47849
|
-
};
|
|
47850
|
-
}).toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
47851
|
-
}
|
|
47966
|
+
"stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
|
|
47967
|
+
kind: "mutation",
|
|
47968
|
+
auth: "admin"
|
|
47969
|
+
}) });
|
|
47852
47970
|
//#endregion
|
|
47853
|
-
//#region src/pipeline-analytics/
|
|
47854
|
-
/**
|
|
47855
|
-
* Default `videoclips` provider — composes recorded footage visits with the
|
|
47856
|
-
* analytics event log. A clip is a time-WINDOW over existing footage, never a
|
|
47857
|
-
* separate file: `getClipPlayback` resolves to
|
|
47858
|
-
* `recording.getPlaybackManifest(startMs, endMs)`.
|
|
47859
|
-
*
|
|
47860
|
-
* When availability is present the grain is a *visit* (events-mode keep-window
|
|
47861
|
-
* clustered at `VISIT_MERGE_GAP_MS`), not one clip per motion heartbeat. The
|
|
47862
|
-
* clip id still encodes the window (`analytics:<startMs>:<endMs>:visit`) so
|
|
47863
|
-
* playback is self-contained. Without availability (or when that read fails)
|
|
47864
|
-
* the provider falls back to per-event padded windows.
|
|
47865
|
-
*
|
|
47866
|
-
* Pure helpers (`eventsToClips` / `clusterFootageVisits` / `clipIdFor` /
|
|
47867
|
-
* `parseClipId`) are unit-tested in isolation; the provider factory wires
|
|
47868
|
-
* injected fetch/resolve deps so the compose logic is testable without I/O.
|
|
47869
|
-
*/
|
|
47870
|
-
var SOURCE = "analytics";
|
|
47871
|
-
/** The addon that registers this provider — stamped on its source row. */
|
|
47872
|
-
var ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
47873
|
-
/**
|
|
47874
|
-
* Backstop on the ANNOTATION read — never on the clip list.
|
|
47875
|
-
*
|
|
47876
|
-
* A visit is annotated by every event inside it, so the clip `limit` must not
|
|
47877
|
-
* reach this fetch (one 615 visit legitimately spans 2 345 motion rows). This
|
|
47878
|
-
* bound exists only so a pathological camera-day cannot pull an unbounded page:
|
|
47879
|
-
* the store orders `timestamp DESC`, so a truncation drops the OLDEST rows and
|
|
47880
|
-
* the EARLIEST visits silently lose their annotations — which is why hitting it
|
|
47881
|
-
* is logged, per camera, rather than absorbed.
|
|
47882
|
-
*/
|
|
47883
|
-
var VISIT_EVENT_FETCH_LIMIT = 5e3;
|
|
47884
|
-
function clipIdFor(eventId, startMs, endMs) {
|
|
47885
|
-
return `${SOURCE}:${startMs}:${endMs}:${eventId}`;
|
|
47886
|
-
}
|
|
47887
|
-
/** Parse a default-provider clip id back into its window + event id. Returns
|
|
47888
|
-
* `null` for any id this provider did not mint (e.g. a native clip id). */
|
|
47889
|
-
function parseClipId(clipId) {
|
|
47890
|
-
const parts = clipId.split(":");
|
|
47891
|
-
if (parts.length < 4 || parts[0] !== SOURCE) return null;
|
|
47892
|
-
const startMs = Number(parts[1]);
|
|
47893
|
-
const endMs = Number(parts[2]);
|
|
47894
|
-
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null;
|
|
47895
|
-
const eventId = parts.slice(3).join(":");
|
|
47896
|
-
if (!eventId) return null;
|
|
47897
|
-
return {
|
|
47898
|
-
eventId,
|
|
47899
|
-
startMs,
|
|
47900
|
-
endMs
|
|
47901
|
-
};
|
|
47902
|
-
}
|
|
47903
|
-
/** Map per-kind events into time-ranged clips, newest-first. Pure. */
|
|
47904
|
-
function eventsToClips(events, pad, vouched) {
|
|
47905
|
-
const make = (e, kind) => {
|
|
47906
|
-
const startMs = e.timestamp - pad.preMs;
|
|
47907
|
-
const endMs = e.timestamp + pad.postMs;
|
|
47908
|
-
return {
|
|
47909
|
-
id: clipIdFor(e.id, startMs, endMs),
|
|
47910
|
-
source: SOURCE,
|
|
47911
|
-
kind,
|
|
47912
|
-
timeRange: {
|
|
47913
|
-
startMs,
|
|
47914
|
-
endMs
|
|
47915
|
-
},
|
|
47916
|
-
...e.mediaUrl !== void 0 && vouched.has(e.id) ? { thumbnail: e.mediaUrl } : {}
|
|
47917
|
-
};
|
|
47918
|
-
};
|
|
47919
|
-
return [
|
|
47920
|
-
...events.motion.map((e) => make(e, "motion")),
|
|
47921
|
-
...events.object.map((e) => make(e, "object")),
|
|
47922
|
-
...events.audio.map((e) => make(e, "audio"))
|
|
47923
|
-
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
47924
|
-
}
|
|
47925
|
-
/** Every id in the three buckets, in the order the composers tag them. */
|
|
47926
|
-
function allEventIds(events) {
|
|
47927
|
-
return [
|
|
47928
|
-
...events.motion,
|
|
47929
|
-
...events.object,
|
|
47930
|
-
...events.audio
|
|
47931
|
-
].map((e) => e.id);
|
|
47932
|
-
}
|
|
47933
|
-
/**
|
|
47934
|
-
* Resolve the event ids whose `mediaUrl` is backed by real bytes, mirroring
|
|
47935
|
-
* `resolveDefaultEventMedia`: the event's OWN media when it has any, else its
|
|
47936
|
-
* owning TRACK's. Up to four batched index reads — one PER EVENT KIND that has
|
|
47937
|
-
* any ids (D476: `motion` / `object` / `audio` are separate owner kinds now,
|
|
47938
|
-
* so a single combined read can no longer ask "does any of these ids own
|
|
47939
|
-
* media" — a motion id and an object id can be the same number post-D474 and
|
|
47940
|
-
* are answered by different rows) — plus the track fallback, only over the
|
|
47941
|
-
* object events the first pass did not already answer for (motion and audio
|
|
47942
|
-
* are trackless roots, so they never need it).
|
|
47943
|
-
*
|
|
47944
|
-
* THROWS on a failed read. The caller fails closed on the CLAIM and open on the
|
|
47945
|
-
* LIST; swallowing here would collapse those two opposite directions into one.
|
|
47946
|
-
*/
|
|
47947
|
-
async function resolveVouchedEvents(deviceId, events, fetchMediaOwners) {
|
|
47948
|
-
const ownMedia = /* @__PURE__ */ new Set();
|
|
47949
|
-
for (const ownerType of require_dist.EVENT_OWNER_TYPES) {
|
|
47950
|
-
const ownerIds = events[ownerType].map((e) => e.id);
|
|
47951
|
-
if (ownerIds.length === 0) continue;
|
|
47952
|
-
const found = await fetchMediaOwners({
|
|
47953
|
-
deviceId,
|
|
47954
|
-
ownerKind: ownerType,
|
|
47955
|
-
ownerIds
|
|
47956
|
-
});
|
|
47957
|
-
for (const id of found) ownMedia.add(id);
|
|
47958
|
-
}
|
|
47959
|
-
const residualTracks = [];
|
|
47960
|
-
const seenTracks = /* @__PURE__ */ new Set();
|
|
47961
|
-
for (const event of events.object) {
|
|
47962
|
-
const trackId = event.trackId;
|
|
47963
|
-
if (trackId === void 0 || ownMedia.has(event.id) || seenTracks.has(trackId)) continue;
|
|
47964
|
-
seenTracks.add(trackId);
|
|
47965
|
-
residualTracks.push(trackId);
|
|
47966
|
-
}
|
|
47967
|
-
const trackMedia = residualTracks.length === 0 ? /* @__PURE__ */ new Set() : await fetchMediaOwners({
|
|
47968
|
-
deviceId,
|
|
47969
|
-
ownerKind: "track",
|
|
47970
|
-
ownerIds: residualTracks
|
|
47971
|
-
});
|
|
47972
|
-
const vouched = new Set(ownMedia);
|
|
47973
|
-
for (const event of events.object) {
|
|
47974
|
-
const trackId = event.trackId;
|
|
47975
|
-
if (trackId !== void 0 && trackMedia.has(trackId)) vouched.add(event.id);
|
|
47976
|
-
}
|
|
47977
|
-
return vouched;
|
|
47978
|
-
}
|
|
47979
|
-
/** Keep only clips that actually touch the caller's window. The reads are
|
|
47980
|
-
* widened by {@link leftOverhangMs}; the ANSWER is not. */
|
|
47981
|
-
function intersectsWindow(clip, since, until) {
|
|
47982
|
-
return clip.timeRange.startMs < until && clip.timeRange.endMs > since;
|
|
47983
|
-
}
|
|
47971
|
+
//#region src/pipeline-analytics/stillness-settings.ts
|
|
47984
47972
|
/**
|
|
47985
|
-
*
|
|
47973
|
+
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
47974
|
+
* THIS camera has to move before it is travelling.
|
|
47986
47975
|
*
|
|
47987
|
-
* A
|
|
47988
|
-
*
|
|
47989
|
-
*
|
|
47990
|
-
*
|
|
47976
|
+
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
47977
|
+
* into a classifier default. The judgement this feeds is
|
|
47978
|
+
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
47979
|
+
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
47980
|
+
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
47981
|
+
* doorway.
|
|
47982
|
+
*
|
|
47983
|
+
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
47984
|
+
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
47985
|
+
* that never throws on a bad blob.
|
|
47986
|
+
*
|
|
47987
|
+
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
47988
|
+
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
47989
|
+
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
47990
|
+
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
47991
|
+
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
47992
|
+
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
47993
|
+
* absolute `velocityThreshold` path.
|
|
47991
47994
|
*/
|
|
47992
|
-
|
|
47993
|
-
|
|
47994
|
-
|
|
47995
|
-
|
|
47995
|
+
var StillnessSettingsSchema = require_dist.object({
|
|
47996
|
+
/**
|
|
47997
|
+
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
47998
|
+
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
47999
|
+
*
|
|
48000
|
+
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48001
|
+
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48002
|
+
* transits).
|
|
48003
|
+
*/
|
|
48004
|
+
stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
|
|
48005
|
+
/**
|
|
48006
|
+
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48007
|
+
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48008
|
+
*/
|
|
48009
|
+
stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
|
|
48010
|
+
/**
|
|
48011
|
+
* What the last calibration run measured and decided, in one line
|
|
48012
|
+
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48013
|
+
* reason, because "why is this camera still on the default" has to be
|
|
48014
|
+
* answerable without re-running anything.
|
|
48015
|
+
*/
|
|
48016
|
+
stillnessCalibrationNote: require_dist.string().max(400).default("")
|
|
48017
|
+
});
|
|
48018
|
+
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48019
|
+
/**
|
|
48020
|
+
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48021
|
+
* invalid fields fall back to the default for that field — including a bar
|
|
48022
|
+
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48023
|
+
* a number the calibration could never have produced is not evidence about
|
|
48024
|
+
* this camera, and silently bending it into range would present it as if it
|
|
48025
|
+
* were.
|
|
48026
|
+
*/
|
|
48027
|
+
function resolveStillnessSettings(raw) {
|
|
48028
|
+
const pick = (key) => {
|
|
48029
|
+
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48030
|
+
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48031
|
+
};
|
|
47996
48032
|
return {
|
|
47997
|
-
|
|
47998
|
-
|
|
47999
|
-
|
|
48000
|
-
deviceId,
|
|
48001
|
-
since: readSince,
|
|
48002
|
-
until,
|
|
48003
|
-
limit: deps.fetchAvailability ? VISIT_EVENT_FETCH_LIMIT : limit
|
|
48004
|
-
});
|
|
48005
|
-
warnOnTruncatedFetch(deps, deviceId, events);
|
|
48006
|
-
let vouched = /* @__PURE__ */ new Set();
|
|
48007
|
-
try {
|
|
48008
|
-
vouched = await resolveVouchedEvents(deviceId, events, deps.fetchMediaOwners);
|
|
48009
|
-
} catch (err) {
|
|
48010
|
-
deps.logWarn?.("videoclips: media presence read failed — listing without thumbnails", {
|
|
48011
|
-
deviceId,
|
|
48012
|
-
events: allEventIds(events).length,
|
|
48013
|
-
error: err instanceof Error ? err.message : String(err)
|
|
48014
|
-
});
|
|
48015
|
-
}
|
|
48016
|
-
let clips = eventsToClips(events, deps.defaultPad, vouched);
|
|
48017
|
-
if (deps.fetchAvailability) try {
|
|
48018
|
-
clips = visitsToClips(clusterFootageVisits(await deps.fetchAvailability({
|
|
48019
|
-
deviceId,
|
|
48020
|
-
since: readSince,
|
|
48021
|
-
until
|
|
48022
|
-
}), require_dist.VISIT_MERGE_GAP_MS), events, deps.defaultPad, vouched);
|
|
48023
|
-
} catch (err) {
|
|
48024
|
-
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
48025
|
-
deviceId,
|
|
48026
|
-
error: err instanceof Error ? err.message : String(err)
|
|
48027
|
-
});
|
|
48028
|
-
}
|
|
48029
|
-
const inWindow = clips.filter((c) => intersectsWindow(c, since, until));
|
|
48030
|
-
const answer = limit != null ? inWindow.slice(0, limit) : inWindow;
|
|
48031
|
-
reportUnvouchedClips(deps, deviceId, answer);
|
|
48032
|
-
return answer;
|
|
48033
|
-
},
|
|
48034
|
-
/**
|
|
48035
|
-
* The one source this provider serves, on every camera: the hub's own
|
|
48036
|
-
* analytics visits over recorded footage.
|
|
48037
|
-
*
|
|
48038
|
-
* Always `ok`, and that is not optimism — this source IS the process
|
|
48039
|
-
* answering the call, so there is no reachability question to report. A
|
|
48040
|
-
* read that fails INSIDE `listClips` degrades that list (and logs it); a
|
|
48041
|
-
* source that cannot be reached at all is reported by the collection
|
|
48042
|
-
* dispatcher, which is the only layer that can tell the difference.
|
|
48043
|
-
* `label` is what the picker draws.
|
|
48044
|
-
*/
|
|
48045
|
-
listSources: async () => [{
|
|
48046
|
-
source: SOURCE,
|
|
48047
|
-
addonId: ANALYTICS_ADDON_ID,
|
|
48048
|
-
label: "CamStack recordings",
|
|
48049
|
-
availability: { state: "ok" }
|
|
48050
|
-
}],
|
|
48051
|
-
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
48052
|
-
const parsed = parseClipId(clipId);
|
|
48053
|
-
if (!parsed) throw new Error(`videoclips: unrecognized clipId "${clipId}"`);
|
|
48054
|
-
return {
|
|
48055
|
-
...await deps.resolvePlayback({
|
|
48056
|
-
deviceId,
|
|
48057
|
-
startMs: parsed.startMs,
|
|
48058
|
-
endMs: parsed.endMs
|
|
48059
|
-
}),
|
|
48060
|
-
format: "hls"
|
|
48061
|
-
};
|
|
48062
|
-
}
|
|
48033
|
+
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48034
|
+
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48035
|
+
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48063
48036
|
};
|
|
48064
48037
|
}
|
|
48065
|
-
/**
|
|
48066
|
-
*
|
|
48067
|
-
|
|
48068
|
-
|
|
48069
|
-
|
|
48070
|
-
|
|
48071
|
-
|
|
48072
|
-
|
|
48073
|
-
|
|
48074
|
-
if (truncated.length === 0) return;
|
|
48075
|
-
deps.logWarn?.("videoclips: annotation fetch truncated — earliest visits lose their events", {
|
|
48076
|
-
deviceId,
|
|
48077
|
-
kinds: truncated,
|
|
48078
|
-
limit: VISIT_EVENT_FETCH_LIMIT
|
|
48079
|
-
});
|
|
48080
|
-
}
|
|
48081
|
-
/** The operator's acceptance criterion as a number: a rising count on one
|
|
48082
|
-
* camera is its motion-snapshot capturer failing, not a quiet day. */
|
|
48083
|
-
function reportUnvouchedClips(deps, deviceId, clips) {
|
|
48084
|
-
const clipsWithoutStill = clips.filter((c) => c.thumbnail === void 0).length;
|
|
48085
|
-
if (clipsWithoutStill === 0) return;
|
|
48086
|
-
deps.logInfo?.("videoclips: visit has no vouched still", {
|
|
48087
|
-
deviceId,
|
|
48088
|
-
clipsWithoutStill,
|
|
48089
|
-
clips: clips.length
|
|
48090
|
-
});
|
|
48091
|
-
}
|
|
48092
|
-
//#endregion
|
|
48093
|
-
//#region src/pipeline-analytics/videoclips-shared.ts
|
|
48094
|
-
/** Stringify the numeric event ids of one bucket. Lossless. */
|
|
48095
|
-
function toEventLike(events) {
|
|
48096
|
-
return events.map((e) => ({
|
|
48097
|
-
...e,
|
|
48098
|
-
id: String(e.id)
|
|
48099
|
-
}));
|
|
48038
|
+
/**
|
|
48039
|
+
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48040
|
+
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48041
|
+
* before". The ONE place the stored-zero convention is interpreted.
|
|
48042
|
+
*/
|
|
48043
|
+
function resolveStillSpeedFracPerSec(settings) {
|
|
48044
|
+
const bar = settings.stillnessSpeedFracPerSec;
|
|
48045
|
+
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48046
|
+
return bar;
|
|
48100
48047
|
}
|
|
48101
48048
|
//#endregion
|
|
48102
48049
|
//#region src/pipeline-analytics/viewer-settings-actions.ts
|
|
@@ -48319,338 +48266,6 @@ function makeViewerSettingsActionHandlers(store) {
|
|
|
48319
48266
|
};
|
|
48320
48267
|
}
|
|
48321
48268
|
//#endregion
|
|
48322
|
-
//#region src/pipeline-analytics/stillness-calibration-actions.ts
|
|
48323
|
-
/**
|
|
48324
|
-
* stillness-calibration-actions — "measure this camera's own stillness scale".
|
|
48325
|
-
*
|
|
48326
|
-
* ## Why an `addons.custom` action and not a cap method
|
|
48327
|
-
*
|
|
48328
|
-
* Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
|
|
48329
|
-
* lives in `@camstack/types`, which travels inside the `@camstack/server`
|
|
48330
|
-
* closure, so a new one is not callable until a server train ships. Here the
|
|
48331
|
-
* schema lives in this addon's own dist and the hub forwards an opaque
|
|
48332
|
-
* envelope, so `camstack deploy` is the whole delivery.
|
|
48333
|
-
*
|
|
48334
|
-
* ## Why `apply` is explicit and not a fallback
|
|
48335
|
-
*
|
|
48336
|
-
* A calibration run READS recorded tracks and does arithmetic. Applying it
|
|
48337
|
-
* WRITES the camera's settings and changes what the tracker calls settled from
|
|
48338
|
-
* the next frame on. Those are different enough in consequence that a button
|
|
48339
|
-
* labelled "calibrate" must not choose between them on the operator's behalf —
|
|
48340
|
-
* the run reports, the operator applies. A REFUSED run never writes the bar
|
|
48341
|
-
* whatever `apply` says; it writes only the note, so the refusal and its reason
|
|
48342
|
-
* are visible in the form instead of being a toast that scrolls away.
|
|
48343
|
-
*/
|
|
48344
|
-
/** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
|
|
48345
|
-
* self-describing and a new reason is a deliberate edit in both places. */
|
|
48346
|
-
var StillnessRefusalSchema = require_dist._enum([
|
|
48347
|
-
"no-tracks",
|
|
48348
|
-
"too-few-tracks",
|
|
48349
|
-
"too-few-samples",
|
|
48350
|
-
"no-motion-evidence"
|
|
48351
|
-
]);
|
|
48352
|
-
var StillnessMeasurementSchema = require_dist.object({
|
|
48353
|
-
/** Tracks that contributed at least one speed sample. */
|
|
48354
|
-
trackCount: require_dist.number().int(),
|
|
48355
|
-
/** Tracks the pass actually read, after the cost bound. */
|
|
48356
|
-
tracksRead: require_dist.number().int(),
|
|
48357
|
-
sampleCount: require_dist.number().int(),
|
|
48358
|
-
/** Speed quantiles, subject-box-diagonals per second. `null` when there was
|
|
48359
|
-
* nothing to measure — never 0, which would read as a measured stillness
|
|
48360
|
-
* (D393). */
|
|
48361
|
-
p25: require_dist.number().nullable(),
|
|
48362
|
-
p50: require_dist.number().nullable(),
|
|
48363
|
-
p90: require_dist.number().nullable(),
|
|
48364
|
-
/** Median gap between stored positions (s) — the camera's real cadence. */
|
|
48365
|
-
medianIntervalSec: require_dist.number().nullable(),
|
|
48366
|
-
/** Median subject box diagonal (px) the speeds were normalised by. */
|
|
48367
|
-
medianSubjectDiagonalPx: require_dist.number().nullable()
|
|
48368
|
-
});
|
|
48369
|
-
var StillnessCalibrateInputSchema = require_dist.object({
|
|
48370
|
-
deviceId: require_dist.number().int(),
|
|
48371
|
-
/** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
|
|
48372
|
-
* row, so the window is a suggestion and `maxTracks` is the real bound. */
|
|
48373
|
-
sinceHours: require_dist.number().min(.25).max(720).default(72),
|
|
48374
|
-
/** Hard cost bound on tracks read, newest first. */
|
|
48375
|
-
maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
|
|
48376
|
-
/**
|
|
48377
|
-
* Write the chosen bar onto the camera. Never implicit, and never done at
|
|
48378
|
-
* all for a refused run.
|
|
48379
|
-
*/
|
|
48380
|
-
apply: require_dist.boolean().default(false)
|
|
48381
|
-
});
|
|
48382
|
-
var StillnessCalibrateResultSchema = require_dist.object({
|
|
48383
|
-
deviceId: require_dist.number().int(),
|
|
48384
|
-
/** The window actually read (epoch ms). */
|
|
48385
|
-
since: require_dist.number().int(),
|
|
48386
|
-
until: require_dist.number().int(),
|
|
48387
|
-
accepted: require_dist.boolean(),
|
|
48388
|
-
/** Why it refused. Absent on an accepted run. */
|
|
48389
|
-
reason: StillnessRefusalSchema.optional(),
|
|
48390
|
-
/** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
|
|
48391
|
-
* never 0, which the settings layer reads as "uncalibrated". */
|
|
48392
|
-
bar: require_dist.number().optional(),
|
|
48393
|
-
/** The raw quantile before the rails, so the clamp is arguable. */
|
|
48394
|
-
rawBar: require_dist.number().optional(),
|
|
48395
|
-
clampedBy: require_dist._enum([
|
|
48396
|
-
"none",
|
|
48397
|
-
"floor",
|
|
48398
|
-
"ceiling",
|
|
48399
|
-
"min",
|
|
48400
|
-
"max"
|
|
48401
|
-
]).optional(),
|
|
48402
|
-
measurement: StillnessMeasurementSchema,
|
|
48403
|
-
/** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
|
|
48404
|
-
* before any write, so the operator sees both numbers. */
|
|
48405
|
-
previousBar: require_dist.number().nullable(),
|
|
48406
|
-
/** Whether the camera's settings were actually written. */
|
|
48407
|
-
applied: require_dist.boolean(),
|
|
48408
|
-
/** The one-line note stored on the camera (accepted or refused). */
|
|
48409
|
-
note: require_dist.string()
|
|
48410
|
-
});
|
|
48411
|
-
var stillnessCalibrationActions = require_dist.defineCustomActions({
|
|
48412
|
-
/**
|
|
48413
|
-
* `admin` because it WRITES a per-camera detection threshold when `apply` is
|
|
48414
|
-
* set, and because the report describes how the camera's subjects move,
|
|
48415
|
-
* which is not a viewer-level fact.
|
|
48416
|
-
*/
|
|
48417
|
-
"stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
|
|
48418
|
-
kind: "mutation",
|
|
48419
|
-
auth: "admin"
|
|
48420
|
-
}) });
|
|
48421
|
-
/**
|
|
48422
|
-
* The shipped limits.
|
|
48423
|
-
*
|
|
48424
|
-
* `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
|
|
48425
|
-
* produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
|
|
48426
|
-
* camera that cannot clear these has genuinely not been watched enough.
|
|
48427
|
-
* `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
|
|
48428
|
-
* (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
|
|
48429
|
-
*/
|
|
48430
|
-
var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
|
|
48431
|
-
minTracks: 12,
|
|
48432
|
-
minSamples: 200,
|
|
48433
|
-
maxTracks: 200,
|
|
48434
|
-
minIntervalSec: .02,
|
|
48435
|
-
maxIntervalSec: 3,
|
|
48436
|
-
quantile: .25,
|
|
48437
|
-
floorFactor: .02,
|
|
48438
|
-
ceilFactor: .25,
|
|
48439
|
-
minBar: .001,
|
|
48440
|
-
maxBar: 1
|
|
48441
|
-
};
|
|
48442
|
-
/** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
|
|
48443
|
-
* would read as a measured zero (D393). */
|
|
48444
|
-
function quantileOf(values, p) {
|
|
48445
|
-
if (values.length === 0) return NaN;
|
|
48446
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
48447
|
-
const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
|
|
48448
|
-
const lo = Math.floor(idx);
|
|
48449
|
-
const hi = Math.ceil(idx);
|
|
48450
|
-
const at = sorted[lo];
|
|
48451
|
-
const next = sorted[hi];
|
|
48452
|
-
if (at === void 0 || next === void 0) return NaN;
|
|
48453
|
-
return at + (next - at) * (idx - lo);
|
|
48454
|
-
}
|
|
48455
|
-
/**
|
|
48456
|
-
* Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
|
|
48457
|
-
*
|
|
48458
|
-
* A pair of consecutive positions contributes a sample only when the interval
|
|
48459
|
-
* between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
|
|
48460
|
-
* has a positive diagonal. A gap outside that band is a session boundary or a
|
|
48461
|
-
* duplicate read, not a measurement of how fast anything travelled; a box with
|
|
48462
|
-
* no size is unknown geometry, and unknown is never a sample.
|
|
48463
|
-
*/
|
|
48464
|
-
function collectStillnessSamples(tracks, limits) {
|
|
48465
|
-
const speeds = [];
|
|
48466
|
-
const intervals = [];
|
|
48467
|
-
const diagonals = [];
|
|
48468
|
-
let trackCount = 0;
|
|
48469
|
-
const read = tracks.slice(0, limits.maxTracks);
|
|
48470
|
-
for (const track of read) {
|
|
48471
|
-
const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
|
|
48472
|
-
let contributed = false;
|
|
48473
|
-
for (let i = 1; i < positions.length; i++) {
|
|
48474
|
-
const prev = positions[i - 1];
|
|
48475
|
-
const cur = positions[i];
|
|
48476
|
-
if (prev === void 0 || cur === void 0) continue;
|
|
48477
|
-
const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
|
|
48478
|
-
if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
|
|
48479
|
-
const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
|
|
48480
|
-
if (!(diagonal > 0)) continue;
|
|
48481
|
-
const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
48482
|
-
speeds.push(travelled / diagonal / dtSec);
|
|
48483
|
-
intervals.push(dtSec);
|
|
48484
|
-
diagonals.push(diagonal);
|
|
48485
|
-
contributed = true;
|
|
48486
|
-
}
|
|
48487
|
-
if (contributed) trackCount++;
|
|
48488
|
-
}
|
|
48489
|
-
return {
|
|
48490
|
-
speeds,
|
|
48491
|
-
intervals,
|
|
48492
|
-
diagonals,
|
|
48493
|
-
trackCount,
|
|
48494
|
-
tracksRead: read.length
|
|
48495
|
-
};
|
|
48496
|
-
}
|
|
48497
|
-
function measure$1(samples) {
|
|
48498
|
-
return {
|
|
48499
|
-
trackCount: samples.trackCount,
|
|
48500
|
-
sampleCount: samples.speeds.length,
|
|
48501
|
-
p25: quantileOf(samples.speeds, .25),
|
|
48502
|
-
p50: quantileOf(samples.speeds, .5),
|
|
48503
|
-
p90: quantileOf(samples.speeds, .9),
|
|
48504
|
-
medianIntervalSec: quantileOf(samples.intervals, .5),
|
|
48505
|
-
medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
|
|
48506
|
-
};
|
|
48507
|
-
}
|
|
48508
|
-
/**
|
|
48509
|
-
* Choose this camera's stillness speed bar, or refuse with a named reason.
|
|
48510
|
-
*
|
|
48511
|
-
* The order of the gates is the order of the questions: is there anything at
|
|
48512
|
-
* all, is it enough tracks, is it enough samples, and did anything on this
|
|
48513
|
-
* camera ever actually move. The last one is not pedantry — the whole method
|
|
48514
|
-
* scales the bar against the camera's own travel, so with no travel there is
|
|
48515
|
-
* nothing to scale against and the default is the honest answer.
|
|
48516
|
-
*/
|
|
48517
|
-
function calibrateStillnessSpeed(samples, limits) {
|
|
48518
|
-
const measurement = measure$1(samples);
|
|
48519
|
-
if (samples.tracksRead === 0) return {
|
|
48520
|
-
ok: false,
|
|
48521
|
-
reason: "no-tracks",
|
|
48522
|
-
measurement
|
|
48523
|
-
};
|
|
48524
|
-
if (samples.trackCount < limits.minTracks) return {
|
|
48525
|
-
ok: false,
|
|
48526
|
-
reason: "too-few-tracks",
|
|
48527
|
-
measurement
|
|
48528
|
-
};
|
|
48529
|
-
if (samples.speeds.length < limits.minSamples) return {
|
|
48530
|
-
ok: false,
|
|
48531
|
-
reason: "too-few-samples",
|
|
48532
|
-
measurement
|
|
48533
|
-
};
|
|
48534
|
-
if (!(measurement.p90 > 0)) return {
|
|
48535
|
-
ok: false,
|
|
48536
|
-
reason: "no-motion-evidence",
|
|
48537
|
-
measurement
|
|
48538
|
-
};
|
|
48539
|
-
const rawBar = quantileOf(samples.speeds, limits.quantile);
|
|
48540
|
-
const floor = measurement.p90 * limits.floorFactor;
|
|
48541
|
-
const ceiling = measurement.p90 * limits.ceilFactor;
|
|
48542
|
-
let bar = rawBar;
|
|
48543
|
-
let clampedBy = "none";
|
|
48544
|
-
if (bar < floor) {
|
|
48545
|
-
bar = floor;
|
|
48546
|
-
clampedBy = "floor";
|
|
48547
|
-
} else if (bar > ceiling) {
|
|
48548
|
-
bar = ceiling;
|
|
48549
|
-
clampedBy = "ceiling";
|
|
48550
|
-
}
|
|
48551
|
-
if (bar < limits.minBar) {
|
|
48552
|
-
bar = limits.minBar;
|
|
48553
|
-
clampedBy = "min";
|
|
48554
|
-
} else if (bar > limits.maxBar) {
|
|
48555
|
-
bar = limits.maxBar;
|
|
48556
|
-
clampedBy = "max";
|
|
48557
|
-
}
|
|
48558
|
-
return {
|
|
48559
|
-
ok: true,
|
|
48560
|
-
bar,
|
|
48561
|
-
rawBar,
|
|
48562
|
-
clampedBy,
|
|
48563
|
-
measurement
|
|
48564
|
-
};
|
|
48565
|
-
}
|
|
48566
|
-
/** One line an operator can read: what was measured, from how much, and what
|
|
48567
|
-
* rule produced the stored number. Kept short — it rides the settings blob. */
|
|
48568
|
-
function describeStillnessCalibration(outcome) {
|
|
48569
|
-
const m = outcome.measurement;
|
|
48570
|
-
const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
|
|
48571
|
-
const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
|
|
48572
|
-
if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
|
|
48573
|
-
return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
|
|
48574
|
-
}
|
|
48575
|
-
//#endregion
|
|
48576
|
-
//#region src/pipeline-analytics/stillness-settings.ts
|
|
48577
|
-
/**
|
|
48578
|
-
* Per-device STILLNESS SCALE — the one number that says how fast a subject on
|
|
48579
|
-
* THIS camera has to move before it is travelling.
|
|
48580
|
-
*
|
|
48581
|
-
* A threshold belongs to whoever GENERATES the event, per device, never baked
|
|
48582
|
-
* into a classifier default. The judgement this feeds is
|
|
48583
|
-
* `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
|
|
48584
|
-
* `pipeline/tracker/stillness-scale.ts`, which also carries the live
|
|
48585
|
-
* measurement showing why one number cannot serve a 30 m driveway and a 2 m
|
|
48586
|
-
* doorway.
|
|
48587
|
-
*
|
|
48588
|
-
* Cascade and shape mirror `stationary-settings` / `media-settings`: a
|
|
48589
|
-
* per-device override on top of the default, resolved per FIELD, and a parse
|
|
48590
|
-
* that never throws on a bad blob.
|
|
48591
|
-
*
|
|
48592
|
-
* **Absent behaves exactly as today.** The device store is a flat numeric blob
|
|
48593
|
-
* with no room for "unset", so the stored form of absent is `0` — and `0` is
|
|
48594
|
-
* not a bar anyone could want (it would mean nothing on this camera is ever
|
|
48595
|
-
* still) and not a value the calibration can produce (`minBar`). The decision
|
|
48596
|
-
* layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
|
|
48597
|
-
* `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
|
|
48598
|
-
* absolute `velocityThreshold` path.
|
|
48599
|
-
*/
|
|
48600
|
-
var StillnessSettingsSchema = require_dist.object({
|
|
48601
|
-
/**
|
|
48602
|
-
* The camera's "not travelling" bar: centroid speed as a fraction of the
|
|
48603
|
-
* SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
|
|
48604
|
-
*
|
|
48605
|
-
* Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
|
|
48606
|
-
* close view, mostly parked subjects) to 0.3081 (device 615, far view, only
|
|
48607
|
-
* transits).
|
|
48608
|
-
*/
|
|
48609
|
-
stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
|
|
48610
|
-
/**
|
|
48611
|
-
* When the number above was last CHOSEN — by a calibration run or by the
|
|
48612
|
-
* operator. `0` = never. Purely a readout; nothing gates on it.
|
|
48613
|
-
*/
|
|
48614
|
-
stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
|
|
48615
|
-
/**
|
|
48616
|
-
* What the last calibration run measured and decided, in one line
|
|
48617
|
-
* (`describeStillnessCalibration`) — including a REFUSAL and its named
|
|
48618
|
-
* reason, because "why is this camera still on the default" has to be
|
|
48619
|
-
* answerable without re-running anything.
|
|
48620
|
-
*/
|
|
48621
|
-
stillnessCalibrationNote: require_dist.string().max(400).default("")
|
|
48622
|
-
});
|
|
48623
|
-
var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
|
|
48624
|
-
/**
|
|
48625
|
-
* Resolve a per-device store blob into typed stillness settings. Unknown or
|
|
48626
|
-
* invalid fields fall back to the default for that field — including a bar
|
|
48627
|
-
* outside the calibration's own rails, which is refused rather than clamped:
|
|
48628
|
-
* a number the calibration could never have produced is not evidence about
|
|
48629
|
-
* this camera, and silently bending it into range would present it as if it
|
|
48630
|
-
* were.
|
|
48631
|
-
*/
|
|
48632
|
-
function resolveStillnessSettings(raw) {
|
|
48633
|
-
const pick = (key) => {
|
|
48634
|
-
const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
|
|
48635
|
-
return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
|
|
48636
|
-
};
|
|
48637
|
-
return {
|
|
48638
|
-
stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
|
|
48639
|
-
stillnessCalibratedAt: pick("stillnessCalibratedAt"),
|
|
48640
|
-
stillnessCalibrationNote: pick("stillnessCalibrationNote")
|
|
48641
|
-
};
|
|
48642
|
-
}
|
|
48643
|
-
/**
|
|
48644
|
-
* The bar the decision layer gets: a positive, in-rails number, or
|
|
48645
|
-
* `undefined` for "this camera is not calibrated — judge it exactly as
|
|
48646
|
-
* before". The ONE place the stored-zero convention is interpreted.
|
|
48647
|
-
*/
|
|
48648
|
-
function resolveStillSpeedFracPerSec(settings) {
|
|
48649
|
-
const bar = settings.stillnessSpeedFracPerSec;
|
|
48650
|
-
if (!(bar >= .001) || bar > 1) return void 0;
|
|
48651
|
-
return bar;
|
|
48652
|
-
}
|
|
48653
|
-
//#endregion
|
|
48654
48269
|
//#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
|
|
48655
48270
|
/**
|
|
48656
48271
|
* @durable class=config owner=pipeline-analytics
|
|
@@ -48828,6 +48443,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
|
|
|
48828
48443
|
return retired;
|
|
48829
48444
|
}
|
|
48830
48445
|
/**
|
|
48446
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
48447
|
+
*
|
|
48448
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
48449
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
48450
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
48451
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
48452
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
48453
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
48454
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
48455
|
+
* The re-encode is smaller than what it replaces.
|
|
48456
|
+
*
|
|
48457
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
48458
|
+
* clip, one rate, two containers.
|
|
48459
|
+
*/
|
|
48460
|
+
var DEFAULT_SPEED = 4;
|
|
48461
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
48462
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
48463
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
48464
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
48465
|
+
var RING_MP4_FPS = 15;
|
|
48466
|
+
var NO_MEDIA = {
|
|
48467
|
+
mp4: null,
|
|
48468
|
+
gif: null,
|
|
48469
|
+
source: "none",
|
|
48470
|
+
startOffsetMs: null,
|
|
48471
|
+
endOffsetMs: null,
|
|
48472
|
+
profile: null,
|
|
48473
|
+
video: null
|
|
48474
|
+
};
|
|
48475
|
+
var EventMediaService = class {
|
|
48476
|
+
deps;
|
|
48477
|
+
constructor(deps) {
|
|
48478
|
+
this.deps = deps;
|
|
48479
|
+
}
|
|
48480
|
+
/**
|
|
48481
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
48482
|
+
* a notification, and every branch that drops it logs why.
|
|
48483
|
+
*/
|
|
48484
|
+
async cut(request) {
|
|
48485
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
48486
|
+
const produced = await this.produce(request);
|
|
48487
|
+
if (produced !== null) return produced;
|
|
48488
|
+
return this.fallbackToRing(request);
|
|
48489
|
+
}
|
|
48490
|
+
async produce(request) {
|
|
48491
|
+
const deviceId = request.deviceId;
|
|
48492
|
+
const log = this.deps.logger;
|
|
48493
|
+
const kinds = [];
|
|
48494
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
48495
|
+
if (request.wantGif) kinds.push("gif");
|
|
48496
|
+
let production;
|
|
48497
|
+
try {
|
|
48498
|
+
production = await this.deps.produce({
|
|
48499
|
+
deviceId,
|
|
48500
|
+
aroundMs: request.aroundMs,
|
|
48501
|
+
preSeconds: request.preRollSec,
|
|
48502
|
+
postSeconds: request.postRollSec,
|
|
48503
|
+
kinds,
|
|
48504
|
+
gifMaxWidth: 640,
|
|
48505
|
+
gifFps: 12,
|
|
48506
|
+
gifSpeed: 4,
|
|
48507
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
48508
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48509
|
+
});
|
|
48510
|
+
} catch (err) {
|
|
48511
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
48512
|
+
tags: { deviceId },
|
|
48513
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48514
|
+
});
|
|
48515
|
+
return null;
|
|
48516
|
+
}
|
|
48517
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
48518
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
48519
|
+
if (mp4 === null && gif === null) {
|
|
48520
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
48521
|
+
tags: { deviceId },
|
|
48522
|
+
meta: {
|
|
48523
|
+
kinds: kinds.join(","),
|
|
48524
|
+
profile: production.profile
|
|
48525
|
+
}
|
|
48526
|
+
});
|
|
48527
|
+
return null;
|
|
48528
|
+
}
|
|
48529
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
48530
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
48531
|
+
log.info("nc media: one production, every attachment", {
|
|
48532
|
+
tags: { deviceId },
|
|
48533
|
+
meta: {
|
|
48534
|
+
profile: production.profile,
|
|
48535
|
+
video: production.video,
|
|
48536
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48537
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48538
|
+
startOffsetMs,
|
|
48539
|
+
endOffsetMs,
|
|
48540
|
+
sharedWindow: true
|
|
48541
|
+
}
|
|
48542
|
+
});
|
|
48543
|
+
return {
|
|
48544
|
+
mp4,
|
|
48545
|
+
gif,
|
|
48546
|
+
source: "produced",
|
|
48547
|
+
startOffsetMs,
|
|
48548
|
+
endOffsetMs,
|
|
48549
|
+
profile: production.profile,
|
|
48550
|
+
video: production.video
|
|
48551
|
+
};
|
|
48552
|
+
}
|
|
48553
|
+
/**
|
|
48554
|
+
* Redeem one artifact handle at the node that produced it.
|
|
48555
|
+
*
|
|
48556
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
48557
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
48558
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
48559
|
+
* existed and did not arrive.
|
|
48560
|
+
*/
|
|
48561
|
+
async redeem(deviceId, media, kind) {
|
|
48562
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
48563
|
+
if (artifact === void 0) return null;
|
|
48564
|
+
try {
|
|
48565
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
48566
|
+
if (res === null) {
|
|
48567
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
48568
|
+
tags: { deviceId },
|
|
48569
|
+
meta: {
|
|
48570
|
+
kind,
|
|
48571
|
+
handle: artifact.handle,
|
|
48572
|
+
nodeId: artifact.nodeId
|
|
48573
|
+
}
|
|
48574
|
+
});
|
|
48575
|
+
return null;
|
|
48576
|
+
}
|
|
48577
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
48578
|
+
if (buf.byteLength === 0) return null;
|
|
48579
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
48580
|
+
bytes.set(buf);
|
|
48581
|
+
return bytes;
|
|
48582
|
+
} catch (err) {
|
|
48583
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
48584
|
+
tags: { deviceId },
|
|
48585
|
+
meta: {
|
|
48586
|
+
kind,
|
|
48587
|
+
handle: artifact.handle,
|
|
48588
|
+
nodeId: artifact.nodeId,
|
|
48589
|
+
error: err instanceof Error ? err.message : String(err)
|
|
48590
|
+
}
|
|
48591
|
+
});
|
|
48592
|
+
return null;
|
|
48593
|
+
}
|
|
48594
|
+
}
|
|
48595
|
+
/**
|
|
48596
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
48597
|
+
*
|
|
48598
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
48599
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
48600
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
48601
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
48602
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
48603
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
48604
|
+
* used and handed to the second.
|
|
48605
|
+
*
|
|
48606
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
48607
|
+
* production method must not stop attaching footage, and three of the four
|
|
48608
|
+
* live rules on this install ask for a gif and nothing else.
|
|
48609
|
+
*/
|
|
48610
|
+
async fallbackToRing(request) {
|
|
48611
|
+
const deviceId = request.deviceId;
|
|
48612
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
48613
|
+
const window = {
|
|
48614
|
+
deviceId,
|
|
48615
|
+
aroundMs: request.aroundMs,
|
|
48616
|
+
preRollSec: request.preRollSec,
|
|
48617
|
+
postRollSec: request.postRollSec,
|
|
48618
|
+
speed,
|
|
48619
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
48620
|
+
};
|
|
48621
|
+
try {
|
|
48622
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
48623
|
+
...window,
|
|
48624
|
+
format: "mp4",
|
|
48625
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
48626
|
+
fps: RING_MP4_FPS
|
|
48627
|
+
}) : null;
|
|
48628
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
48629
|
+
...window,
|
|
48630
|
+
format: "gif",
|
|
48631
|
+
maxWidth: 640,
|
|
48632
|
+
fps: 12 / 4,
|
|
48633
|
+
speed: 4
|
|
48634
|
+
}) : null;
|
|
48635
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
48636
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
48637
|
+
tags: { deviceId },
|
|
48638
|
+
meta: { aroundMs: request.aroundMs }
|
|
48639
|
+
});
|
|
48640
|
+
return NO_MEDIA;
|
|
48641
|
+
}
|
|
48642
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
48643
|
+
tags: { deviceId },
|
|
48644
|
+
meta: {
|
|
48645
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
48646
|
+
gifBytes: gif?.byteLength ?? null,
|
|
48647
|
+
profile: request.profile ?? null,
|
|
48648
|
+
sharedWindow: "by-parameters"
|
|
48649
|
+
}
|
|
48650
|
+
});
|
|
48651
|
+
return {
|
|
48652
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
48653
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
48654
|
+
source: "ring-fallback",
|
|
48655
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
48656
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
48657
|
+
profile: request.profile ?? null,
|
|
48658
|
+
video: null
|
|
48659
|
+
};
|
|
48660
|
+
} catch (err) {
|
|
48661
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
48662
|
+
tags: { deviceId },
|
|
48663
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
48664
|
+
});
|
|
48665
|
+
return NO_MEDIA;
|
|
48666
|
+
}
|
|
48667
|
+
}
|
|
48668
|
+
};
|
|
48669
|
+
/**
|
|
48831
48670
|
* How long the recorder keeps the file we are about to read and delete.
|
|
48832
48671
|
*
|
|
48833
48672
|
* `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
|
|
@@ -49081,230 +48920,6 @@ function decodeBase64(base64) {
|
|
|
49081
48920
|
return null;
|
|
49082
48921
|
}
|
|
49083
48922
|
}
|
|
49084
|
-
/**
|
|
49085
|
-
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
49086
|
-
*
|
|
49087
|
-
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
49088
|
-
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
49089
|
-
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
49090
|
-
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
49091
|
-
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
49092
|
-
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
49093
|
-
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
49094
|
-
* The re-encode is smaller than what it replaces.
|
|
49095
|
-
*
|
|
49096
|
-
* So both attachments now agree on the timeline as well as on the window: one
|
|
49097
|
-
* clip, one rate, two containers.
|
|
49098
|
-
*/
|
|
49099
|
-
var DEFAULT_SPEED = 4;
|
|
49100
|
-
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
49101
|
-
* at or below 1080p is an identity scale and the broker copies it. */
|
|
49102
|
-
var RING_MP4_MAX_WIDTH = 1920;
|
|
49103
|
-
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
49104
|
-
var RING_MP4_FPS = 15;
|
|
49105
|
-
var NO_MEDIA = {
|
|
49106
|
-
mp4: null,
|
|
49107
|
-
gif: null,
|
|
49108
|
-
source: "none",
|
|
49109
|
-
startOffsetMs: null,
|
|
49110
|
-
endOffsetMs: null,
|
|
49111
|
-
profile: null,
|
|
49112
|
-
video: null
|
|
49113
|
-
};
|
|
49114
|
-
var EventMediaService = class {
|
|
49115
|
-
deps;
|
|
49116
|
-
constructor(deps) {
|
|
49117
|
-
this.deps = deps;
|
|
49118
|
-
}
|
|
49119
|
-
/**
|
|
49120
|
-
* Cut this event. Never throws — a notification that lost its media is still
|
|
49121
|
-
* a notification, and every branch that drops it logs why.
|
|
49122
|
-
*/
|
|
49123
|
-
async cut(request) {
|
|
49124
|
-
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
49125
|
-
const produced = await this.produce(request);
|
|
49126
|
-
if (produced !== null) return produced;
|
|
49127
|
-
return this.fallbackToRing(request);
|
|
49128
|
-
}
|
|
49129
|
-
async produce(request) {
|
|
49130
|
-
const deviceId = request.deviceId;
|
|
49131
|
-
const log = this.deps.logger;
|
|
49132
|
-
const kinds = [];
|
|
49133
|
-
if (request.wantMp4) kinds.push("mp4");
|
|
49134
|
-
if (request.wantGif) kinds.push("gif");
|
|
49135
|
-
let production;
|
|
49136
|
-
try {
|
|
49137
|
-
production = await this.deps.produce({
|
|
49138
|
-
deviceId,
|
|
49139
|
-
aroundMs: request.aroundMs,
|
|
49140
|
-
preSeconds: request.preRollSec,
|
|
49141
|
-
postSeconds: request.postRollSec,
|
|
49142
|
-
kinds,
|
|
49143
|
-
gifMaxWidth: 640,
|
|
49144
|
-
gifFps: 12,
|
|
49145
|
-
gifSpeed: 4,
|
|
49146
|
-
speed: request.speed ?? DEFAULT_SPEED,
|
|
49147
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
49148
|
-
});
|
|
49149
|
-
} catch (err) {
|
|
49150
|
-
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
49151
|
-
tags: { deviceId },
|
|
49152
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
49153
|
-
});
|
|
49154
|
-
return null;
|
|
49155
|
-
}
|
|
49156
|
-
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
49157
|
-
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
49158
|
-
if (mp4 === null && gif === null) {
|
|
49159
|
-
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
49160
|
-
tags: { deviceId },
|
|
49161
|
-
meta: {
|
|
49162
|
-
kinds: kinds.join(","),
|
|
49163
|
-
profile: production.profile
|
|
49164
|
-
}
|
|
49165
|
-
});
|
|
49166
|
-
return null;
|
|
49167
|
-
}
|
|
49168
|
-
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
49169
|
-
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
49170
|
-
log.info("nc media: one production, every attachment", {
|
|
49171
|
-
tags: { deviceId },
|
|
49172
|
-
meta: {
|
|
49173
|
-
profile: production.profile,
|
|
49174
|
-
video: production.video,
|
|
49175
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
49176
|
-
gifBytes: gif?.byteLength ?? null,
|
|
49177
|
-
startOffsetMs,
|
|
49178
|
-
endOffsetMs,
|
|
49179
|
-
sharedWindow: true
|
|
49180
|
-
}
|
|
49181
|
-
});
|
|
49182
|
-
return {
|
|
49183
|
-
mp4,
|
|
49184
|
-
gif,
|
|
49185
|
-
source: "produced",
|
|
49186
|
-
startOffsetMs,
|
|
49187
|
-
endOffsetMs,
|
|
49188
|
-
profile: production.profile,
|
|
49189
|
-
video: production.video
|
|
49190
|
-
};
|
|
49191
|
-
}
|
|
49192
|
-
/**
|
|
49193
|
-
* Redeem one artifact handle at the node that produced it.
|
|
49194
|
-
*
|
|
49195
|
-
* `null` is not an error here — a production simply may not carry the kind
|
|
49196
|
-
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
49197
|
-
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
49198
|
-
* existed and did not arrive.
|
|
49199
|
-
*/
|
|
49200
|
-
async redeem(deviceId, media, kind) {
|
|
49201
|
-
const artifact = media.find((m) => m.kind === kind);
|
|
49202
|
-
if (artifact === void 0) return null;
|
|
49203
|
-
try {
|
|
49204
|
-
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
49205
|
-
if (res === null) {
|
|
49206
|
-
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
49207
|
-
tags: { deviceId },
|
|
49208
|
-
meta: {
|
|
49209
|
-
kind,
|
|
49210
|
-
handle: artifact.handle,
|
|
49211
|
-
nodeId: artifact.nodeId
|
|
49212
|
-
}
|
|
49213
|
-
});
|
|
49214
|
-
return null;
|
|
49215
|
-
}
|
|
49216
|
-
const buf = Buffer.from(res.base64, "base64");
|
|
49217
|
-
if (buf.byteLength === 0) return null;
|
|
49218
|
-
const bytes = new Uint8Array(buf.byteLength);
|
|
49219
|
-
bytes.set(buf);
|
|
49220
|
-
return bytes;
|
|
49221
|
-
} catch (err) {
|
|
49222
|
-
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
49223
|
-
tags: { deviceId },
|
|
49224
|
-
meta: {
|
|
49225
|
-
kind,
|
|
49226
|
-
handle: artifact.handle,
|
|
49227
|
-
nodeId: artifact.nodeId,
|
|
49228
|
-
error: err instanceof Error ? err.message : String(err)
|
|
49229
|
-
}
|
|
49230
|
-
});
|
|
49231
|
-
return null;
|
|
49232
|
-
}
|
|
49233
|
-
}
|
|
49234
|
-
/**
|
|
49235
|
-
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
49236
|
-
*
|
|
49237
|
-
* Weaker than a production and deliberately so — the coherence here rests on
|
|
49238
|
-
* the two calls being given IDENTICAL parameters rather than on there being
|
|
49239
|
-
* one render. That is defensible because the ring only ever grows forward and
|
|
49240
|
-
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
49241
|
-
* seconds apart choose the same packets; the one thing that could differ is
|
|
49242
|
-
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
49243
|
-
* used and handed to the second.
|
|
49244
|
-
*
|
|
49245
|
-
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
49246
|
-
* production method must not stop attaching footage, and three of the four
|
|
49247
|
-
* live rules on this install ask for a gif and nothing else.
|
|
49248
|
-
*/
|
|
49249
|
-
async fallbackToRing(request) {
|
|
49250
|
-
const deviceId = request.deviceId;
|
|
49251
|
-
const speed = request.speed ?? DEFAULT_SPEED;
|
|
49252
|
-
const window = {
|
|
49253
|
-
deviceId,
|
|
49254
|
-
aroundMs: request.aroundMs,
|
|
49255
|
-
preRollSec: request.preRollSec,
|
|
49256
|
-
postRollSec: request.postRollSec,
|
|
49257
|
-
speed,
|
|
49258
|
-
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
49259
|
-
};
|
|
49260
|
-
try {
|
|
49261
|
-
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
49262
|
-
...window,
|
|
49263
|
-
format: "mp4",
|
|
49264
|
-
maxWidth: RING_MP4_MAX_WIDTH,
|
|
49265
|
-
fps: RING_MP4_FPS
|
|
49266
|
-
}) : null;
|
|
49267
|
-
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
49268
|
-
...window,
|
|
49269
|
-
format: "gif",
|
|
49270
|
-
maxWidth: 640,
|
|
49271
|
-
fps: 12 / 4,
|
|
49272
|
-
speed: 4
|
|
49273
|
-
}) : null;
|
|
49274
|
-
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
49275
|
-
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
49276
|
-
tags: { deviceId },
|
|
49277
|
-
meta: { aroundMs: request.aroundMs }
|
|
49278
|
-
});
|
|
49279
|
-
return NO_MEDIA;
|
|
49280
|
-
}
|
|
49281
|
-
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
49282
|
-
tags: { deviceId },
|
|
49283
|
-
meta: {
|
|
49284
|
-
mp4Bytes: mp4?.byteLength ?? null,
|
|
49285
|
-
gifBytes: gif?.byteLength ?? null,
|
|
49286
|
-
profile: request.profile ?? null,
|
|
49287
|
-
sharedWindow: "by-parameters"
|
|
49288
|
-
}
|
|
49289
|
-
});
|
|
49290
|
-
return {
|
|
49291
|
-
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
49292
|
-
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
49293
|
-
source: "ring-fallback",
|
|
49294
|
-
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
49295
|
-
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
49296
|
-
profile: request.profile ?? null,
|
|
49297
|
-
video: null
|
|
49298
|
-
};
|
|
49299
|
-
} catch (err) {
|
|
49300
|
-
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
49301
|
-
tags: { deviceId },
|
|
49302
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
49303
|
-
});
|
|
49304
|
-
return NO_MEDIA;
|
|
49305
|
-
}
|
|
49306
|
-
}
|
|
49307
|
-
};
|
|
49308
48923
|
//#endregion
|
|
49309
48924
|
//#region src/shared/frame/crop-extractor.ts
|
|
49310
48925
|
/**
|
|
@@ -57575,6 +57190,41 @@ function resolveMediaSettings(raw) {
|
|
|
57575
57190
|
snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
|
|
57576
57191
|
};
|
|
57577
57192
|
}
|
|
57193
|
+
//#endregion
|
|
57194
|
+
//#region src/pipeline-analytics/our-recording-availability.ts
|
|
57195
|
+
/**
|
|
57196
|
+
* Coverage of OUR archive, asked of the `recording` collection by name.
|
|
57197
|
+
*
|
|
57198
|
+
* `recording` became a device collection on 2026-09-24 (D625): a camera may
|
|
57199
|
+
* have several sources of recorded coverage and every read names one. Every
|
|
57200
|
+
* read in this addon wants the same one — the footage OUR recorder wrote,
|
|
57201
|
+
* because everything downstream of it (a recorded clip cut, a timelapse, the
|
|
57202
|
+
* earliest-footage floor of a retention sweep) is about bytes we hold and can
|
|
57203
|
+
* export. A camera's own SD card is not an answer to any of those questions.
|
|
57204
|
+
*
|
|
57205
|
+
* It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
|
|
57206
|
+
* throws here rather than arriving downstream as an empty range list. Every
|
|
57207
|
+
* caller of this module treats "no ranges" as "recording is off for this
|
|
57208
|
+
* camera" and acts on it — dropping the attachment, skipping the render,
|
|
57209
|
+
* lowering the sweep floor — so folding a failed read into that shape would
|
|
57210
|
+
* make an unreachable recorder look exactly like a camera nobody records
|
|
57211
|
+
* (D393). The callers already handle a throw and log it as a read failure.
|
|
57212
|
+
*/
|
|
57213
|
+
/** Our coverage of `[fromMs, toMs)`, or a throw naming the failed read. */
|
|
57214
|
+
async function ourRecordingAvailability(api, input) {
|
|
57215
|
+
const answer = await api.recording.getAvailability.query({
|
|
57216
|
+
deviceId: input.deviceId,
|
|
57217
|
+
provider: require_dist.RECORDING_SOURCE_CAMSTACK_ADDON,
|
|
57218
|
+
fromMs: input.fromMs,
|
|
57219
|
+
toMs: input.toMs,
|
|
57220
|
+
...input.profile !== void 0 ? { profile: input.profile } : {}
|
|
57221
|
+
});
|
|
57222
|
+
if (answer.read === "unreadable") throw new Error(`recording.getAvailability: the CamStack archive was unreadable for device ${String(input.deviceId)} — no claim is made about its footage`);
|
|
57223
|
+
return {
|
|
57224
|
+
ranges: answer.ranges,
|
|
57225
|
+
profilesWithFootage: answer.profilesWithFootage
|
|
57226
|
+
};
|
|
57227
|
+
}
|
|
57578
57228
|
function toDetectionBbox(bbox) {
|
|
57579
57229
|
return {
|
|
57580
57230
|
x: bbox.x,
|
|
@@ -58866,6 +58516,17 @@ function resolveSearchThumbnailUrl(input) {
|
|
|
58866
58516
|
return `${input.baseUrl}/${encodeURIComponent(id)}`;
|
|
58867
58517
|
}
|
|
58868
58518
|
//#endregion
|
|
58519
|
+
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
58520
|
+
/**
|
|
58521
|
+
* May a suppression verdict RETRACT this birth?
|
|
58522
|
+
*
|
|
58523
|
+
* `false` means hold: keep the track, log the held verdict, and let the
|
|
58524
|
+
* ordinary lifetime decide. It never means "confirmed".
|
|
58525
|
+
*/
|
|
58526
|
+
function suppressionMayRetract(source) {
|
|
58527
|
+
return source !== "onboard";
|
|
58528
|
+
}
|
|
58529
|
+
//#endregion
|
|
58869
58530
|
//#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
|
|
58870
58531
|
/**
|
|
58871
58532
|
* Decide whether a package-class box is plausibly parcel-sized.
|
|
@@ -61559,65 +61220,6 @@ var StationaryEvidenceLedger = class {
|
|
|
61559
61220
|
for (const row of excess) this.forgetKey(row.key);
|
|
61560
61221
|
}
|
|
61561
61222
|
};
|
|
61562
|
-
//#endregion
|
|
61563
|
-
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
61564
|
-
function planStationaryTrackDisposal(input) {
|
|
61565
|
-
const trackIds = [input.liveTrackId];
|
|
61566
|
-
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
61567
|
-
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
61568
|
-
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
61569
|
-
return {
|
|
61570
|
-
trackIds,
|
|
61571
|
-
keepMediaIds
|
|
61572
|
-
};
|
|
61573
|
-
}
|
|
61574
|
-
/**
|
|
61575
|
-
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
61576
|
-
* persisted by a sweep racing this call; the cascade then removes what every
|
|
61577
|
-
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
61578
|
-
* the never-persisted live track).
|
|
61579
|
-
*
|
|
61580
|
-
* Never silent: the line names the entity, what went and what was kept — a
|
|
61581
|
-
* deleted track is dropped work, and the operator asked for it by name.
|
|
61582
|
-
*/
|
|
61583
|
-
async function disposeStationaryTracks(deps, plan) {
|
|
61584
|
-
const [liveTrackId] = plan.trackIds;
|
|
61585
|
-
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
61586
|
-
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
61587
|
-
const result = await runTrackCascadeBatch({
|
|
61588
|
-
registry: [...deps.leaves, sparingMedia],
|
|
61589
|
-
trackStore: deps.trackStore,
|
|
61590
|
-
deviceId: deps.deviceId,
|
|
61591
|
-
onTrackCleanup: deps.onTrackCleanup,
|
|
61592
|
-
onFailure: (trackId, err) => {
|
|
61593
|
-
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
61594
|
-
tags: { deviceId: deps.deviceId },
|
|
61595
|
-
meta: {
|
|
61596
|
-
deviceId: deps.deviceId,
|
|
61597
|
-
entryId: deps.entryId,
|
|
61598
|
-
trackId,
|
|
61599
|
-
error: String(err)
|
|
61600
|
-
}
|
|
61601
|
-
});
|
|
61602
|
-
}
|
|
61603
|
-
}, plan.trackIds);
|
|
61604
|
-
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
61605
|
-
tags: { deviceId: deps.deviceId },
|
|
61606
|
-
meta: {
|
|
61607
|
-
deviceId: deps.deviceId,
|
|
61608
|
-
entryId: deps.entryId,
|
|
61609
|
-
trackIds: plan.trackIds,
|
|
61610
|
-
keptMediaIds: [...plan.keepMediaIds],
|
|
61611
|
-
deleted: result.deleted,
|
|
61612
|
-
failed: result.failed,
|
|
61613
|
-
mediaRows: result.tally.media
|
|
61614
|
-
}
|
|
61615
|
-
});
|
|
61616
|
-
return {
|
|
61617
|
-
deleted: result.deleted,
|
|
61618
|
-
failed: result.failed
|
|
61619
|
-
};
|
|
61620
|
-
}
|
|
61621
61223
|
/**
|
|
61622
61224
|
* How long the same weak box must keep being seen before it may re-anchor.
|
|
61623
61225
|
*
|
|
@@ -62559,6 +62161,97 @@ function rowToEntry(id, data) {
|
|
|
62559
62161
|
};
|
|
62560
62162
|
}
|
|
62561
62163
|
//#endregion
|
|
62164
|
+
//#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
|
|
62165
|
+
function planStationaryTrackDisposal(input) {
|
|
62166
|
+
const trackIds = [input.liveTrackId];
|
|
62167
|
+
if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
|
|
62168
|
+
const keepMediaIds = /* @__PURE__ */ new Set();
|
|
62169
|
+
if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
|
|
62170
|
+
return {
|
|
62171
|
+
trackIds,
|
|
62172
|
+
keepMediaIds
|
|
62173
|
+
};
|
|
62174
|
+
}
|
|
62175
|
+
/**
|
|
62176
|
+
* Run the plan. The live track is dropped from RAM FIRST so it can never be
|
|
62177
|
+
* persisted by a sweep racing this call; the cascade then removes what every
|
|
62178
|
+
* track owned, sparing the entry's key frame, and deletes the row (a no-op for
|
|
62179
|
+
* the never-persisted live track).
|
|
62180
|
+
*
|
|
62181
|
+
* Never silent: the line names the entity, what went and what was kept — a
|
|
62182
|
+
* deleted track is dropped work, and the operator asked for it by name.
|
|
62183
|
+
*/
|
|
62184
|
+
async function disposeStationaryTracks(deps, plan) {
|
|
62185
|
+
const [liveTrackId] = plan.trackIds;
|
|
62186
|
+
if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
|
|
62187
|
+
const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
|
|
62188
|
+
const result = await runTrackCascadeBatch({
|
|
62189
|
+
registry: [...deps.leaves, sparingMedia],
|
|
62190
|
+
trackStore: deps.trackStore,
|
|
62191
|
+
deviceId: deps.deviceId,
|
|
62192
|
+
onTrackCleanup: deps.onTrackCleanup,
|
|
62193
|
+
onFailure: (trackId, err) => {
|
|
62194
|
+
deps.logger.warn("stationary promotion: a source track could not be disposed of", {
|
|
62195
|
+
tags: { deviceId: deps.deviceId },
|
|
62196
|
+
meta: {
|
|
62197
|
+
deviceId: deps.deviceId,
|
|
62198
|
+
entryId: deps.entryId,
|
|
62199
|
+
trackId,
|
|
62200
|
+
error: String(err)
|
|
62201
|
+
}
|
|
62202
|
+
});
|
|
62203
|
+
}
|
|
62204
|
+
}, plan.trackIds);
|
|
62205
|
+
deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
|
|
62206
|
+
tags: { deviceId: deps.deviceId },
|
|
62207
|
+
meta: {
|
|
62208
|
+
deviceId: deps.deviceId,
|
|
62209
|
+
entryId: deps.entryId,
|
|
62210
|
+
trackIds: plan.trackIds,
|
|
62211
|
+
keptMediaIds: [...plan.keepMediaIds],
|
|
62212
|
+
deleted: result.deleted,
|
|
62213
|
+
failed: result.failed,
|
|
62214
|
+
mediaRows: result.tally.media
|
|
62215
|
+
}
|
|
62216
|
+
});
|
|
62217
|
+
return {
|
|
62218
|
+
deleted: result.deleted,
|
|
62219
|
+
failed: result.failed
|
|
62220
|
+
};
|
|
62221
|
+
}
|
|
62222
|
+
//#endregion
|
|
62223
|
+
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
62224
|
+
/**
|
|
62225
|
+
* WHICH detection planes carry the stationary machinery.
|
|
62226
|
+
*
|
|
62227
|
+
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
62228
|
+
* that is what the registry needs to decide an object is parked: a box, a
|
|
62229
|
+
* class, and an observed clock that only advances while frames flow.
|
|
62230
|
+
*
|
|
62231
|
+
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
62232
|
+
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
62233
|
+
* frame in that frame's pixel space — the same plane, entered by a different
|
|
62234
|
+
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
62235
|
+
*
|
|
62236
|
+
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
62237
|
+
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
62238
|
+
* written for had been measured (617, the ML plane), never an argument that the
|
|
62239
|
+
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
62240
|
+
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
62241
|
+
* sits there, and every detection session runs to its 120 s cap instead of
|
|
62242
|
+
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
62243
|
+
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
62244
|
+
* detection, on a battery camera.
|
|
62245
|
+
*
|
|
62246
|
+
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
62247
|
+
* this repo has already paid for that with two authorities on one function
|
|
62248
|
+
* (D62).
|
|
62249
|
+
*/
|
|
62250
|
+
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
62251
|
+
function planeRunsStationary(source) {
|
|
62252
|
+
return source === "pipeline" || source === "onboard";
|
|
62253
|
+
}
|
|
62254
|
+
//#endregion
|
|
62562
62255
|
//#region src/pipeline-analytics/pipeline/suppressed-births.ts
|
|
62563
62256
|
/**
|
|
62564
62257
|
* Memory of births the confirmation gate rejected, per device.
|
|
@@ -63474,49 +63167,6 @@ function hasKeyFrame(source) {
|
|
|
63474
63167
|
return source.keyFrame !== null && source.bbox !== null;
|
|
63475
63168
|
}
|
|
63476
63169
|
//#endregion
|
|
63477
|
-
//#region src/pipeline-analytics/pipeline/stationary-planes.ts
|
|
63478
|
-
/**
|
|
63479
|
-
* WHICH detection planes carry the stationary machinery.
|
|
63480
|
-
*
|
|
63481
|
-
* A plane qualifies when it produces OBJECT BOXES on decoded frames — because
|
|
63482
|
-
* that is what the registry needs to decide an object is parked: a box, a
|
|
63483
|
-
* class, and an observed clock that only advances while frames flow.
|
|
63484
|
-
*
|
|
63485
|
-
* Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
|
|
63486
|
-
* camera's own firmware, and the boxes it produces are paired with a decoded
|
|
63487
|
-
* frame in that frame's pixel space — the same plane, entered by a different
|
|
63488
|
-
* door. `sensor` and `audio` do not: they carry no geometry to park.
|
|
63489
|
-
*
|
|
63490
|
-
* It used to be `source === 'pipeline'`, in five places, each explaining that
|
|
63491
|
-
* the gate "runs on that plane". The reason given was WHERE the flood it was
|
|
63492
|
-
* written for had been measured (617, the ML plane), never an argument that the
|
|
63493
|
-
* firmware's boxes were unfit. The cost of the accident, on device 640: a still
|
|
63494
|
-
* object is never promoted, so it holds `hasLiveTrack` true for as long as it
|
|
63495
|
-
* sits there, and every detection session runs to its 120 s cap instead of
|
|
63496
|
-
* closing on cooldown — `closed at the max-hold cap with a subject still
|
|
63497
|
-
* present`. The camera's lazy stream is then torn down and rebuilt on the next
|
|
63498
|
-
* detection, on a battery camera.
|
|
63499
|
-
*
|
|
63500
|
-
* One predicate and not five comparisons, because five copies of a rule drift:
|
|
63501
|
-
* this repo has already paid for that with two authorities on one function
|
|
63502
|
-
* (D62).
|
|
63503
|
-
*/
|
|
63504
|
-
/** Does this detection plane run the stationary registry, gate and promotion? */
|
|
63505
|
-
function planeRunsStationary(source) {
|
|
63506
|
-
return source === "pipeline" || source === "onboard";
|
|
63507
|
-
}
|
|
63508
|
-
//#endregion
|
|
63509
|
-
//#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
|
|
63510
|
-
/**
|
|
63511
|
-
* May a suppression verdict RETRACT this birth?
|
|
63512
|
-
*
|
|
63513
|
-
* `false` means hold: keep the track, log the held verdict, and let the
|
|
63514
|
-
* ordinary lifetime decide. It never means "confirmed".
|
|
63515
|
-
*/
|
|
63516
|
-
function suppressionMayRetract(source) {
|
|
63517
|
-
return source !== "onboard";
|
|
63518
|
-
}
|
|
63519
|
-
//#endregion
|
|
63520
63170
|
//#region src/pipeline-analytics/rebuild-source-loader.ts
|
|
63521
63171
|
/**
|
|
63522
63172
|
* Assemble one track's rebuild inputs, reading at most ONE blob.
|
|
@@ -78648,7 +78298,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78648
78298
|
await step("buildSceneMonitor", () => this.buildSceneMonitor(api, logger));
|
|
78649
78299
|
this.startSweepTimers();
|
|
78650
78300
|
this.ctx.logger.info("pipeline-analytics subscribers installed");
|
|
78651
|
-
const providers = await step("buildProviderRegistrations", () => this.buildProviderRegistrations(
|
|
78301
|
+
const providers = await step("buildProviderRegistrations", () => this.buildProviderRegistrations(stores, capProviders));
|
|
78652
78302
|
const isHub = ownNodeId === "hub";
|
|
78653
78303
|
const ncHandlers = isHub ? this.buildNcActionHandlers(api) : void 0;
|
|
78654
78304
|
this.discardJob = createDiscardJob({
|
|
@@ -78727,7 +78377,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78727
78377
|
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
|
|
78728
78378
|
timelapse: {
|
|
78729
78379
|
store: center.timelapseStore,
|
|
78730
|
-
getRecordingConfig: (deviceId) => api.
|
|
78380
|
+
getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
|
|
78731
78381
|
runNow: (input) => center.runTimelapseNow(input)
|
|
78732
78382
|
},
|
|
78733
78383
|
summary: {
|
|
@@ -79820,7 +79470,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
79820
79470
|
logger: logger.child("RecordedClip"),
|
|
79821
79471
|
now: () => Date.now(),
|
|
79822
79472
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
79823
|
-
getAvailability: (input) => api
|
|
79473
|
+
getAvailability: (input) => ourRecordingAvailability(api, input),
|
|
79824
79474
|
createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
|
|
79825
79475
|
getExport: (input) => api.recordingExport.getExport.query(input),
|
|
79826
79476
|
readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
|
|
@@ -80074,7 +79724,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
80074
79724
|
buildTimelapsePorts(api, stores) {
|
|
80075
79725
|
return {
|
|
80076
79726
|
getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
|
|
80077
|
-
const availability = await api
|
|
79727
|
+
const availability = await ourRecordingAvailability(api, {
|
|
80078
79728
|
deviceId,
|
|
80079
79729
|
fromMs,
|
|
80080
79730
|
toMs,
|
|
@@ -81052,71 +80702,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
81052
80702
|
});
|
|
81053
80703
|
}
|
|
81054
80704
|
}
|
|
81055
|
-
buildProviderRegistrations(
|
|
80705
|
+
buildProviderRegistrations(stores, providers) {
|
|
81056
80706
|
const widgetsProvider = createAnalyticsWidgetsProvider();
|
|
81057
|
-
const videoclipsProvider = createVideoclipsProvider({
|
|
81058
|
-
defaultPad: require_dist.EVENT_PAD_MS,
|
|
81059
|
-
fetchEvents: async ({ deviceId, since, until, limit }) => {
|
|
81060
|
-
const [motion, object, audio] = await Promise.all([
|
|
81061
|
-
this.getMotionEvents({
|
|
81062
|
-
deviceId,
|
|
81063
|
-
since,
|
|
81064
|
-
until,
|
|
81065
|
-
limit,
|
|
81066
|
-
projection: "slim"
|
|
81067
|
-
}),
|
|
81068
|
-
this.getObjectEvents({
|
|
81069
|
-
deviceId,
|
|
81070
|
-
since,
|
|
81071
|
-
until,
|
|
81072
|
-
limit,
|
|
81073
|
-
projection: "slim"
|
|
81074
|
-
}),
|
|
81075
|
-
this.getAudioEvents({
|
|
81076
|
-
deviceId,
|
|
81077
|
-
since,
|
|
81078
|
-
until,
|
|
81079
|
-
limit,
|
|
81080
|
-
projection: "slim"
|
|
81081
|
-
})
|
|
81082
|
-
]);
|
|
81083
|
-
return {
|
|
81084
|
-
motion: toEventLike(motion),
|
|
81085
|
-
object: toEventLike(object),
|
|
81086
|
-
audio: toEventLike(audio)
|
|
81087
|
-
};
|
|
81088
|
-
},
|
|
81089
|
-
fetchMediaOwners: async ({ deviceId, ownerKind, ownerIds }) => {
|
|
81090
|
-
return stores.mediaStore.ownersWithMedia(ownerKind, ownerIds, deviceId);
|
|
81091
|
-
},
|
|
81092
|
-
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
81093
|
-
return (await api.recording.getAvailability.query({
|
|
81094
|
-
deviceId,
|
|
81095
|
-
fromMs: since,
|
|
81096
|
-
toMs: until
|
|
81097
|
-
})).ranges;
|
|
81098
|
-
},
|
|
81099
|
-
logWarn: (message, meta) => this.ctx.logger.warn(message, {
|
|
81100
|
-
tags: { deviceId: Number(meta["deviceId"]) },
|
|
81101
|
-
meta
|
|
81102
|
-
}),
|
|
81103
|
-
logInfo: (message, meta) => this.ctx.logger.info(message, {
|
|
81104
|
-
tags: { deviceId: Number(meta["deviceId"]) },
|
|
81105
|
-
meta
|
|
81106
|
-
}),
|
|
81107
|
-
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
81108
|
-
const manifest = await api.recording.getPlaybackManifest.query({
|
|
81109
|
-
deviceId,
|
|
81110
|
-
fromMs: startMs,
|
|
81111
|
-
toMs: endMs
|
|
81112
|
-
});
|
|
81113
|
-
if (manifest.playbackUrl == null) throw new Error(`videoclips: no footage for clip window [${startMs},${endMs}] on device ${deviceId}`);
|
|
81114
|
-
return {
|
|
81115
|
-
playbackUrl: manifest.playbackUrl,
|
|
81116
|
-
playbackEndpoints: manifest.playbackEndpoints
|
|
81117
|
-
};
|
|
81118
|
-
}
|
|
81119
|
-
});
|
|
81120
80707
|
const faceGallery = new FaceGalleryProvider({
|
|
81121
80708
|
identityStore: stores.identityStore,
|
|
81122
80709
|
faceStore: stores.faceStore,
|
|
@@ -81179,10 +80766,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
81179
80766
|
capability: require_dist.addonWidgetsSourceCapability,
|
|
81180
80767
|
provider: widgetsProvider
|
|
81181
80768
|
},
|
|
81182
|
-
{
|
|
81183
|
-
capability: require_dist.videoclipsCapability,
|
|
81184
|
-
provider: videoclipsProvider
|
|
81185
|
-
},
|
|
81186
80769
|
{
|
|
81187
80770
|
capability: require_dist.faceGalleryCapability,
|
|
81188
80771
|
provider: faceGallery
|
|
@@ -84332,7 +83915,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
84332
83915
|
if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
|
|
84333
83916
|
let value = null;
|
|
84334
83917
|
try {
|
|
84335
|
-
const res = await this.ctx.api
|
|
83918
|
+
const res = await ourRecordingAvailability(this.ctx.api, {
|
|
84336
83919
|
deviceId,
|
|
84337
83920
|
fromMs: 0,
|
|
84338
83921
|
toMs: Date.now()
|
|
@@ -85835,6 +85418,24 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85835
85418
|
return this.queryFacade.listEventMedia(input);
|
|
85836
85419
|
}
|
|
85837
85420
|
/**
|
|
85421
|
+
* Which of `ownerIds` hold at least ONE media row — the media-PRESENCE probe,
|
|
85422
|
+
* on the wire because the `videoclips` source lives in the addon that owns
|
|
85423
|
+
* the FOOTAGE now (D560).
|
|
85424
|
+
*
|
|
85425
|
+
* A pass-through to {@link MediaStore.ownersWithMedia}, which batches by
|
|
85426
|
+
* `whereIn` and pages: the N+1 this exists to prevent is one query per owner,
|
|
85427
|
+
* and a process boundary in front of it would multiply that, not absorb it.
|
|
85428
|
+
*
|
|
85429
|
+
* It THROWS rather than answering partially, and the empty ask costs no read
|
|
85430
|
+
* at all — a caller with nothing to probe must not pay a round trip for it.
|
|
85431
|
+
*/
|
|
85432
|
+
async ownersWithMedia(input) {
|
|
85433
|
+
if (input.ownerIds.length === 0) return [];
|
|
85434
|
+
const store = this.mediaStore;
|
|
85435
|
+
if (store === null) throw new Error(`pipelineAnalytics.ownersWithMedia: the media index is not open on this node — refusing rather than answering "these owners have nothing" (device ${String(input.deviceId)})`);
|
|
85436
|
+
return [...await store.ownersWithMedia(input.ownerKind, input.ownerIds, input.deviceId)];
|
|
85437
|
+
}
|
|
85438
|
+
/**
|
|
85838
85439
|
* Search object events by text using CLIP cosine similarity.
|
|
85839
85440
|
*
|
|
85840
85441
|
* Flow:
|
|
@@ -86360,7 +85961,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
86360
85961
|
} });
|
|
86361
85962
|
const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
|
|
86362
85963
|
const recordedStills = new RecordedStillService(buildRecordedStillPorts({
|
|
86363
|
-
api: this.ctx.api.
|
|
85964
|
+
api: this.ctx.api.recordingArchive,
|
|
86364
85965
|
logger: this.ctx.logger
|
|
86365
85966
|
}));
|
|
86366
85967
|
const deps = {
|