@camstack/addon-post-analysis 1.2.273 → 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.
@@ -1,4 +1,4 @@
1
- import { $ as TrackSourceSchema, $t as literal, A as NcRuleInputSchema, At as resolveLocationMode, B as OpsLogEntrySchema, Bt as CamProfileSchema, C as MediaFileKindEnum, Ct as occupancyScope, Dt as plateGalleryCapability, E as NC_DEFAULT_SNOOZE_MINUTES, Et as pipelineAnalyticsCapability, F as NcSnoozeInputSchema, Ft as systemEventFilterApplies, G as SCENE_DEFAULT_ANCHOR_THRESHOLD, Gt as hydrateSchema, H as RECORDING_EXPORT_MAX_READ_BYTES, Ht as createEvent, I as NcSnoozeSchema, It as vectorDimFromBase64, J as SceneMonitorSchema, Jt as sleep$1, K as SCENE_DEFAULT_UNCOVERED_POLICY, Kt as isDeviceScopedCap, L as NcSnoozeSuppressedSchema, Lt as zoneAnalyticsCapability, M as NcRuleSchema, Mt as sceneMonitorCapability, N as NcRuleTargetSchema, Nt as storageOccupancyCapability, O as NC_TAXONOMY, Ot as readDeviceStateFrom, P as NcScheduleSchema, Pt as subKindsOf, Q as TimelapseRuleSchema, Qt as discriminatedUnion, R as NcSystemEventKindSchema, Rt as errMsg$1, S as MOTION_CLOSE_AFTER_MS, St as notificationRulesCapability, T as NC_CONDITION_CATALOG, Tt as pickClusterStepModels, U as RetrainStatusSchema, Ut as customAction, Vt as DeviceType, Wt as defineCustomActions, X as TimelapseRuleInputSchema, Xt as array, Y as TIMELAPSE_DENSE_FLOOR_SEC, Yt as _enum, Z as TimelapseRulePatchSchema, Zt as boolean, _ as FailureCounters, _t as isMediaOwnerType, a as CLUSTER_MODEL_SCOPED_STEPS, an as unknown, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, dt as evictionPolicyOfLocation, en as number, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as faceGalleryCapability, g as FULL_IMAGE_BBOX, gt as isEventOwnerType, h as FIRST_LEVEL_MACRO_CLASSES, ht as isDetectionMacroClass, i as BirthDecisionRecordSchema, in as string, it as audioModeOf, j as NcRulePatchSchema, k as NcConditionDescriptorSchema, kt as readTimelapseGeneratedAt, lt as encodeVectorBase64, m as EVENT_OWNER_TYPES, n as ArchivedDebugNoteSchema, nn as partialRecord, nt as assertTimelapseCadences, o as COCO_TO_MACRO, on as EventCategory, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as failureContributionCapability, q as SCENE_DIVERGED, qt as nodePin, r as BaseDevice, rn as record, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as deriveRecordingMode, t as AUDIO_MACRO_LABELS, tn as object, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, ut as evaluateSensorEdge, v as LabelAttributionSchema, vt as isScheduleActive, w as NC_ALARM_SYSTEM_EVENT_KINDS, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as NcTaxonomySchema, zt as BaseAddon } from "../dist-PZVHV70g.mjs";
1
+ import { $ as TimelapseRuleSchema, $t as discriminatedUnion, A as NcRuleInputSchema, At as readTimelapseGeneratedAt, B as OpsLogEntrySchema, Bt as BaseAddon, C as MediaFileKindEnum, Ct as notificationRulesCapability, Dt as pipelineAnalyticsCapability, E as NC_DEFAULT_SNOOZE_MINUTES, Et as pickClusterStepModels, F as NcSnoozeInputSchema, Ft as subKindsOf, Gt as defineCustomActions, H as RECORDING_EXPORT_MAX_READ_BYTES, Ht as DeviceType, I as NcSnoozeSchema, It as systemEventFilterApplies, J as SCENE_DIVERGED, Jt as nodePin, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as hydrateSchema, L as NcSnoozeSuppressedSchema, Lt as vectorDimFromBase64, M as NcRuleSchema, N as NcRuleTargetSchema, Nt as sceneMonitorCapability, O as NC_TAXONOMY, Ot as plateGalleryCapability, P as NcScheduleSchema, Pt as storageOccupancyCapability, Q as TimelapseRulePatchSchema, Qt as boolean, R as NcSystemEventKindSchema, Rt as zoneAnalyticsCapability, S as MOTION_CLOSE_AFTER_MS, St as mayWriteToLocation, T as NC_CONDITION_CATALOG, U as RECORDING_SOURCE_CAMSTACK_ADDON, Ut as createEvent, Vt as CamProfileSchema, W as RetrainStatusSchema, Wt as customAction, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as _enum, Y as SceneMonitorSchema, Yt as sleep$1, Z as TimelapseRuleInputSchema, Zt as array, _ as FailureCounters, _t as isEventOwnerType, a as CLUSTER_MODEL_SCOPED_STEPS, an as string, at as audioModeOf, b as MAX_BIRTH_DECISION_RECORDS, bt as isSourceCap, ct as deriveRecordingMode, dt as evaluateSensorEdge, en as literal, et as TrackSourceSchema, f as DeclaredDevices, ft as evictionPolicyOfLocation, g as FULL_IMAGE_BBOX, gt as isDetectionMacroClass, h as FIRST_LEVEL_MACRO_CLASSES, i as BirthDecisionRecordSchema, in as record, it as audioMetricsCapability, j as NcRulePatchSchema, jt as resolveLocationMode, k as NcConditionDescriptorSchema, kt as readDeviceStateFrom, m as EVENT_OWNER_TYPES, mt as failureContributionCapability, n as ArchivedDebugNoteSchema, nn as object, nt as alarmPanelCapability, o as COCO_TO_MACRO, on as unknown, ot as buildEventKindDescriptor, p as EVENT_KIND_BY_CAP, pt as faceGalleryCapability, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as isDeviceScopedCap, r as BaseDevice, rn as partialRecord, rt as assertTimelapseCadences, s as DEFAULT_EVENT_COLOR, sn as EventCategory, st as cosineSimilarity$1, t as AUDIO_MACRO_LABELS, tn as number, tt as addonWidgetsSourceCapability, u as DETECTION_MACRO_CLASSES, ut as encodeVectorBase64, v as LabelAttributionSchema, vt as isMediaOwnerType, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as occupancyScope, xt as kebabToCamel, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isScheduleActive, z as NcTaxonomySchema, zt as errMsg$1 } from "../dist-DDko6srJ.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -41691,6 +41691,160 @@ var TrackResidentState = class {
41691
41691
  if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
41692
41692
  }
41693
41693
  };
