@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.
@@ -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-m5QdWXqR.js");
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,6 +47868,184 @@ async function retentionScopeDeviceIds(deps, onError) {
47714
47868
  return [...ids];
47715
47869
  }
47716
47870
  //#endregion
47871
+ //#region src/pipeline-analytics/stillness-calibration-actions.ts
47872
+ /**
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.
47882
+ *
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.
47892
+ */
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({
47961
+ /**
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.
47965
+ */
47966
+ "stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
47967
+ kind: "mutation",
47968
+ auth: "admin"
47969
+ }) });
47970
+ //#endregion
47971
+ //#region src/pipeline-analytics/stillness-settings.ts
47972
+ /**
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.
47975
+ *
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.
47994
+ */
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
+ };
48032
+ return {
48033
+ stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48034
+ stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48035
+ stillnessCalibrationNote: pick("stillnessCalibrationNote")
48036
+ };
48037
+ }
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;
48047
+ }
48048
+ //#endregion
47717
48049
  //#region src/pipeline-analytics/viewer-settings-actions.ts
47718
48050
  /**
47719
48051
  * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
@@ -47934,338 +48266,6 @@ function makeViewerSettingsActionHandlers(store) {
47934
48266
  };
47935
48267
  }
47936
48268
  //#endregion
47937
- //#region src/pipeline-analytics/stillness-calibration-actions.ts
47938
- /**
47939
- * stillness-calibration-actions — "measure this camera's own stillness scale".
47940
- *
47941
- * ## Why an `addons.custom` action and not a cap method
47942
- *
47943
- * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
47944
- * lives in `@camstack/types`, which travels inside the `@camstack/server`
47945
- * closure, so a new one is not callable until a server train ships. Here the
47946
- * schema lives in this addon's own dist and the hub forwards an opaque
47947
- * envelope, so `camstack deploy` is the whole delivery.
47948
- *
47949
- * ## Why `apply` is explicit and not a fallback
47950
- *
47951
- * A calibration run READS recorded tracks and does arithmetic. Applying it
47952
- * WRITES the camera's settings and changes what the tracker calls settled from
47953
- * the next frame on. Those are different enough in consequence that a button
47954
- * labelled "calibrate" must not choose between them on the operator's behalf —
47955
- * the run reports, the operator applies. A REFUSED run never writes the bar
47956
- * whatever `apply` says; it writes only the note, so the refusal and its reason
47957
- * are visible in the form instead of being a toast that scrolls away.
47958
- */
47959
- /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
47960
- * self-describing and a new reason is a deliberate edit in both places. */
47961
- var StillnessRefusalSchema = require_dist._enum([
47962
- "no-tracks",
47963
- "too-few-tracks",
47964
- "too-few-samples",
47965
- "no-motion-evidence"
47966
- ]);
47967
- var StillnessMeasurementSchema = require_dist.object({
47968
- /** Tracks that contributed at least one speed sample. */
47969
- trackCount: require_dist.number().int(),
47970
- /** Tracks the pass actually read, after the cost bound. */
47971
- tracksRead: require_dist.number().int(),
47972
- sampleCount: require_dist.number().int(),
47973
- /** Speed quantiles, subject-box-diagonals per second. `null` when there was
47974
- * nothing to measure — never 0, which would read as a measured stillness
47975
- * (D393). */
47976
- p25: require_dist.number().nullable(),
47977
- p50: require_dist.number().nullable(),
47978
- p90: require_dist.number().nullable(),
47979
- /** Median gap between stored positions (s) — the camera's real cadence. */
47980
- medianIntervalSec: require_dist.number().nullable(),
47981
- /** Median subject box diagonal (px) the speeds were normalised by. */
47982
- medianSubjectDiagonalPx: require_dist.number().nullable()
47983
- });
47984
- var StillnessCalibrateInputSchema = require_dist.object({
47985
- deviceId: require_dist.number().int(),
47986
- /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
47987
- * row, so the window is a suggestion and `maxTracks` is the real bound. */
47988
- sinceHours: require_dist.number().min(.25).max(720).default(72),
47989
- /** Hard cost bound on tracks read, newest first. */
47990
- maxTracks: require_dist.number().int().min(1).max(1e3).default(200),
47991
- /**
47992
- * Write the chosen bar onto the camera. Never implicit, and never done at
47993
- * all for a refused run.
47994
- */
47995
- apply: require_dist.boolean().default(false)
47996
- });
47997
- var StillnessCalibrateResultSchema = require_dist.object({
47998
- deviceId: require_dist.number().int(),
47999
- /** The window actually read (epoch ms). */
48000
- since: require_dist.number().int(),
48001
- until: require_dist.number().int(),
48002
- accepted: require_dist.boolean(),
48003
- /** Why it refused. Absent on an accepted run. */
48004
- reason: StillnessRefusalSchema.optional(),
48005
- /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
48006
- * never 0, which the settings layer reads as "uncalibrated". */
48007
- bar: require_dist.number().optional(),
48008
- /** The raw quantile before the rails, so the clamp is arguable. */
48009
- rawBar: require_dist.number().optional(),
48010
- clampedBy: require_dist._enum([
48011
- "none",
48012
- "floor",
48013
- "ceiling",
48014
- "min",
48015
- "max"
48016
- ]).optional(),
48017
- measurement: StillnessMeasurementSchema,
48018
- /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
48019
- * before any write, so the operator sees both numbers. */
48020
- previousBar: require_dist.number().nullable(),
48021
- /** Whether the camera's settings were actually written. */
48022
- applied: require_dist.boolean(),
48023
- /** The one-line note stored on the camera (accepted or refused). */
48024
- note: require_dist.string()
48025
- });
48026
- var stillnessCalibrationActions = require_dist.defineCustomActions({
48027
- /**
48028
- * `admin` because it WRITES a per-camera detection threshold when `apply` is
48029
- * set, and because the report describes how the camera's subjects move,
48030
- * which is not a viewer-level fact.
48031
- */
48032
- "stillness.calibrate": require_dist.customAction(StillnessCalibrateInputSchema, StillnessCalibrateResultSchema, {
48033
- kind: "mutation",
48034
- auth: "admin"
48035
- }) });
48036
- /**
48037
- * The shipped limits.
48038
- *
48039
- * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
48040
- * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
48041
- * camera that cannot clear these has genuinely not been watched enough.
48042
- * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
48043
- * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
48044
- */
48045
- var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
48046
- minTracks: 12,
48047
- minSamples: 200,
48048
- maxTracks: 200,
48049
- minIntervalSec: .02,
48050
- maxIntervalSec: 3,
48051
- quantile: .25,
48052
- floorFactor: .02,
48053
- ceilFactor: .25,
48054
- minBar: .001,
48055
- maxBar: 1
48056
- };
48057
- /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
48058
- * would read as a measured zero (D393). */
48059
- function quantileOf(values, p) {
48060
- if (values.length === 0) return NaN;
48061
- const sorted = [...values].sort((a, b) => a - b);
48062
- const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
48063
- const lo = Math.floor(idx);
48064
- const hi = Math.ceil(idx);
48065
- const at = sorted[lo];
48066
- const next = sorted[hi];
48067
- if (at === void 0 || next === void 0) return NaN;
48068
- return at + (next - at) * (idx - lo);
48069
- }
48070
- /**
48071
- * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
48072
- *
48073
- * A pair of consecutive positions contributes a sample only when the interval
48074
- * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
48075
- * has a positive diagonal. A gap outside that band is a session boundary or a
48076
- * duplicate read, not a measurement of how fast anything travelled; a box with
48077
- * no size is unknown geometry, and unknown is never a sample.
48078
- */
48079
- function collectStillnessSamples(tracks, limits) {
48080
- const speeds = [];
48081
- const intervals = [];
48082
- const diagonals = [];
48083
- let trackCount = 0;
48084
- const read = tracks.slice(0, limits.maxTracks);
48085
- for (const track of read) {
48086
- const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
48087
- let contributed = false;
48088
- for (let i = 1; i < positions.length; i++) {
48089
- const prev = positions[i - 1];
48090
- const cur = positions[i];
48091
- if (prev === void 0 || cur === void 0) continue;
48092
- const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
48093
- if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
48094
- const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
48095
- if (!(diagonal > 0)) continue;
48096
- const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
48097
- speeds.push(travelled / diagonal / dtSec);
48098
- intervals.push(dtSec);
48099
- diagonals.push(diagonal);
48100
- contributed = true;
48101
- }
48102
- if (contributed) trackCount++;
48103
- }
48104
- return {
48105
- speeds,
48106
- intervals,
48107
- diagonals,
48108
- trackCount,
48109
- tracksRead: read.length
48110
- };
48111
- }
48112
- function measure$1(samples) {
48113
- return {
48114
- trackCount: samples.trackCount,
48115
- sampleCount: samples.speeds.length,
48116
- p25: quantileOf(samples.speeds, .25),
48117
- p50: quantileOf(samples.speeds, .5),
48118
- p90: quantileOf(samples.speeds, .9),
48119
- medianIntervalSec: quantileOf(samples.intervals, .5),
48120
- medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
48121
- };
48122
- }
48123
- /**
48124
- * Choose this camera's stillness speed bar, or refuse with a named reason.
48125
- *
48126
- * The order of the gates is the order of the questions: is there anything at
48127
- * all, is it enough tracks, is it enough samples, and did anything on this
48128
- * camera ever actually move. The last one is not pedantry — the whole method
48129
- * scales the bar against the camera's own travel, so with no travel there is
48130
- * nothing to scale against and the default is the honest answer.
48131
- */
48132
- function calibrateStillnessSpeed(samples, limits) {
48133
- const measurement = measure$1(samples);
48134
- if (samples.tracksRead === 0) return {
48135
- ok: false,
48136
- reason: "no-tracks",
48137
- measurement
48138
- };
48139
- if (samples.trackCount < limits.minTracks) return {
48140
- ok: false,
48141
- reason: "too-few-tracks",
48142
- measurement
48143
- };
48144
- if (samples.speeds.length < limits.minSamples) return {
48145
- ok: false,
48146
- reason: "too-few-samples",
48147
- measurement
48148
- };
48149
- if (!(measurement.p90 > 0)) return {
48150
- ok: false,
48151
- reason: "no-motion-evidence",
48152
- measurement
48153
- };
48154
- const rawBar = quantileOf(samples.speeds, limits.quantile);
48155
- const floor = measurement.p90 * limits.floorFactor;
48156
- const ceiling = measurement.p90 * limits.ceilFactor;
48157
- let bar = rawBar;
48158
- let clampedBy = "none";
48159
- if (bar < floor) {
48160
- bar = floor;
48161
- clampedBy = "floor";
48162
- } else if (bar > ceiling) {
48163
- bar = ceiling;
48164
- clampedBy = "ceiling";
48165
- }
48166
- if (bar < limits.minBar) {
48167
- bar = limits.minBar;
48168
- clampedBy = "min";
48169
- } else if (bar > limits.maxBar) {
48170
- bar = limits.maxBar;
48171
- clampedBy = "max";
48172
- }
48173
- return {
48174
- ok: true,
48175
- bar,
48176
- rawBar,
48177
- clampedBy,
48178
- measurement
48179
- };
48180
- }
48181
- /** One line an operator can read: what was measured, from how much, and what
48182
- * rule produced the stored number. Kept short — it rides the settings blob. */
48183
- function describeStillnessCalibration(outcome) {
48184
- const m = outcome.measurement;
48185
- const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
48186
- const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
48187
- if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
48188
- return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
48189
- }
48190
- //#endregion
48191
- //#region src/pipeline-analytics/stillness-settings.ts
48192
- /**
48193
- * Per-device STILLNESS SCALE — the one number that says how fast a subject on
48194
- * THIS camera has to move before it is travelling.
48195
- *
48196
- * A threshold belongs to whoever GENERATES the event, per device, never baked
48197
- * into a classifier default. The judgement this feeds is
48198
- * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
48199
- * `pipeline/tracker/stillness-scale.ts`, which also carries the live
48200
- * measurement showing why one number cannot serve a 30 m driveway and a 2 m
48201
- * doorway.
48202
- *
48203
- * Cascade and shape mirror `stationary-settings` / `media-settings`: a
48204
- * per-device override on top of the default, resolved per FIELD, and a parse
48205
- * that never throws on a bad blob.
48206
- *
48207
- * **Absent behaves exactly as today.** The device store is a flat numeric blob
48208
- * with no room for "unset", so the stored form of absent is `0` — and `0` is
48209
- * not a bar anyone could want (it would mean nothing on this camera is ever
48210
- * still) and not a value the calibration can produce (`minBar`). The decision
48211
- * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
48212
- * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
48213
- * absolute `velocityThreshold` path.
48214
- */
48215
- var StillnessSettingsSchema = require_dist.object({
48216
- /**
48217
- * The camera's "not travelling" bar: centroid speed as a fraction of the
48218
- * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
48219
- *
48220
- * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
48221
- * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
48222
- * transits).
48223
- */
48224
- stillnessSpeedFracPerSec: require_dist.number().min(0).max(1).default(0),
48225
- /**
48226
- * When the number above was last CHOSEN — by a calibration run or by the
48227
- * operator. `0` = never. Purely a readout; nothing gates on it.
48228
- */
48229
- stillnessCalibratedAt: require_dist.number().int().min(0).default(0),
48230
- /**
48231
- * What the last calibration run measured and decided, in one line
48232
- * (`describeStillnessCalibration`) — including a REFUSAL and its named
48233
- * reason, because "why is this camera still on the default" has to be
48234
- * answerable without re-running anything.
48235
- */
48236
- stillnessCalibrationNote: require_dist.string().max(400).default("")
48237
- });
48238
- var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
48239
- /**
48240
- * Resolve a per-device store blob into typed stillness settings. Unknown or
48241
- * invalid fields fall back to the default for that field — including a bar
48242
- * outside the calibration's own rails, which is refused rather than clamped:
48243
- * a number the calibration could never have produced is not evidence about
48244
- * this camera, and silently bending it into range would present it as if it
48245
- * were.
48246
- */
48247
- function resolveStillnessSettings(raw) {
48248
- const pick = (key) => {
48249
- const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
48250
- return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
48251
- };
48252
- return {
48253
- stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48254
- stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48255
- stillnessCalibrationNote: pick("stillnessCalibrationNote")
48256
- };
48257
- }
48258
- /**
48259
- * The bar the decision layer gets: a positive, in-rails number, or
48260
- * `undefined` for "this camera is not calibrated — judge it exactly as
48261
- * before". The ONE place the stored-zero convention is interpreted.
48262
- */
48263
- function resolveStillSpeedFracPerSec(settings) {
48264
- const bar = settings.stillnessSpeedFracPerSec;
48265
- if (!(bar >= .001) || bar > 1) return void 0;
48266
- return bar;
48267
- }
48268
- //#endregion
48269
48269
  //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