41694
+ /**
41695
+ * The shipped limits.
41696
+ *
41697
+ * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
41698
+ * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
41699
+ * camera that cannot clear these has genuinely not been watched enough.
41700
+ * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
41701
+ * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
41702
+ */
41703
+ var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
41704
+ minTracks: 12,
41705
+ minSamples: 200,
41706
+ maxTracks: 200,
41707
+ minIntervalSec: .02,
41708
+ maxIntervalSec: 3,
41709
+ quantile: .25,
41710
+ floorFactor: .02,
41711
+ ceilFactor: .25,
41712
+ minBar: .001,
41713
+ maxBar: 1
41714
+ };
41715
+ /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
41716
+ * would read as a measured zero (D393). */
41717
+ function quantileOf(values, p) {
41718
+ if (values.length === 0) return NaN;
41719
+ const sorted = [...values].sort((a, b) => a - b);
41720
+ const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
41721
+ const lo = Math.floor(idx);
41722
+ const hi = Math.ceil(idx);
41723
+ const at = sorted[lo];
41724
+ const next = sorted[hi];
41725
+ if (at === void 0 || next === void 0) return NaN;
41726
+ return at + (next - at) * (idx - lo);
41727
+ }
41728
+ /**
41729
+ * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
41730
+ *
41731
+ * A pair of consecutive positions contributes a sample only when the interval
41732
+ * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
41733
+ * has a positive diagonal. A gap outside that band is a session boundary or a
41734
+ * duplicate read, not a measurement of how fast anything travelled; a box with
41735
+ * no size is unknown geometry, and unknown is never a sample.
41736
+ */
41737
+ function collectStillnessSamples(tracks, limits) {
41738
+ const speeds = [];
41739
+ const intervals = [];
41740
+ const diagonals = [];
41741
+ let trackCount = 0;
41742
+ const read = tracks.slice(0, limits.maxTracks);
41743
+ for (const track of read) {
41744
+ const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
41745
+ let contributed = false;
41746
+ for (let i = 1; i < positions.length; i++) {
41747
+ const prev = positions[i - 1];
41748
+ const cur = positions[i];
41749
+ if (prev === void 0 || cur === void 0) continue;
41750
+ const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
41751
+ if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
41752
+ const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
41753
+ if (!(diagonal > 0)) continue;
41754
+ const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
41755
+ speeds.push(travelled / diagonal / dtSec);
41756
+ intervals.push(dtSec);
41757
+ diagonals.push(diagonal);
41758
+ contributed = true;
41759
+ }
41760
+ if (contributed) trackCount++;
41761
+ }
41762
+ return {
41763
+ speeds,
41764
+ intervals,
41765
+ diagonals,
41766
+ trackCount,
41767
+ tracksRead: read.length
41768
+ };
41769
+ }
41770
+ function measure$1(samples) {
41771
+ return {
41772
+ trackCount: samples.trackCount,
41773
+ sampleCount: samples.speeds.length,
41774
+ p25: quantileOf(samples.speeds, .25),
41775
+ p50: quantileOf(samples.speeds, .5),
41776
+ p90: quantileOf(samples.speeds, .9),
41777
+ medianIntervalSec: quantileOf(samples.intervals, .5),
41778
+ medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
41779
+ };
41780
+ }
41781
+ /**
41782
+ * Choose this camera's stillness speed bar, or refuse with a named reason.
41783
+ *
41784
+ * The order of the gates is the order of the questions: is there anything at
41785
+ * all, is it enough tracks, is it enough samples, and did anything on this
41786
+ * camera ever actually move. The last one is not pedantry — the whole method
41787
+ * scales the bar against the camera's own travel, so with no travel there is
41788
+ * nothing to scale against and the default is the honest answer.
41789
+ */
41790
+ function calibrateStillnessSpeed(samples, limits) {
41791
+ const measurement = measure$1(samples);
41792
+ if (samples.tracksRead === 0) return {
41793
+ ok: false,
41794
+ reason: "no-tracks",
41795
+ measurement
41796
+ };
41797
+ if (samples.trackCount < limits.minTracks) return {
41798
+ ok: false,
41799
+ reason: "too-few-tracks",
41800
+ measurement
41801
+ };
41802
+ if (samples.speeds.length < limits.minSamples) return {
41803
+ ok: false,
41804
+ reason: "too-few-samples",
41805
+ measurement
41806
+ };
41807
+ if (!(measurement.p90 > 0)) return {
41808
+ ok: false,
41809
+ reason: "no-motion-evidence",
41810
+ measurement
41811
+ };
41812
+ const rawBar = quantileOf(samples.speeds, limits.quantile);
41813
+ const floor = measurement.p90 * limits.floorFactor;
41814
+ const ceiling = measurement.p90 * limits.ceilFactor;
41815
+ let bar = rawBar;
41816
+ let clampedBy = "none";
41817
+ if (bar < floor) {
41818
+ bar = floor;
41819
+ clampedBy = "floor";
41820
+ } else if (bar > ceiling) {
41821
+ bar = ceiling;
41822
+ clampedBy = "ceiling";
41823
+ }
41824
+ if (bar < limits.minBar) {
41825
+ bar = limits.minBar;
41826
+ clampedBy = "min";
41827
+ } else if (bar > limits.maxBar) {
41828
+ bar = limits.maxBar;
41829
+ clampedBy = "max";
41830
+ }
41831
+ return {
41832
+ ok: true,
41833
+ bar,
41834
+ rawBar,
41835
+ clampedBy,
41836
+ measurement
41837
+ };
41838
+ }
41839
+ /** One line an operator can read: what was measured, from how much, and what
41840
+ * rule produced the stored number. Kept short — it rides the settings blob. */
41841
+ function describeStillnessCalibration(outcome) {
41842
+ const m = outcome.measurement;
41843
+ const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
41844
+ const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
41845
+ if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
41846
+ return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
41847
+ }
41694
41848
  //#endregion
41695
41849
  //#region src/pipeline-analytics/pipeline/plate-clustering.ts