48270
48270
  /**
48271
48271
  * @durable class=config owner=pipeline-analytics
@@ -48443,6 +48443,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
48443
48443
  return retired;
48444
48444
  }
48445
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
+ /**
48446
48670
  * How long the recorder keeps the file we are about to read and delete.
48447
48671
  *
48448
48672
  * `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
@@ -48696,230 +48920,6 @@ function decodeBase64(base64) {
48696
48920
  return null;
48697
48921
  }
48698
48922
  }
48699
- /**
48700
- * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
48701
- *
48702
- * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
48703
- * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
48704
- * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
48705
- * one encode per event over a ~12 s window, not a permanent transcode child
48706
- * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
48707
- * it was measured on a real 615 720p cut before being chosen: **0.23 s of
48708
- * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
48709
- * The re-encode is smaller than what it replaces.
48710
- *
48711
- * So both attachments now agree on the timeline as well as on the window: one
48712
- * clip, one rate, two containers.
48713
- */
48714
- var DEFAULT_SPEED = 4;
48715
- /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
48716
- * at or below 1080p is an identity scale and the broker copies it. */
48717
- var RING_MP4_MAX_WIDTH = 1920;
48718
- /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
48719
- var RING_MP4_FPS = 15;
48720
- var NO_MEDIA = {
48721
- mp4: null,
48722
- gif: null,
48723
- source: "none",
48724
- startOffsetMs: null,
48725
- endOffsetMs: null,
48726
- profile: null,
48727
- video: null
48728
- };
48729
- var EventMediaService = class {
48730
- deps;
48731
- constructor(deps) {
48732
- this.deps = deps;
48733
- }
48734
- /**
48735
- * Cut this event. Never throws — a notification that lost its media is still
48736
- * a notification, and every branch that drops it logs why.
48737
- */
48738
- async cut(request) {
48739
- if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
48740
- const produced = await this.produce(request);
48741
- if (produced !== null) return produced;
48742
- return this.fallbackToRing(request);
48743
- }
48744
- async produce(request) {
48745
- const deviceId = request.deviceId;
48746
- const log = this.deps.logger;
48747
- const kinds = [];
48748
- if (request.wantMp4) kinds.push("mp4");
48749
- if (request.wantGif) kinds.push("gif");
48750
- let production;
48751
- try {
48752
- production = await this.deps.produce({
48753
- deviceId,
48754
- aroundMs: request.aroundMs,
48755
- preSeconds: request.preRollSec,
48756
- postSeconds: request.postRollSec,
48757
- kinds,
48758
- gifMaxWidth: 640,
48759
- gifFps: 12,
48760
- gifSpeed: 4,
48761
- speed: request.speed ?? DEFAULT_SPEED,
48762
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48763
- });
48764
- } catch (err) {
48765
- log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
48766
- tags: { deviceId },
48767
- meta: { error: err instanceof Error ? err.message : String(err) }
48768
- });
48769
- return null;
48770
- }
48771
- const mp4 = await this.redeem(deviceId, production.media, "mp4");
48772
- const gif = await this.redeem(deviceId, production.media, "gif");
48773
- if (mp4 === null && gif === null) {
48774
- log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
48775
- tags: { deviceId },
48776
- meta: {
48777
- kinds: kinds.join(","),
48778
- profile: production.profile
48779
- }
48780
- });
48781
- return null;
48782
- }
48783
- const startOffsetMs = production.coverage.fromTs - request.aroundMs;
48784
- const endOffsetMs = production.coverage.toTs - request.aroundMs;
48785
- log.info("nc media: one production, every attachment", {
48786
- tags: { deviceId },
48787
- meta: {
48788
- profile: production.profile,
48789
- video: production.video,
48790
- mp4Bytes: mp4?.byteLength ?? null,
48791
- gifBytes: gif?.byteLength ?? null,
48792
- startOffsetMs,
48793
- endOffsetMs,
48794
- sharedWindow: true
48795
- }
48796
- });
48797
- return {
48798
- mp4,
48799
- gif,
48800
- source: "produced",
48801
- startOffsetMs,
48802
- endOffsetMs,
48803
- profile: production.profile,
48804
- video: production.video
48805
- };
48806
- }
48807
- /**
48808
- * Redeem one artifact handle at the node that produced it.
48809
- *
48810
- * `null` is not an error here — a production simply may not carry the kind
48811
- * (a gif whose derive failed says so on the broker's own log line). A handle
48812
- * that FAILS to redeem is different and is logged, because it means the bytes
48813
- * existed and did not arrive.
48814
- */
48815
- async redeem(deviceId, media, kind) {
48816
- const artifact = media.find((m) => m.kind === kind);
48817
- if (artifact === void 0) return null;
48818
- try {
48819
- const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
48820
- if (res === null) {
48821
- this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
48822
- tags: { deviceId },
48823
- meta: {
48824
- kind,
48825
- handle: artifact.handle,
48826
- nodeId: artifact.nodeId
48827
- }
48828
- });
48829
- return null;
48830
- }
48831
- const buf = Buffer.from(res.base64, "base64");
48832
- if (buf.byteLength === 0) return null;
48833
- const bytes = new Uint8Array(buf.byteLength);
48834
- bytes.set(buf);
48835
- return bytes;
48836
- } catch (err) {
48837
- this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
48838
- tags: { deviceId },
48839
- meta: {
48840
- kind,
48841
- handle: artifact.handle,
48842
- nodeId: artifact.nodeId,
48843
- error: err instanceof Error ? err.message : String(err)
48844
- }
48845
- });
48846
- return null;
48847
- }
48848
- }
48849
- /**
48850
- * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
48851
- *
48852
- * Weaker than a production and deliberately so — the coherence here rests on
48853
- * the two calls being given IDENTICAL parameters rather than on there being
48854
- * one render. That is defensible because the ring only ever grows forward and
48855
- * `windowAround` selects by wall clock around the same instant, so two calls
48856
- * seconds apart choose the same packets; the one thing that could differ is
48857
- * the rendition, so the profile is pinned to whatever the FIRST call actually
48858
- * used and handed to the second.
48859
- *
48860
- * It exists for one reason: a hub whose `@camstack/server` predates the
48861
- * production method must not stop attaching footage, and three of the four
48862
- * live rules on this install ask for a gif and nothing else.
48863
- */
48864
- async fallbackToRing(request) {
48865
- const deviceId = request.deviceId;
48866
- const speed = request.speed ?? DEFAULT_SPEED;
48867
- const window = {
48868
- deviceId,
48869
- aroundMs: request.aroundMs,
48870
- preRollSec: request.preRollSec,
48871
- postRollSec: request.postRollSec,
48872
- speed,
48873
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48874
- };
48875
- try {
48876
- const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
48877
- ...window,
48878
- format: "mp4",
48879
- maxWidth: RING_MP4_MAX_WIDTH,
48880
- fps: RING_MP4_FPS
48881
- }) : null;
48882
- const gif = request.wantGif ? await this.deps.renderRingClip({
48883
- ...window,
48884
- format: "gif",
48885
- maxWidth: 640,
48886
- fps: 12 / 4,
48887
- speed: 4
48888
- }) : null;
48889
- if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
48890
- this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
48891
- tags: { deviceId },
48892
- meta: { aroundMs: request.aroundMs }
48893
- });
48894
- return NO_MEDIA;
48895
- }
48896
- this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
48897
- tags: { deviceId },
48898
- meta: {
48899
- mp4Bytes: mp4?.byteLength ?? null,
48900
- gifBytes: gif?.byteLength ?? null,
48901
- profile: request.profile ?? null,
48902
- sharedWindow: "by-parameters"
48903
- }
48904
- });
48905
- return {
48906
- mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
48907
- gif: gif !== null && gif.byteLength > 0 ? gif : null,
48908
- source: "ring-fallback",
48909
- startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
48910
- endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
48911
- profile: request.profile ?? null,
48912
- video: null
48913
- };
48914
- } catch (err) {
48915
- this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
48916
- tags: { deviceId },
48917
- meta: { error: err instanceof Error ? err.message : String(err) }
48918
- });
48919
- return NO_MEDIA;
48920
- }
48921
- }
48922
- };
48923
48923
  //#endregion
48924
48924
  //#region src/shared/frame/crop-extractor.ts
48925
48925
  /**
@@ -57190,6 +57190,41 @@ function resolveMediaSettings(raw) {
57190
57190
  snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
57191
57191
  };
57192
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
+ }
57193
57228
  function toDetectionBbox(bbox) {
57194
57229
  return {
57195
57230
  x: bbox.x,
@@ -58481,6 +58516,17 @@ function resolveSearchThumbnailUrl(input) {
58481
58516
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
58482
58517
  }
58483
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
58484
58530
  //#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
58485
58531
  /**
58486
58532
  * Decide whether a package-class box is plausibly parcel-sized.
@@ -61174,65 +61220,6 @@ var StationaryEvidenceLedger = class {
61174
61220
  for (const row of excess) this.forgetKey(row.key);
61175
61221
  }
61176
61222
  };
61177
- //#endregion
61178
- //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
61179
- function planStationaryTrackDisposal(input) {
61180
- const trackIds = [input.liveTrackId];
61181
- if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
61182
- const keepMediaIds = /* @__PURE__ */ new Set();
61183
- if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
61184
- return {
61185
- trackIds,
61186
- keepMediaIds
61187
- };
61188
- }
61189
- /**
61190
- * Run the plan. The live track is dropped from RAM FIRST so it can never be
61191
- * persisted by a sweep racing this call; the cascade then removes what every
61192
- * track owned, sparing the entry's key frame, and deletes the row (a no-op for
61193
- * the never-persisted live track).
61194
- *
61195
- * Never silent: the line names the entity, what went and what was kept — a
61196
- * deleted track is dropped work, and the operator asked for it by name.
61197
- */
61198
- async function disposeStationaryTracks(deps, plan) {
61199
- const [liveTrackId] = plan.trackIds;
61200
- if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
61201
- const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
61202
- const result = await runTrackCascadeBatch({
61203
- registry: [...deps.leaves, sparingMedia],
61204
- trackStore: deps.trackStore,
61205
- deviceId: deps.deviceId,
61206
- onTrackCleanup: deps.onTrackCleanup,
61207
- onFailure: (trackId, err) => {
61208
- deps.logger.warn("stationary promotion: a source track could not be disposed of", {
61209
- tags: { deviceId: deps.deviceId },
61210
- meta: {
61211
- deviceId: deps.deviceId,
61212
- entryId: deps.entryId,
61213
- trackId,
61214
- error: String(err)
61215
- }
61216
- });
61217
- }
61218
- }, plan.trackIds);
61219
- deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
61220
- tags: { deviceId: deps.deviceId },
61221
- meta: {
61222
- deviceId: deps.deviceId,
61223
- entryId: deps.entryId,
61224
- trackIds: plan.trackIds,
61225
- keptMediaIds: [...plan.keepMediaIds],
61226
- deleted: result.deleted,
61227
- failed: result.failed,
61228
- mediaRows: result.tally.media
61229
- }
61230
- });
61231
- return {
61232
- deleted: result.deleted,
61233
- failed: result.failed
61234
- };
61235
- }
61236
61223
  /**
61237
61224
  * How long the same weak box must keep being seen before it may re-anchor.
61238
61225
  *
@@ -62174,6 +62161,97 @@ function rowToEntry(id, data) {
62174
62161
  };
62175
62162
  }
62176
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
62177
62255
  //#region src/pipeline-analytics/pipeline/suppressed-births.ts
62178
62256
  /**
62179
62257
  * Memory of births the confirmation gate rejected, per device.
@@ -63089,49 +63167,6 @@ function hasKeyFrame(source) {
63089
63167
  return source.keyFrame !== null && source.bbox !== null;
63090
63168
  }
63091
63169
  //#endregion
63092
- //#region src/pipeline-analytics/pipeline/stationary-planes.ts
63093
- /**
63094
- * WHICH detection planes carry the stationary machinery.
63095
- *
63096
- * A plane qualifies when it produces OBJECT BOXES on decoded frames — because
63097
- * that is what the registry needs to decide an object is parked: a box, a
63098
- * class, and an observed clock that only advances while frames flow.
63099
- *
63100
- * Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
63101
- * camera's own firmware, and the boxes it produces are paired with a decoded
63102
- * frame in that frame's pixel space — the same plane, entered by a different
63103
- * door. `sensor` and `audio` do not: they carry no geometry to park.
63104
- *
63105
- * It used to be `source === 'pipeline'`, in five places, each explaining that
63106
- * the gate "runs on that plane". The reason given was WHERE the flood it was
63107
- * written for had been measured (617, the ML plane), never an argument that the
63108
- * firmware's boxes were unfit. The cost of the accident, on device 640: a still
63109
- * object is never promoted, so it holds `hasLiveTrack` true for as long as it
63110
- * sits there, and every detection session runs to its 120 s cap instead of
63111
- * closing on cooldown — `closed at the max-hold cap with a subject still
63112
- * present`. The camera's lazy stream is then torn down and rebuilt on the next
63113
- * detection, on a battery camera.
63114
- *
63115
- * One predicate and not five comparisons, because five copies of a rule drift:
63116
- * this repo has already paid for that with two authorities on one function
63117
- * (D62).
63118
- */
63119
- /** Does this detection plane run the stationary registry, gate and promotion? */
63120
- function planeRunsStationary(source) {
63121
- return source === "pipeline" || source === "onboard";
63122
- }
63123
- //#endregion
63124
- //#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
63125
- /**
63126
- * May a suppression verdict RETRACT this birth?
63127
- *
63128
- * `false` means hold: keep the track, log the held verdict, and let the
63129
- * ordinary lifetime decide. It never means "confirmed".
63130
- */
63131
- function suppressionMayRetract(source) {
63132
- return source !== "onboard";
63133
- }
63134
- //#endregion
63135
63170
  //#region src/pipeline-analytics/rebuild-source-loader.ts