41696
41850
  /**
@@ -47641,6 +47795,182 @@ async function retentionScopeDeviceIds(deps, onError) {
47641
47795
  return [...ids];
47642
47796
  }
47643
47797
  //#endregion
47798
+ //#region src/pipeline-analytics/stillness-calibration-actions.ts
47799
+ /**
47800
+ * stillness-calibration-actions — "measure this camera's own stillness scale".
47801
+ *
47802
+ * ## Why an `addons.custom` action and not a cap method
47803
+ *
47804
+ * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
47805
+ * lives in `@camstack/types`, which travels inside the `@camstack/server`
47806
+ * closure, so a new one is not callable until a server train ships. Here the
47807
+ * schema lives in this addon's own dist and the hub forwards an opaque
47808
+ * envelope, so `camstack deploy` is the whole delivery.
47809
+ *
47810
+ * ## Why `apply` is explicit and not a fallback
47811
+ *
47812
+ * A calibration run READS recorded tracks and does arithmetic. Applying it
47813
+ * WRITES the camera's settings and changes what the tracker calls settled from
47814
+ * the next frame on. Those are different enough in consequence that a button
47815
+ * labelled "calibrate" must not choose between them on the operator's behalf —
47816
+ * the run reports, the operator applies. A REFUSED run never writes the bar
47817
+ * whatever `apply` says; it writes only the note, so the refusal and its reason
47818
+ * are visible in the form instead of being a toast that scrolls away.
47819
+ */
47820
+ /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
47821
+ * self-describing and a new reason is a deliberate edit in both places. */
47822
+ var StillnessRefusalSchema = _enum([
47823
+ "no-tracks",
47824
+ "too-few-tracks",
47825
+ "too-few-samples",
47826
+ "no-motion-evidence"
47827
+ ]);
47828
+ var StillnessMeasurementSchema = object({
47829
+ /** Tracks that contributed at least one speed sample. */
47830
+ trackCount: number().int(),
47831
+ /** Tracks the pass actually read, after the cost bound. */
47832
+ tracksRead: number().int(),
47833
+ sampleCount: number().int(),
47834
+ /** Speed quantiles, subject-box-diagonals per second. `null` when there was
47835
+ * nothing to measure — never 0, which would read as a measured stillness
47836
+ * (D393). */
47837
+ p25: number().nullable(),
47838
+ p50: number().nullable(),
47839
+ p90: number().nullable(),
47840
+ /** Median gap between stored positions (s) — the camera's real cadence. */
47841
+ medianIntervalSec: number().nullable(),
47842
+ /** Median subject box diagonal (px) the speeds were normalised by. */
47843
+ medianSubjectDiagonalPx: number().nullable()
47844
+ });
47845
+ var stillnessCalibrationActions = defineCustomActions({
47846
+ /**
47847
+ * `admin` because it WRITES a per-camera detection threshold when `apply` is
47848
+ * set, and because the report describes how the camera's subjects move,
47849
+ * which is not a viewer-level fact.
47850
+ */
47851
+ "stillness.calibrate": customAction(object({
47852
+ deviceId: number().int(),
47853
+ /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
47854
+ * row, so the window is a suggestion and `maxTracks` is the real bound. */
47855
+ sinceHours: number().min(.25).max(720).default(72),
47856
+ /** Hard cost bound on tracks read, newest first. */
47857
+ maxTracks: number().int().min(1).max(1e3).default(200),
47858
+ /**
47859
+ * Write the chosen bar onto the camera. Never implicit, and never done at
47860
+ * all for a refused run.
47861
+ */
47862
+ apply: boolean().default(false)
47863
+ }), object({
47864
+ deviceId: number().int(),
47865
+ /** The window actually read (epoch ms). */
47866
+ since: number().int(),
47867
+ until: number().int(),
47868
+ accepted: boolean(),
47869
+ /** Why it refused. Absent on an accepted run. */
47870
+ reason: StillnessRefusalSchema.optional(),
47871
+ /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
47872
+ * never 0, which the settings layer reads as "uncalibrated". */
47873
+ bar: number().optional(),
47874
+ /** The raw quantile before the rails, so the clamp is arguable. */
47875
+ rawBar: number().optional(),
47876
+ clampedBy: _enum([
47877
+ "none",
47878
+ "floor",
47879
+ "ceiling",
47880
+ "min",
47881
+ "max"
47882
+ ]).optional(),
47883
+ measurement: StillnessMeasurementSchema,
47884
+ /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
47885
+ * before any write, so the operator sees both numbers. */
47886
+ previousBar: number().nullable(),
47887
+ /** Whether the camera's settings were actually written. */
47888
+ applied: boolean(),
47889
+ /** The one-line note stored on the camera (accepted or refused). */
47890
+ note: string()
47891
+ }), {
47892
+ kind: "mutation",
47893
+ auth: "admin"
47894
+ }) });
47895
+ //#endregion
47896
+ //#region src/pipeline-analytics/stillness-settings.ts
47897
+ /**
47898
+ * Per-device STILLNESS SCALE — the one number that says how fast a subject on
47899
+ * THIS camera has to move before it is travelling.
47900
+ *
47901
+ * A threshold belongs to whoever GENERATES the event, per device, never baked
47902
+ * into a classifier default. The judgement this feeds is
47903
+ * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
47904
+ * `pipeline/tracker/stillness-scale.ts`, which also carries the live
47905
+ * measurement showing why one number cannot serve a 30 m driveway and a 2 m
47906
+ * doorway.
47907
+ *
47908
+ * Cascade and shape mirror `stationary-settings` / `media-settings`: a
47909
+ * per-device override on top of the default, resolved per FIELD, and a parse
47910
+ * that never throws on a bad blob.
47911
+ *
47912
+ * **Absent behaves exactly as today.** The device store is a flat numeric blob
47913
+ * with no room for "unset", so the stored form of absent is `0` — and `0` is
47914
+ * not a bar anyone could want (it would mean nothing on this camera is ever
47915
+ * still) and not a value the calibration can produce (`minBar`). The decision
47916
+ * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
47917
+ * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
47918
+ * absolute `velocityThreshold` path.
47919
+ */
47920
+ var StillnessSettingsSchema = object({
47921
+ /**
47922
+ * The camera's "not travelling" bar: centroid speed as a fraction of the
47923
+ * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
47924
+ *
47925
+ * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
47926
+ * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
47927
+ * transits).
47928
+ */
47929
+ stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
47930
+ /**
47931
+ * When the number above was last CHOSEN — by a calibration run or by the
47932
+ * operator. `0` = never. Purely a readout; nothing gates on it.
47933
+ */
47934
+ stillnessCalibratedAt: number().int().min(0).default(0),
47935
+ /**
47936
+ * What the last calibration run measured and decided, in one line
47937
+ * (`describeStillnessCalibration`) — including a REFUSAL and its named
47938
+ * reason, because "why is this camera still on the default" has to be
47939
+ * answerable without re-running anything.
47940
+ */
47941
+ stillnessCalibrationNote: string().max(400).default("")
47942
+ });
47943
+ var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
47944
+ /**
47945
+ * Resolve a per-device store blob into typed stillness settings. Unknown or
47946
+ * invalid fields fall back to the default for that field — including a bar
47947
+ * outside the calibration's own rails, which is refused rather than clamped:
47948
+ * a number the calibration could never have produced is not evidence about
47949
+ * this camera, and silently bending it into range would present it as if it
47950
+ * were.
47951
+ */
47952
+ function resolveStillnessSettings(raw) {
47953
+ const pick = (key) => {
47954
+ const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
47955
+ return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
47956
+ };
47957
+ return {
47958
+ stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
47959
+ stillnessCalibratedAt: pick("stillnessCalibratedAt"),
47960
+ stillnessCalibrationNote: pick("stillnessCalibrationNote")
47961
+ };
47962
+ }
47963
+ /**
47964
+ * The bar the decision layer gets: a positive, in-rails number, or
47965
+ * `undefined` for "this camera is not calibrated — judge it exactly as
47966
+ * before". The ONE place the stored-zero convention is interpreted.
47967
+ */
47968
+ function resolveStillSpeedFracPerSec(settings) {
47969
+ const bar = settings.stillnessSpeedFracPerSec;
47970
+ if (!(bar >= .001) || bar > 1) return void 0;
47971
+ return bar;
47972
+ }
47973
+ //#endregion
47644
47974
  //#region src/pipeline-analytics/viewer-settings-actions.ts
47645
47975
  /**
47646
47976
  * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
@@ -47860,336 +48190,6 @@ function makeViewerSettingsActionHandlers(store) {
47860
48190
  };
47861
48191
  }
47862
48192
  //#endregion
47863
- //#region src/pipeline-analytics/stillness-calibration-actions.ts
47864
- /**
47865
- * stillness-calibration-actions — "measure this camera's own stillness scale".
47866
- *
47867
- * ## Why an `addons.custom` action and not a cap method
47868
- *
47869
- * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
47870
- * lives in `@camstack/types`, which travels inside the `@camstack/server`
47871
- * closure, so a new one is not callable until a server train ships. Here the
47872
- * schema lives in this addon's own dist and the hub forwards an opaque
47873
- * envelope, so `camstack deploy` is the whole delivery.
47874
- *
47875
- * ## Why `apply` is explicit and not a fallback
47876
- *
47877
- * A calibration run READS recorded tracks and does arithmetic. Applying it
47878
- * WRITES the camera's settings and changes what the tracker calls settled from
47879
- * the next frame on. Those are different enough in consequence that a button
47880
- * labelled "calibrate" must not choose between them on the operator's behalf —
47881
- * the run reports, the operator applies. A REFUSED run never writes the bar
47882
- * whatever `apply` says; it writes only the note, so the refusal and its reason
47883
- * are visible in the form instead of being a toast that scrolls away.
47884
- */
47885
- /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
47886
- * self-describing and a new reason is a deliberate edit in both places. */
47887
- var StillnessRefusalSchema = _enum([
47888
- "no-tracks",
47889
- "too-few-tracks",
47890
- "too-few-samples",
47891
- "no-motion-evidence"
47892
- ]);
47893
- var StillnessMeasurementSchema = object({
47894
- /** Tracks that contributed at least one speed sample. */
47895
- trackCount: number().int(),
47896
- /** Tracks the pass actually read, after the cost bound. */
47897
- tracksRead: number().int(),
47898
- sampleCount: number().int(),
47899
- /** Speed quantiles, subject-box-diagonals per second. `null` when there was
47900
- * nothing to measure — never 0, which would read as a measured stillness
47901
- * (D393). */
47902
- p25: number().nullable(),
47903
- p50: number().nullable(),
47904
- p90: number().nullable(),
47905
- /** Median gap between stored positions (s) — the camera's real cadence. */
47906
- medianIntervalSec: number().nullable(),
47907
- /** Median subject box diagonal (px) the speeds were normalised by. */
47908
- medianSubjectDiagonalPx: number().nullable()
47909
- });
47910
- var stillnessCalibrationActions = defineCustomActions({
47911
- /**
47912
- * `admin` because it WRITES a per-camera detection threshold when `apply` is
47913
- * set, and because the report describes how the camera's subjects move,
47914
- * which is not a viewer-level fact.
47915
- */
47916
- "stillness.calibrate": customAction(object({
47917
- deviceId: number().int(),
47918
- /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
47919
- * row, so the window is a suggestion and `maxTracks` is the real bound. */
47920
- sinceHours: number().min(.25).max(720).default(72),
47921
- /** Hard cost bound on tracks read, newest first. */
47922
- maxTracks: number().int().min(1).max(1e3).default(200),
47923
- /**
47924
- * Write the chosen bar onto the camera. Never implicit, and never done at
47925
- * all for a refused run.
47926
- */
47927
- apply: boolean().default(false)
47928
- }), object({
47929
- deviceId: number().int(),
47930
- /** The window actually read (epoch ms). */
47931
- since: number().int(),
47932
- until: number().int(),
47933
- accepted: boolean(),
47934
- /** Why it refused. Absent on an accepted run. */
47935
- reason: StillnessRefusalSchema.optional(),
47936
- /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
47937
- * never 0, which the settings layer reads as "uncalibrated". */
47938
- bar: number().optional(),
47939
- /** The raw quantile before the rails, so the clamp is arguable. */
47940
- rawBar: number().optional(),
47941
- clampedBy: _enum([
47942
- "none",
47943
- "floor",
47944
- "ceiling",
47945
- "min",
47946
- "max"
47947
- ]).optional(),
47948
- measurement: StillnessMeasurementSchema,
47949
- /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
47950
- * before any write, so the operator sees both numbers. */
47951
- previousBar: number().nullable(),
47952
- /** Whether the camera's settings were actually written. */
47953
- applied: boolean(),
47954
- /** The one-line note stored on the camera (accepted or refused). */
47955
- note: string()
47956
- }), {
47957
- kind: "mutation",
47958
- auth: "admin"
47959
- }) });
47960
- /**
47961
- * The shipped limits.
47962
- *
47963
- * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
47964
- * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
47965
- * camera that cannot clear these has genuinely not been watched enough.
47966
- * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
47967
- * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
47968
- */
47969
- var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
47970
- minTracks: 12,
47971
- minSamples: 200,
47972
- maxTracks: 200,
47973
- minIntervalSec: .02,
47974
- maxIntervalSec: 3,
47975
- quantile: .25,
47976
- floorFactor: .02,
47977
- ceilFactor: .25,
47978
- minBar: .001,
47979
- maxBar: 1
47980
- };
47981
- /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
47982
- * would read as a measured zero (D393). */
47983
- function quantileOf(values, p) {
47984
- if (values.length === 0) return NaN;
47985
- const sorted = [...values].sort((a, b) => a - b);
47986
- const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
47987
- const lo = Math.floor(idx);
47988
- const hi = Math.ceil(idx);
47989
- const at = sorted[lo];
47990
- const next = sorted[hi];
47991
- if (at === void 0 || next === void 0) return NaN;
47992
- return at + (next - at) * (idx - lo);
47993
- }
47994
- /**
47995
- * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
47996
- *
47997
- * A pair of consecutive positions contributes a sample only when the interval
47998
- * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
47999
- * has a positive diagonal. A gap outside that band is a session boundary or a
48000
- * duplicate read, not a measurement of how fast anything travelled; a box with
48001
- * no size is unknown geometry, and unknown is never a sample.
48002
- */
48003
- function collectStillnessSamples(tracks, limits) {
48004
- const speeds = [];
48005
- const intervals = [];
48006
- const diagonals = [];
48007
- let trackCount = 0;
48008
- const read = tracks.slice(0, limits.maxTracks);
48009
- for (const track of read) {
48010
- const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
48011
- let contributed = false;
48012
- for (let i = 1; i < positions.length; i++) {
48013
- const prev = positions[i - 1];
48014
- const cur = positions[i];
48015
- if (prev === void 0 || cur === void 0) continue;
48016
- const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
48017
- if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
48018
- const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
48019
- if (!(diagonal > 0)) continue;
48020
- const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
48021
- speeds.push(travelled / diagonal / dtSec);
48022
- intervals.push(dtSec);
48023
- diagonals.push(diagonal);
48024
- contributed = true;
48025
- }
48026
- if (contributed) trackCount++;
48027
- }
48028
- return {
48029
- speeds,
48030
- intervals,
48031
- diagonals,
48032
- trackCount,
48033
- tracksRead: read.length
48034
- };
48035
- }
48036
- function measure$1(samples) {
48037
- return {
48038
- trackCount: samples.trackCount,
48039
- sampleCount: samples.speeds.length,
48040
- p25: quantileOf(samples.speeds, .25),
48041
- p50: quantileOf(samples.speeds, .5),
48042
- p90: quantileOf(samples.speeds, .9),
48043
- medianIntervalSec: quantileOf(samples.intervals, .5),
48044
- medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
48045
- };
48046
- }
48047
- /**
48048
- * Choose this camera's stillness speed bar, or refuse with a named reason.
48049
- *
48050
- * The order of the gates is the order of the questions: is there anything at
48051
- * all, is it enough tracks, is it enough samples, and did anything on this
48052
- * camera ever actually move. The last one is not pedantry — the whole method
48053
- * scales the bar against the camera's own travel, so with no travel there is
48054
- * nothing to scale against and the default is the honest answer.
48055
- */
48056
- function calibrateStillnessSpeed(samples, limits) {
48057
- const measurement = measure$1(samples);
48058
- if (samples.tracksRead === 0) return {
48059
- ok: false,
48060
- reason: "no-tracks",
48061
- measurement
48062
- };
48063
- if (samples.trackCount < limits.minTracks) return {
48064
- ok: false,
48065
- reason: "too-few-tracks",
48066
- measurement
48067
- };
48068
- if (samples.speeds.length < limits.minSamples) return {
48069
- ok: false,
48070
- reason: "too-few-samples",
48071
- measurement
48072
- };
48073
- if (!(measurement.p90 > 0)) return {
48074
- ok: false,
48075
- reason: "no-motion-evidence",
48076
- measurement
48077
- };
48078
- const rawBar = quantileOf(samples.speeds, limits.quantile);
48079
- const floor = measurement.p90 * limits.floorFactor;
48080
- const ceiling = measurement.p90 * limits.ceilFactor;
48081
- let bar = rawBar;
48082
- let clampedBy = "none";
48083
- if (bar < floor) {
48084
- bar = floor;
48085
- clampedBy = "floor";
48086
- } else if (bar > ceiling) {
48087
- bar = ceiling;
48088
- clampedBy = "ceiling";
48089
- }
48090
- if (bar < limits.minBar) {
48091
- bar = limits.minBar;
48092
- clampedBy = "min";
48093
- } else if (bar > limits.maxBar) {
48094
- bar = limits.maxBar;
48095
- clampedBy = "max";
48096
- }
48097
- return {
48098
- ok: true,
48099
- bar,
48100
- rawBar,
48101
- clampedBy,
48102
- measurement
48103
- };
48104
- }
48105
- /** One line an operator can read: what was measured, from how much, and what
48106
- * rule produced the stored number. Kept short — it rides the settings blob. */
48107
- function describeStillnessCalibration(outcome) {
48108
- const m = outcome.measurement;
48109
- const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
48110
- const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
48111
- if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
48112
- return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
48113
- }
48114
- //#endregion
48115
- //#region src/pipeline-analytics/stillness-settings.ts
48116
- /**
48117
- * Per-device STILLNESS SCALE — the one number that says how fast a subject on
48118
- * THIS camera has to move before it is travelling.
48119
- *
48120
- * A threshold belongs to whoever GENERATES the event, per device, never baked
48121
- * into a classifier default. The judgement this feeds is
48122
- * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
48123
- * `pipeline/tracker/stillness-scale.ts`, which also carries the live
48124
- * measurement showing why one number cannot serve a 30 m driveway and a 2 m
48125
- * doorway.
48126
- *
48127
- * Cascade and shape mirror `stationary-settings` / `media-settings`: a
48128
- * per-device override on top of the default, resolved per FIELD, and a parse
48129
- * that never throws on a bad blob.
48130
- *
48131
- * **Absent behaves exactly as today.** The device store is a flat numeric blob
48132
- * with no room for "unset", so the stored form of absent is `0` — and `0` is
48133
- * not a bar anyone could want (it would mean nothing on this camera is ever
48134
- * still) and not a value the calibration can produce (`minBar`). The decision
48135
- * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
48136
- * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
48137
- * absolute `velocityThreshold` path.
48138
- */
48139
- var StillnessSettingsSchema = object({
48140
- /**
48141
- * The camera's "not travelling" bar: centroid speed as a fraction of the
48142
- * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
48143
- *
48144
- * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
48145
- * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
48146
- * transits).
48147
- */
48148
- stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
48149
- /**
48150
- * When the number above was last CHOSEN — by a calibration run or by the
48151
- * operator. `0` = never. Purely a readout; nothing gates on it.
48152
- */
48153
- stillnessCalibratedAt: number().int().min(0).default(0),
48154
- /**
48155
- * What the last calibration run measured and decided, in one line
48156
- * (`describeStillnessCalibration`) — including a REFUSAL and its named
48157
- * reason, because "why is this camera still on the default" has to be
48158
- * answerable without re-running anything.
48159
- */
48160
- stillnessCalibrationNote: string().max(400).default("")
48161
- });
48162
- var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
48163
- /**
48164
- * Resolve a per-device store blob into typed stillness settings. Unknown or
48165
- * invalid fields fall back to the default for that field — including a bar
48166
- * outside the calibration's own rails, which is refused rather than clamped:
48167
- * a number the calibration could never have produced is not evidence about
48168
- * this camera, and silently bending it into range would present it as if it
48169
- * were.
48170
- */
48171
- function resolveStillnessSettings(raw) {
48172
- const pick = (key) => {
48173
- const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
48174
- return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
48175
- };
48176
- return {
48177
- stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48178
- stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48179
- stillnessCalibrationNote: pick("stillnessCalibrationNote")
48180
- };
48181
- }
48182
- /**
48183
- * The bar the decision layer gets: a positive, in-rails number, or
48184
- * `undefined` for "this camera is not calibrated — judge it exactly as
48185
- * before". The ONE place the stored-zero convention is interpreted.
48186
- */
48187
- function resolveStillSpeedFracPerSec(settings) {
48188
- const bar = settings.stillnessSpeedFracPerSec;
48189
- if (!(bar >= .001) || bar > 1) return void 0;
48190
- return bar;
48191
- }
48192
- //#endregion
48193
48193
  //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