63136
63171
  /**
63137
63172
  * Assemble one track's rebuild inputs, reading at most ONE blob.
@@ -78342,7 +78377,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78342
78377
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
78343
78378
  timelapse: {
78344
78379
  store: center.timelapseStore,
78345
- getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
78380
+ getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
78346
78381
  runNow: (input) => center.runTimelapseNow(input)
78347
78382
  },
78348
78383
  summary: {
@@ -79435,7 +79470,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
79435
79470
  logger: logger.child("RecordedClip"),
79436
79471
  now: () => Date.now(),
79437
79472
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
79438
- getAvailability: (input) => api.recording.getAvailability.query(input),
79473
+ getAvailability: (input) => ourRecordingAvailability(api, input),
79439
79474
  createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
79440
79475
  getExport: (input) => api.recordingExport.getExport.query(input),
79441
79476
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
@@ -79689,7 +79724,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
79689
79724
  buildTimelapsePorts(api, stores) {
79690
79725
  return {
79691
79726
  getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
79692
- const availability = await api.recording.getAvailability.query({
79727
+ const availability = await ourRecordingAvailability(api, {
79693
79728
  deviceId,
79694
79729
  fromMs,
79695
79730
  toMs,
@@ -83880,7 +83915,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
83880
83915
  if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
83881
83916
  let value = null;
83882
83917
  try {
83883
- const res = await this.ctx.api.recording.getAvailability.query({
83918
+ const res = await ourRecordingAvailability(this.ctx.api, {
83884
83919
  deviceId,
83885
83920
  fromMs: 0,
83886
83921
  toMs: Date.now()
@@ -85926,7 +85961,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
85926
85961
  } });
85927
85962
  const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
85928
85963
  const recordedStills = new RecordedStillService(buildRecordedStillPorts({
85929
- api: this.ctx.api.recording,
85964
+ api: this.ctx.api.recordingArchive,
85930
85965
  logger: this.ctx.logger
85931
85966
  }));
85932
85967
  const deps = {