48194
48194
  /**
48195
48195
  * @durable class=config owner=pipeline-analytics
@@ -48367,6 +48367,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
48367
48367
  return retired;
48368
48368
  }
48369
48369
  /**
48370
+ * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
48371
+ *
48372
+ * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
48373
+ * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
48374
+ * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
48375
+ * one encode per event over a ~12 s window, not a permanent transcode child
48376
+ * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
48377
+ * it was measured on a real 615 720p cut before being chosen: **0.23 s of
48378
+ * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
48379
+ * The re-encode is smaller than what it replaces.
48380
+ *
48381
+ * So both attachments now agree on the timeline as well as on the window: one
48382
+ * clip, one rate, two containers.
48383
+ */
48384
+ var DEFAULT_SPEED = 4;
48385
+ /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
48386
+ * at or below 1080p is an identity scale and the broker copies it. */
48387
+ var RING_MP4_MAX_WIDTH = 1920;
48388
+ /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
48389
+ var RING_MP4_FPS = 15;
48390
+ var NO_MEDIA = {
48391
+ mp4: null,
48392
+ gif: null,
48393
+ source: "none",
48394
+ startOffsetMs: null,
48395
+ endOffsetMs: null,
48396
+ profile: null,
48397
+ video: null
48398
+ };
48399
+ var EventMediaService = class {
48400
+ deps;
48401
+ constructor(deps) {
48402
+ this.deps = deps;
48403
+ }
48404
+ /**
48405
+ * Cut this event. Never throws — a notification that lost its media is still
48406
+ * a notification, and every branch that drops it logs why.
48407
+ */
48408
+ async cut(request) {
48409
+ if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
48410
+ const produced = await this.produce(request);
48411
+ if (produced !== null) return produced;
48412
+ return this.fallbackToRing(request);
48413
+ }
48414
+ async produce(request) {
48415
+ const deviceId = request.deviceId;
48416
+ const log = this.deps.logger;
48417
+ const kinds = [];
48418
+ if (request.wantMp4) kinds.push("mp4");
48419
+ if (request.wantGif) kinds.push("gif");
48420
+ let production;
48421
+ try {
48422
+ production = await this.deps.produce({
48423
+ deviceId,
48424
+ aroundMs: request.aroundMs,
48425
+ preSeconds: request.preRollSec,
48426
+ postSeconds: request.postRollSec,
48427
+ kinds,
48428
+ gifMaxWidth: 640,
48429
+ gifFps: 12,
48430
+ gifSpeed: 4,
48431
+ speed: request.speed ?? DEFAULT_SPEED,
48432
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
48433
+ });
48434
+ } catch (err) {
48435
+ log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
48436
+ tags: { deviceId },
48437
+ meta: { error: err instanceof Error ? err.message : String(err) }
48438
+ });
48439
+ return null;
48440
+ }
48441
+ const mp4 = await this.redeem(deviceId, production.media, "mp4");
48442
+ const gif = await this.redeem(deviceId, production.media, "gif");
48443
+ if (mp4 === null && gif === null) {
48444
+ log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
48445
+ tags: { deviceId },
48446
+ meta: {
48447
+ kinds: kinds.join(","),
48448
+ profile: production.profile
48449
+ }
48450
+ });
48451
+ return null;
48452
+ }
48453
+ const startOffsetMs = production.coverage.fromTs - request.aroundMs;
48454
+ const endOffsetMs = production.coverage.toTs - request.aroundMs;
48455
+ log.info("nc media: one production, every attachment", {
48456
+ tags: { deviceId },
48457
+ meta: {
48458
+ profile: production.profile,
48459
+ video: production.video,
48460
+ mp4Bytes: mp4?.byteLength ?? null,
48461
+ gifBytes: gif?.byteLength ?? null,
48462
+ startOffsetMs,
48463
+ endOffsetMs,
48464
+ sharedWindow: true
48465
+ }
48466
+ });
48467
+ return {
48468
+ mp4,
48469
+ gif,
48470
+ source: "produced",
48471
+ startOffsetMs,
48472
+ endOffsetMs,
48473
+ profile: production.profile,
48474
+ video: production.video
48475
+ };
48476
+ }
48477
+ /**
48478
+ * Redeem one artifact handle at the node that produced it.
48479
+ *
48480
+ * `null` is not an error here — a production simply may not carry the kind
48481
+ * (a gif whose derive failed says so on the broker's own log line). A handle
48482
+ * that FAILS to redeem is different and is logged, because it means the bytes
48483
+ * existed and did not arrive.
48484
+ */
48485
+ async redeem(deviceId, media, kind) {
48486
+ const artifact = media.find((m) => m.kind === kind);
48487
+ if (artifact === void 0) return null;
48488
+ try {
48489
+ const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
48490
+ if (res === null) {
48491
+ this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
48492
+ tags: { deviceId },
48493
+ meta: {
48494
+ kind,
48495
+ handle: artifact.handle,
48496
+ nodeId: artifact.nodeId
48497
+ }
48498
+ });
48499
+ return null;
48500
+ }
48501
+ const buf = Buffer.from(res.base64, "base64");
48502
+ if (buf.byteLength === 0) return null;
48503
+ const bytes = new Uint8Array(buf.byteLength);
48504
+ bytes.set(buf);
48505
+ return bytes;
48506
+ } catch (err) {
48507
+ this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
48508
+ tags: { deviceId },
48509
+ meta: {
48510
+ kind,
48511
+ handle: artifact.handle,
48512
+ nodeId: artifact.nodeId,
48513
+ error: err instanceof Error ? err.message : String(err)
48514
+ }
48515
+ });
48516
+ return null;
48517
+ }
48518
+ }
48519
+ /**
48520
+ * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
48521
+ *
48522
+ * Weaker than a production and deliberately so — the coherence here rests on
48523
+ * the two calls being given IDENTICAL parameters rather than on there being
48524
+ * one render. That is defensible because the ring only ever grows forward and
48525
+ * `windowAround` selects by wall clock around the same instant, so two calls
48526
+ * seconds apart choose the same packets; the one thing that could differ is
48527
+ * the rendition, so the profile is pinned to whatever the FIRST call actually
48528
+ * used and handed to the second.
48529
+ *
48530
+ * It exists for one reason: a hub whose `@camstack/server` predates the
48531
+ * production method must not stop attaching footage, and three of the four
48532
+ * live rules on this install ask for a gif and nothing else.
48533
+ */
48534
+ async fallbackToRing(request) {
48535
+ const deviceId = request.deviceId;
48536
+ const speed = request.speed ?? DEFAULT_SPEED;
48537
+ const window = {
48538
+ deviceId,
48539
+ aroundMs: request.aroundMs,
48540
+ preRollSec: request.preRollSec,
48541
+ postRollSec: request.postRollSec,
48542
+ speed,
48543
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
48544
+ };
48545
+ try {
48546
+ const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
48547
+ ...window,
48548
+ format: "mp4",
48549
+ maxWidth: RING_MP4_MAX_WIDTH,
48550
+ fps: RING_MP4_FPS
48551
+ }) : null;
48552
+ const gif = request.wantGif ? await this.deps.renderRingClip({
48553
+ ...window,
48554
+ format: "gif",
48555
+ maxWidth: 640,
48556
+ fps: 12 / 4,
48557
+ speed: 4
48558
+ }) : null;
48559
+ if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
48560
+ this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
48561
+ tags: { deviceId },
48562
+ meta: { aroundMs: request.aroundMs }
48563
+ });
48564
+ return NO_MEDIA;
48565
+ }
48566
+ this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
48567
+ tags: { deviceId },
48568
+ meta: {
48569
+ mp4Bytes: mp4?.byteLength ?? null,
48570
+ gifBytes: gif?.byteLength ?? null,
48571
+ profile: request.profile ?? null,
48572
+ sharedWindow: "by-parameters"
48573
+ }
48574
+ });
48575
+ return {
48576
+ mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
48577
+ gif: gif !== null && gif.byteLength > 0 ? gif : null,
48578
+ source: "ring-fallback",
48579
+ startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
48580
+ endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
48581
+ profile: request.profile ?? null,
48582
+ video: null
48583
+ };
48584
+ } catch (err) {
48585
+ this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
48586
+ tags: { deviceId },
48587
+ meta: { error: err instanceof Error ? err.message : String(err) }
48588
+ });
48589
+ return NO_MEDIA;
48590
+ }
48591
+ }
48592
+ };
48593
+ /**
48370
48594
  * How long the recorder keeps the file we are about to read and delete.
48371
48595
  *
48372
48596
  * `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
@@ -48620,230 +48844,6 @@ function decodeBase64(base64) {
48620
48844
  return null;
48621
48845
  }
48622
48846
  }
48623
- /**
48624
- * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
48625
- *
48626
- * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
48627
- * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
48628
- * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
48629
- * one encode per event over a ~12 s window, not a permanent transcode child
48630
- * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
48631
- * it was measured on a real 615 720p cut before being chosen: **0.23 s of
48632
- * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
48633
- * The re-encode is smaller than what it replaces.
48634
- *
48635
- * So both attachments now agree on the timeline as well as on the window: one
48636
- * clip, one rate, two containers.
48637
- */
48638
- var DEFAULT_SPEED = 4;
48639
- /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
48640
- * at or below 1080p is an identity scale and the broker copies it. */
48641
- var RING_MP4_MAX_WIDTH = 1920;
48642
- /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
48643
- var RING_MP4_FPS = 15;
48644
- var NO_MEDIA = {
48645
- mp4: null,
48646
- gif: null,
48647
- source: "none",
48648
- startOffsetMs: null,
48649
- endOffsetMs: null,
48650
- profile: null,
48651
- video: null
48652
- };
48653
- var EventMediaService = class {
48654
- deps;
48655
- constructor(deps) {
48656
- this.deps = deps;
48657
- }
48658
- /**
48659
- * Cut this event. Never throws — a notification that lost its media is still
48660
- * a notification, and every branch that drops it logs why.
48661
- */
48662
- async cut(request) {
48663
- if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
48664
- const produced = await this.produce(request);
48665
- if (produced !== null) return produced;
48666
- return this.fallbackToRing(request);
48667
- }
48668
- async produce(request) {
48669
- const deviceId = request.deviceId;
48670
- const log = this.deps.logger;
48671
- const kinds = [];
48672
- if (request.wantMp4) kinds.push("mp4");
48673
- if (request.wantGif) kinds.push("gif");
48674
- let production;
48675
- try {
48676
- production = await this.deps.produce({
48677
- deviceId,
48678
- aroundMs: request.aroundMs,
48679
- preSeconds: request.preRollSec,
48680
- postSeconds: request.postRollSec,
48681
- kinds,
48682
- gifMaxWidth: 640,
48683
- gifFps: 12,
48684
- gifSpeed: 4,
48685
- speed: request.speed ?? DEFAULT_SPEED,
48686
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48687
- });
48688
- } catch (err) {
48689
- log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
48690
- tags: { deviceId },
48691
- meta: { error: err instanceof Error ? err.message : String(err) }
48692
- });
48693
- return null;
48694
- }
48695
- const mp4 = await this.redeem(deviceId, production.media, "mp4");
48696
- const gif = await this.redeem(deviceId, production.media, "gif");
48697
- if (mp4 === null && gif === null) {
48698
- log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
48699
- tags: { deviceId },
48700
- meta: {
48701
- kinds: kinds.join(","),
48702
- profile: production.profile
48703
- }
48704
- });
48705
- return null;
48706
- }
48707
- const startOffsetMs = production.coverage.fromTs - request.aroundMs;
48708
- const endOffsetMs = production.coverage.toTs - request.aroundMs;
48709
- log.info("nc media: one production, every attachment", {
48710
- tags: { deviceId },
48711
- meta: {
48712
- profile: production.profile,
48713
- video: production.video,
48714
- mp4Bytes: mp4?.byteLength ?? null,
48715
- gifBytes: gif?.byteLength ?? null,
48716
- startOffsetMs,
48717
- endOffsetMs,
48718
- sharedWindow: true
48719
- }
48720
- });
48721
- return {
48722
- mp4,
48723
- gif,
48724
- source: "produced",
48725
- startOffsetMs,
48726
- endOffsetMs,
48727
- profile: production.profile,
48728
- video: production.video
48729
- };
48730
- }
48731
- /**
48732
- * Redeem one artifact handle at the node that produced it.
48733
- *
48734
- * `null` is not an error here — a production simply may not carry the kind
48735
- * (a gif whose derive failed says so on the broker's own log line). A handle
48736
- * that FAILS to redeem is different and is logged, because it means the bytes
48737
- * existed and did not arrive.
48738
- */
48739
- async redeem(deviceId, media, kind) {
48740
- const artifact = media.find((m) => m.kind === kind);
48741
- if (artifact === void 0) return null;
48742
- try {
48743
- const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
48744
- if (res === null) {
48745
- this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
48746
- tags: { deviceId },
48747
- meta: {
48748
- kind,
48749
- handle: artifact.handle,
48750
- nodeId: artifact.nodeId
48751
- }
48752
- });
48753
- return null;
48754
- }
48755
- const buf = Buffer.from(res.base64, "base64");
48756
- if (buf.byteLength === 0) return null;
48757
- const bytes = new Uint8Array(buf.byteLength);
48758
- bytes.set(buf);
48759
- return bytes;
48760
- } catch (err) {
48761
- this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
48762
- tags: { deviceId },
48763
- meta: {
48764
- kind,
48765
- handle: artifact.handle,
48766
- nodeId: artifact.nodeId,
48767
- error: err instanceof Error ? err.message : String(err)
48768
- }
48769
- });
48770
- return null;
48771
- }
48772
- }
48773
- /**
48774
- * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
48775
- *
48776
- * Weaker than a production and deliberately so — the coherence here rests on
48777
- * the two calls being given IDENTICAL parameters rather than on there being
48778
- * one render. That is defensible because the ring only ever grows forward and
48779
- * `windowAround` selects by wall clock around the same instant, so two calls
48780
- * seconds apart choose the same packets; the one thing that could differ is
48781
- * the rendition, so the profile is pinned to whatever the FIRST call actually
48782
- * used and handed to the second.
48783
- *
48784
- * It exists for one reason: a hub whose `@camstack/server` predates the
48785
- * production method must not stop attaching footage, and three of the four
48786
- * live rules on this install ask for a gif and nothing else.
48787
- */
48788
- async fallbackToRing(request) {
48789
- const deviceId = request.deviceId;
48790
- const speed = request.speed ?? DEFAULT_SPEED;
48791
- const window = {
48792
- deviceId,
48793
- aroundMs: request.aroundMs,
48794
- preRollSec: request.preRollSec,
48795
- postRollSec: request.postRollSec,
48796
- speed,
48797
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48798
- };
48799
- try {
48800
- const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
48801
- ...window,
48802
- format: "mp4",
48803
- maxWidth: RING_MP4_MAX_WIDTH,
48804
- fps: RING_MP4_FPS
48805
- }) : null;
48806
- const gif = request.wantGif ? await this.deps.renderRingClip({
48807
- ...window,
48808
- format: "gif",
48809
- maxWidth: 640,
48810
- fps: 12 / 4,
48811
- speed: 4
48812
- }) : null;
48813
- if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
48814
- this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
48815
- tags: { deviceId },
48816
- meta: { aroundMs: request.aroundMs }
48817
- });
48818
- return NO_MEDIA;
48819
- }
48820
- this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
48821
- tags: { deviceId },
48822
- meta: {
48823
- mp4Bytes: mp4?.byteLength ?? null,
48824
- gifBytes: gif?.byteLength ?? null,
48825
- profile: request.profile ?? null,
48826
- sharedWindow: "by-parameters"
48827
- }
48828
- });
48829
- return {
48830
- mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
48831
- gif: gif !== null && gif.byteLength > 0 ? gif : null,
48832
- source: "ring-fallback",
48833
- startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
48834
- endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
48835
- profile: request.profile ?? null,
48836
- video: null
48837
- };
48838
- } catch (err) {
48839
- this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
48840
- tags: { deviceId },
48841
- meta: { error: err instanceof Error ? err.message : String(err) }
48842
- });
48843
- return NO_MEDIA;
48844
- }
48845
- }
48846
- };
48847
48847
  //#endregion
48848
48848
  //#region src/shared/frame/crop-extractor.ts
48849
48849
  /**
@@ -57114,6 +57114,41 @@ function resolveMediaSettings(raw) {
57114
57114
  snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
57115
57115
  };
57116
57116
  }
57117
+ //#endregion
57118
+ //#region src/pipeline-analytics/our-recording-availability.ts
57119
+ /**
57120
+ * Coverage of OUR archive, asked of the `recording` collection by name.
57121
+ *
57122
+ * `recording` became a device collection on 2026-09-24 (D625): a camera may
57123
+ * have several sources of recorded coverage and every read names one. Every
57124
+ * read in this addon wants the same one — the footage OUR recorder wrote,
57125
+ * because everything downstream of it (a recorded clip cut, a timelapse, the
57126
+ * earliest-footage floor of a retention sweep) is about bytes we hold and can
57127
+ * export. A camera's own SD card is not an answer to any of those questions.
57128
+ *
57129
+ * It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
57130
+ * throws here rather than arriving downstream as an empty range list. Every
57131
+ * caller of this module treats "no ranges" as "recording is off for this
57132
+ * camera" and acts on it — dropping the attachment, skipping the render,
57133
+ * lowering the sweep floor — so folding a failed read into that shape would
57134
+ * make an unreachable recorder look exactly like a camera nobody records
57135
+ * (D393). The callers already handle a throw and log it as a read failure.
57136
+ */
57137
+ /** Our coverage of `[fromMs, toMs)`, or a throw naming the failed read. */
57138
+ async function ourRecordingAvailability(api, input) {
57139
+ const answer = await api.recording.getAvailability.query({
57140
+ deviceId: input.deviceId,
57141
+ provider: RECORDING_SOURCE_CAMSTACK_ADDON,
57142
+ fromMs: input.fromMs,
57143
+ toMs: input.toMs,
57144
+ ...input.profile !== void 0 ? { profile: input.profile } : {}
57145
+ });
57146
+ 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`);
57147
+ return {
57148
+ ranges: answer.ranges,
57149
+ profilesWithFootage: answer.profilesWithFootage
57150
+ };
57151
+ }
57117
57152
  function toDetectionBbox(bbox) {
57118
57153
  return {
57119
57154
  x: bbox.x,
@@ -58405,6 +58440,17 @@ function resolveSearchThumbnailUrl(input) {
58405
58440
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
58406
58441
  }
58407
58442
  //#endregion
58443
+ //#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
58444
+ /**
58445
+ * May a suppression verdict RETRACT this birth?
58446
+ *
58447
+ * `false` means hold: keep the track, log the held verdict, and let the
58448
+ * ordinary lifetime decide. It never means "confirmed".
58449
+ */
58450
+ function suppressionMayRetract(source) {
58451
+ return source !== "onboard";
58452
+ }
58453
+ //#endregion
58408
58454
  //#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
58409
58455
  /**
58410
58456
  * Decide whether a package-class box is plausibly parcel-sized.
@@ -61098,65 +61144,6 @@ var StationaryEvidenceLedger = class {
61098
61144
  for (const row of excess) this.forgetKey(row.key);
61099
61145
  }
61100
61146
  };
61101
- //#endregion
61102
- //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
61103
- function planStationaryTrackDisposal(input) {
61104
- const trackIds = [input.liveTrackId];
61105
- if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
61106
- const keepMediaIds = /* @__PURE__ */ new Set();
61107
- if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
61108
- return {
61109
- trackIds,
61110
- keepMediaIds
61111
- };
61112
- }
61113
- /**
61114
- * Run the plan. The live track is dropped from RAM FIRST so it can never be
61115
- * persisted by a sweep racing this call; the cascade then removes what every
61116
- * track owned, sparing the entry's key frame, and deletes the row (a no-op for
61117
- * the never-persisted live track).
61118
- *
61119
- * Never silent: the line names the entity, what went and what was kept — a
61120
- * deleted track is dropped work, and the operator asked for it by name.
61121
- */
61122
- async function disposeStationaryTracks(deps, plan) {
61123
- const [liveTrackId] = plan.trackIds;
61124
- if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
61125
- const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
61126
- const result = await runTrackCascadeBatch({
61127
- registry: [...deps.leaves, sparingMedia],
61128
- trackStore: deps.trackStore,
61129
- deviceId: deps.deviceId,
61130
- onTrackCleanup: deps.onTrackCleanup,
61131
- onFailure: (trackId, err) => {
61132
- deps.logger.warn("stationary promotion: a source track could not be disposed of", {
61133
- tags: { deviceId: deps.deviceId },
61134
- meta: {
61135
- deviceId: deps.deviceId,
61136
- entryId: deps.entryId,
61137
- trackId,
61138
- error: String(err)
61139
- }
61140
- });
61141
- }
61142
- }, plan.trackIds);
61143
- deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
61144
- tags: { deviceId: deps.deviceId },
61145
- meta: {
61146
- deviceId: deps.deviceId,
61147
- entryId: deps.entryId,
61148
- trackIds: plan.trackIds,
61149
- keptMediaIds: [...plan.keepMediaIds],
61150
- deleted: result.deleted,
61151
- failed: result.failed,
61152
- mediaRows: result.tally.media
61153
- }
61154
- });
61155
- return {
61156
- deleted: result.deleted,
61157
- failed: result.failed
61158
- };
61159
- }
61160
61147
  /**
61161
61148
  * How long the same weak box must keep being seen before it may re-anchor.
61162
61149
  *
@@ -62098,6 +62085,97 @@ function rowToEntry(id, data) {
62098
62085
  };
62099
62086
  }
62100
62087
  //#endregion
62088
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
62089
+ function planStationaryTrackDisposal(input) {
62090
+ const trackIds = [input.liveTrackId];
62091
+ if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
62092
+ const keepMediaIds = /* @__PURE__ */ new Set();
62093
+ if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
62094
+ return {
62095
+ trackIds,
62096
+ keepMediaIds
62097
+ };
62098
+ }
62099
+ /**
62100
+ * Run the plan. The live track is dropped from RAM FIRST so it can never be
62101
+ * persisted by a sweep racing this call; the cascade then removes what every
62102
+ * track owned, sparing the entry's key frame, and deletes the row (a no-op for
62103
+ * the never-persisted live track).
62104
+ *
62105
+ * Never silent: the line names the entity, what went and what was kept — a
62106
+ * deleted track is dropped work, and the operator asked for it by name.
62107
+ */
62108
+ async function disposeStationaryTracks(deps, plan) {
62109
+ const [liveTrackId] = plan.trackIds;
62110
+ if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
62111
+ const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
62112
+ const result = await runTrackCascadeBatch({
62113
+ registry: [...deps.leaves, sparingMedia],
62114
+ trackStore: deps.trackStore,
62115
+ deviceId: deps.deviceId,
62116
+ onTrackCleanup: deps.onTrackCleanup,
62117
+ onFailure: (trackId, err) => {
62118
+ deps.logger.warn("stationary promotion: a source track could not be disposed of", {
62119
+ tags: { deviceId: deps.deviceId },
62120
+ meta: {
62121
+ deviceId: deps.deviceId,
62122
+ entryId: deps.entryId,
62123
+ trackId,
62124
+ error: String(err)
62125
+ }
62126
+ });
62127
+ }
62128
+ }, plan.trackIds);
62129
+ deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
62130
+ tags: { deviceId: deps.deviceId },
62131
+ meta: {
62132
+ deviceId: deps.deviceId,
62133
+ entryId: deps.entryId,
62134
+ trackIds: plan.trackIds,
62135
+ keptMediaIds: [...plan.keepMediaIds],
62136
+ deleted: result.deleted,
62137
+ failed: result.failed,
62138
+ mediaRows: result.tally.media
62139
+ }
62140
+ });
62141
+ return {
62142
+ deleted: result.deleted,
62143
+ failed: result.failed
62144
+ };
62145
+ }
62146
+ //#endregion
62147
+ //#region src/pipeline-analytics/pipeline/stationary-planes.ts
62148
+ /**
62149
+ * WHICH detection planes carry the stationary machinery.
62150
+ *
62151
+ * A plane qualifies when it produces OBJECT BOXES on decoded frames — because
62152
+ * that is what the registry needs to decide an object is parked: a box, a
62153
+ * class, and an observed clock that only advances while frames flow.
62154
+ *
62155
+ * Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
62156
+ * camera's own firmware, and the boxes it produces are paired with a decoded
62157
+ * frame in that frame's pixel space — the same plane, entered by a different
62158
+ * door. `sensor` and `audio` do not: they carry no geometry to park.
62159
+ *
62160
+ * It used to be `source === 'pipeline'`, in five places, each explaining that
62161
+ * the gate "runs on that plane". The reason given was WHERE the flood it was
62162
+ * written for had been measured (617, the ML plane), never an argument that the
62163
+ * firmware's boxes were unfit. The cost of the accident, on device 640: a still
62164
+ * object is never promoted, so it holds `hasLiveTrack` true for as long as it
62165
+ * sits there, and every detection session runs to its 120 s cap instead of
62166
+ * closing on cooldown — `closed at the max-hold cap with a subject still
62167
+ * present`. The camera's lazy stream is then torn down and rebuilt on the next
62168
+ * detection, on a battery camera.
62169
+ *
62170
+ * One predicate and not five comparisons, because five copies of a rule drift:
62171
+ * this repo has already paid for that with two authorities on one function
62172
+ * (D62).
62173
+ */
62174
+ /** Does this detection plane run the stationary registry, gate and promotion? */
62175
+ function planeRunsStationary(source) {
62176
+ return source === "pipeline" || source === "onboard";
62177
+ }
62178
+ //#endregion
62101
62179
  //#region src/pipeline-analytics/pipeline/suppressed-births.ts
62102
62180
  /**
62103
62181
  * Memory of births the confirmation gate rejected, per device.
@@ -63013,49 +63091,6 @@ function hasKeyFrame(source) {
63013
63091
  return source.keyFrame !== null && source.bbox !== null;
63014
63092
  }
63015
63093
  //#endregion
63016
- //#region src/pipeline-analytics/pipeline/stationary-planes.ts
63017
- /**
63018
- * WHICH detection planes carry the stationary machinery.
63019
- *
63020
- * A plane qualifies when it produces OBJECT BOXES on decoded frames — because
63021
- * that is what the registry needs to decide an object is parked: a box, a
63022
- * class, and an observed clock that only advances while frames flow.
63023
- *
63024
- * Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
63025
- * camera's own firmware, and the boxes it produces are paired with a decoded
63026
- * frame in that frame's pixel space — the same plane, entered by a different
63027
- * door. `sensor` and `audio` do not: they carry no geometry to park.
63028
- *
63029
- * It used to be `source === 'pipeline'`, in five places, each explaining that
63030
- * the gate "runs on that plane". The reason given was WHERE the flood it was
63031
- * written for had been measured (617, the ML plane), never an argument that the
63032
- * firmware's boxes were unfit. The cost of the accident, on device 640: a still
63033
- * object is never promoted, so it holds `hasLiveTrack` true for as long as it
63034
- * sits there, and every detection session runs to its 120 s cap instead of
63035
- * closing on cooldown — `closed at the max-hold cap with a subject still
63036
- * present`. The camera's lazy stream is then torn down and rebuilt on the next
63037
- * detection, on a battery camera.
63038
- *
63039
- * One predicate and not five comparisons, because five copies of a rule drift:
63040
- * this repo has already paid for that with two authorities on one function
63041
- * (D62).
63042
- */
63043
- /** Does this detection plane run the stationary registry, gate and promotion? */
63044
- function planeRunsStationary(source) {
63045
- return source === "pipeline" || source === "onboard";
63046
- }
63047
- //#endregion
63048
- //#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
63049
- /**
63050
- * May a suppression verdict RETRACT this birth?
63051
- *
63052
- * `false` means hold: keep the track, log the held verdict, and let the
63053
- * ordinary lifetime decide. It never means "confirmed".
63054
- */
63055
- function suppressionMayRetract(source) {
63056
- return source !== "onboard";
63057
- }
63058
- //#endregion
63059
63094
  //#region src/pipeline-analytics/rebuild-source-loader.ts
63060
63095
  /**
63061
63096
  * Assemble one track's rebuild inputs, reading at most ONE blob.
@@ -78266,7 +78301,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78266
78301
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
78267
78302
  timelapse: {
78268
78303
  store: center.timelapseStore,
78269
- getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
78304
+ getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
78270
78305
  runNow: (input) => center.runTimelapseNow(input)
78271
78306
  },
78272
78307
  summary: {
@@ -79359,7 +79394,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79359
79394
  logger: logger.child("RecordedClip"),
79360
79395
  now: () => Date.now(),
79361
79396
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
79362
- getAvailability: (input) => api.recording.getAvailability.query(input),
79397
+ getAvailability: (input) => ourRecordingAvailability(api, input),
79363
79398
  createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
79364
79399
  getExport: (input) => api.recordingExport.getExport.query(input),
79365
79400
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
@@ -79613,7 +79648,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79613
79648
  buildTimelapsePorts(api, stores) {
79614
79649
  return {
79615
79650
  getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
79616
- const availability = await api.recording.getAvailability.query({
79651
+ const availability = await ourRecordingAvailability(api, {
79617
79652
  deviceId,
79618
79653
  fromMs,
79619
79654
  toMs,
@@ -83804,7 +83839,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
83804
83839
  if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
83805
83840
  let value = null;
83806
83841
  try {
83807
- const res = await this.ctx.api.recording.getAvailability.query({
83842
+ const res = await ourRecordingAvailability(this.ctx.api, {
83808
83843
  deviceId,
83809
83844
  fromMs: 0,
83810
83845
  toMs: Date.now()
@@ -85850,7 +85885,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85850
85885
  } });
85851
85886
  const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
85852
85887
  const recordedStills = new RecordedStillService(buildRecordedStillPorts({
85853
- api: this.ctx.api.recording,
85888
+ api: this.ctx.api.recordingArchive,
85854
85889
  logger: this.ctx.logger
85855
85890
  }));
85856
85891
  const deps = {