@camstack/addon-post-analysis 1.2.48 → 1.2.49

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-DKhwOB5L.js");
5
+ const require_dist = require("../dist-BaqsyWbd.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -6812,6 +6812,34 @@ function isEdgeAwareNewBest(current, candidate, hysteresis) {
6812
6812
  //#endregion
6813
6813
  //#region src/pipeline-analytics/pipeline/label-utils.ts
6814
6814
  /**
6815
+ * Every macro class the pipeline can carry on a detection. A `label` exists to
6816
+ * say something FINER than the class — a species, a vehicle type, a plate read,
6817
+ * an identity — so a value drawn from this set is by definition not a label,
6818
+ * whatever produced it.
6819
+ *
6820
+ * Typed against the macro unions so a new macro class is a compile error here
6821
+ * rather than a silent hole. `package` is a first-level macro of the default
6822
+ * detector that predates `FirstLevelMacro`, hence the explicit member.
6823
+ */
6824
+ var MACRO_CLASSES = new Set([
6825
+ "person",
6826
+ "vehicle",
6827
+ "animal",
6828
+ "package",
6829
+ "face",
6830
+ "plate",
6831
+ "audio"
6832
+ ]);
6833
+ /**
6834
+ * Whether a class token is a macro class. EXACT match, no case folding: plate
6835
+ * OCR text is upper-case and identities are capitalised, so `PLATE` (a real
6836
+ * read) and `Person` (a real name) must survive while `plate` / `person` (the
6837
+ * taxonomy tokens) must not.
6838
+ */
6839
+ function isMacroClass(value) {
6840
+ return MACRO_CLASSES.has(value);
6841
+ }
6842
+ /**
6815
6843
  * Opaque internal label markers that carry no human-readable meaning and must
6816
6844
  * never surface on an event/track. The face-embedding (arcface) step pushes a
6817
6845
  * `embedding:<dim>d` marker (score 1.0) onto the parent's labels — it is a
@@ -6842,12 +6870,26 @@ function pickTopLabel(labels, excludeClass) {
6842
6870
  /**
6843
6871
  * Resolve the label to stamp on an ObjectEvent / track: an enrichment label
6844
6872
  * (identity, plate text) takes precedence; otherwise the raw detector class
6845
- * (`originalClass`) when it differs from the macro class; otherwise nothing.
6873
+ * (`originalClass`) when it is genuinely FINER than the macro class; otherwise
6874
+ * nothing.
6875
+ *
6876
+ * "Finer" is load-bearing, and `originalClass !== className` alone does not
6877
+ * establish it. The two fields come from different time bases — `className` is
6878
+ * the tracker's confidence-weighted LIFETIME class vote, `originalClass` is the
6879
+ * class of the detection matched into the track THIS frame — and outside
6880
+ * `verbosity === 'full'` the runner ships no `debug.originalClass` at all, so
6881
+ * the FrameProcessor substitutes `det.macroClass`. A person track that a single
6882
+ * animal detection matched into therefore arrived here as
6883
+ * `{ className: 'person', originalClass: 'animal' }` and got `label: 'animal'`
6884
+ * written on it: a COARSE class in the slot reserved for finer information.
6885
+ * A macro class is never an answer, so it is never a label.
6846
6886
  */
6847
6887
  function resolveDetectionLabel(input) {
6848
6888
  const top = pickTopLabel(input.labels, input.className);
6849
6889
  if (top) return top;
6850
- return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
6890
+ const raw = input.originalClass;
6891
+ if (raw === void 0 || raw === input.className || isMacroClass(raw)) return void 0;
6892
+ return raw;
6851
6893
  }
6852
6894
  var DEFAULT_ZONE_TRANSITION_CONFIG = {
6853
6895
  enterFrames: 2,
@@ -11840,9 +11882,24 @@ var TrackDetailDispatcher = class {
11840
11882
  dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
11841
11883
  }
11842
11884
  /**
11843
- * Run the request once via the frameHandle, and — on a miss (null OR throw)
11844
- * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
11845
- * the detail list, or `null` when both attempts fail to produce a result.
11885
+ * Walk the crop-source ladder for one request, stopping at the first rung that
11886
+ * produces a result. Returns the detail list, or `null` when every rung failed.
11887
+ *
11888
+ * 1. `frameHandle` — cheapest when it hits: no payload moves and the runner's
11889
+ * leaf children re-cut their ROI at native resolution. Tried first, always.
11890
+ * 2. `frameJpeg` — the freshest frame's FULL raster, fetched from the node that
11891
+ * owns the handle and handed to the runner UNCUT, so the rectangle still
11892
+ * comes from the single derivation (D52). This rung exists because rung 1 is
11893
+ * structurally unreachable for most dispatches: the decode worker's native
11894
+ * lease is 1.2 s and a tick + inference routinely lands outside it, while
11895
+ * the retained store holds the frame for 3 s.
11896
+ * 3. `cropJpeg` — the pre-cut tile. Last, because the caller decided its
11897
+ * rectangle and because in practice the same native miss that emptied rung 1
11898
+ * empties this one too.
11899
+ *
11900
+ * On D9/D18: the payload that crosses in rung 2 is a COMPRESSED JPEG fetched on
11901
+ * demand, per TRACK per dispatch (≤2 in flight per device, ~1 Hz) and only after
11902
+ * rung 1 missed — never a frame-rate stream, and never raw pixels.
11846
11903
  */
11847
11904
  async runOnce(deviceId, dev, req, frame) {
11848
11905
  const parent = {
@@ -11859,7 +11916,39 @@ var TrackDetailDispatcher = class {
11859
11916
  }, frame.nodeId);
11860
11917
  if (primary !== null) return primary.details;
11861
11918
  } catch (err) {
11862
- this.deps.logger.debug("detail primary call failed — trying crop fallback", {
11919
+ this.deps.logger.debug("detail primary call failed — trying frame fallback", {
11920
+ tags: { deviceId },
11921
+ meta: {
11922
+ trackId: req.trackId,
11923
+ stepId: req.stepId,
11924
+ error: String(err)
11925
+ }
11926
+ });
11927
+ }
11928
+ if (this.deps.captureFullFrameBase64 !== void 0 && frame.frameHandle !== void 0) try {
11929
+ const full = await this.deps.captureFullFrameBase64(frame);
11930
+ if (full !== null) if (full.width !== frame.frameWidth || full.height !== frame.frameHeight) this.deps.logger.warn("detail full-frame fallback refused — frame is not the bbox scale", {
11931
+ tags: { deviceId },
11932
+ meta: {
11933
+ trackId: req.trackId,
11934
+ stepId: req.stepId,
11935
+ frameWidth: full.width,
11936
+ frameHeight: full.height,
11937
+ bboxFrameWidth: frame.frameWidth,
11938
+ bboxFrameHeight: frame.frameHeight
11939
+ }
11940
+ });
11941
+ else {
11942
+ const derived = await this.deps.runDetailSubtree({
11943
+ deviceId,
11944
+ frameJpeg: full.base64,
11945
+ parent,
11946
+ steps
11947
+ }, frame.nodeId);
11948
+ if (derived !== null) return derived.details;
11949
+ }
11950
+ } catch (err) {
11951
+ this.deps.logger.debug("detail full-frame fallback call failed", {
11863
11952
  tags: { deviceId },
11864
11953
  meta: {
11865
11954
  trackId: req.trackId,
@@ -11896,12 +11985,14 @@ var TrackDetailDispatcher = class {
11896
11985
  const now = Date.now();
11897
11986
  if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
11898
11987
  dev.lastFailWarnAt = now;
11899
- this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
11988
+ this.deps.logger.warn("runDetailSubtree produced no result (every crop source missed)", {
11900
11989
  tags: { deviceId },
11901
11990
  meta: {
11902
11991
  trackId: req.trackId,
11903
11992
  stepId: req.stepId,
11904
- reason: req.reason
11993
+ reason: req.reason,
11994
+ triedFullFrame: this.deps.captureFullFrameBase64 !== void 0,
11995
+ triedCropJpeg: this.deps.captureCropBase64 !== void 0
11905
11996
  }
11906
11997
  });
11907
11998
  }
@@ -18625,6 +18716,20 @@ function rowToSensorEvent(id, data) {
18625
18716
  function isRecord(x) {
18626
18717
  return x !== null && typeof x === "object" && !Array.isArray(x);
18627
18718
  }
18719
+ /**
18720
+ * May the interval this attempt consumed be given back?
18721
+ *
18722
+ * Pure, and deliberately takes the CURRENT clock as well as the mark: a rollback
18723
+ * is only correct while this attempt is still the one the gate is standing on. A
18724
+ * later frame's pending mark, or a snapshot that landed in between, has already
18725
+ * moved the reference forward and restoring an older one would re-open a window
18726
+ * that is legitimately closed.
18727
+ */
18728
+ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
18729
+ if (mark === void 0) return false;
18730
+ if (mark.at !== currentLastSnapshotAt) return false;
18731
+ return mark.priorRollbacks < 3;
18732
+ }
18628
18733
  //#endregion
18629
18734
  //#region src/pipeline-analytics/store/recent-cursor.ts
18630
18735
  function encodeRecentCursor(cursor) {
@@ -18958,6 +19063,8 @@ var TrackStore = class {
18958
19063
  t.snapshots.push(snapshot);
18959
19064
  t.lastSnapshotAt = snapshot.timestamp;
18960
19065
  t.lastSnapshotBbox = { ...snapshot.position.bbox };
19066
+ delete t.snapshotPending;
19067
+ t.snapshotRollbacks = 0;
18961
19068
  }
18962
19069
  /**
18963
19070
  * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
@@ -18969,18 +19076,57 @@ var TrackStore = class {
18969
19076
  * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
18970
19077
  * rolling `lastFrame`.
18971
19078
  *
18972
- * No rollback: if the write later fails the slot is simply lost (a rare dropped
18973
- * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
18974
- * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
18975
- * an unknown/expired track (the active entry is dropped on expiry, so there is
18976
- * no separate map to leak).
19079
+ * The advance is REVERSIBLE. It used to be permanent — this docblock said
19080
+ * "if the write later fails the slot is simply lost" — which made `snapshot`
19081
+ * the only media kind with no retry of any shape while every sibling advanced
19082
+ * its bookkeeping from the ids that actually persisted. See
19083
+ * {@link rollbackSnapshotPending} and `pipeline/snapshot-retry.ts`.
19084
+ *
19085
+ * `addSnapshot` re-stamps the same clock/bbox when the real snapshot lands, so
19086
+ * the two stay consistent. No-op for an unknown/expired track (the active entry
19087
+ * is dropped on expiry, so there is no separate map to leak).
18977
19088
  */
18978
19089
  markSnapshotPending(trackId, timestamp, bbox) {
18979
19090
  const t = this.active.get(trackId);
18980
19091
  if (!t) return;
19092
+ t.snapshotPending = {
19093
+ at: timestamp,
19094
+ previous: {
19095
+ at: t.lastSnapshotAt,
19096
+ bbox: t.lastSnapshotBbox ? { ...t.lastSnapshotBbox } : void 0
19097
+ },
19098
+ priorRollbacks: t.snapshotRollbacks ?? 0
19099
+ };
18981
19100
  t.lastSnapshotAt = timestamp;
18982
19101
  t.lastSnapshotBbox = { ...bbox };
18983
19102
  }
19103
+ /**
19104
+ * The capture decided at `timestamp` did NOT land: restore the gate reference
19105
+ * it displaced so the next frame re-evaluates instead of waiting out an
19106
+ * interval that was never used. Returns whether the slot was given back.
19107
+ *
19108
+ * Refused — deliberately — when the attempt is no longer the one the gate
19109
+ * stands on (a snapshot landed, or a newer frame already marked its own
19110
+ * pending), and once the bounded run of non-landings is spent
19111
+ * ({@link SNAPSHOT_MAX_ROLLBACKS}): a camera whose frames are permanently
19112
+ * unresolvable must not re-decide a snapshot on every frame forever.
19113
+ */
19114
+ rollbackSnapshotPending(trackId, timestamp) {
19115
+ const t = this.active.get(trackId);
19116
+ if (!t) return false;
19117
+ const mark = t.snapshotPending;
19118
+ if (mark?.at !== timestamp) return false;
19119
+ if (!canRollbackSnapshot(mark, t.lastSnapshotAt)) {
19120
+ delete t.snapshotPending;
19121
+ return false;
19122
+ }
19123
+ t.lastSnapshotAt = mark.previous.at;
19124
+ if (mark.previous.bbox) t.lastSnapshotBbox = { ...mark.previous.bbox };
19125
+ else delete t.lastSnapshotBbox;
19126
+ t.snapshotRollbacks = mark.priorRollbacks + 1;
19127
+ delete t.snapshotPending;
19128
+ return true;
19129
+ }
18984
19130
  lastSnapshotAt(trackId) {
18985
19131
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
18986
19132
  }
@@ -21101,6 +21247,35 @@ function createNativeFrameTransport(deps) {
21101
21247
  };
21102
21248
  };
21103
21249
  const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
21250
+ const getFullFrameJpegBase64 = async (handle, maxWidth) => {
21251
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
21252
+ try {
21253
+ const full = await pipelineRunnerApi.getNativeCrop.query({
21254
+ handle,
21255
+ bbox: {
21256
+ x: 0,
21257
+ y: 0,
21258
+ w: 1,
21259
+ h: 1
21260
+ },
21261
+ maxWidth,
21262
+ encodeJpeg: true
21263
+ }, require_dist.nodePin(handle.nodeId));
21264
+ if (!full?.jpeg || full.width <= 0 || full.height <= 0) return null;
21265
+ return {
21266
+ base64: full.jpeg,
21267
+ width: full.width,
21268
+ height: full.height,
21269
+ tier: full.tier === "ram-fullframe" ? "ram-fullframe" : "native"
21270
+ };
21271
+ } catch (err) {
21272
+ logger.debug("full-frame jpeg fetch failed", { meta: {
21273
+ nodeId: handle.nodeId,
21274
+ error: require_dist.errMsg(err)
21275
+ } });
21276
+ return null;
21277
+ }
21278
+ };
21104
21279
  const cropMetricLogger = logger.child("NativeCrop");
21105
21280
  let nativeHits = 0;
21106
21281
  let nativeFallbacks = 0;
@@ -21151,6 +21326,7 @@ function createNativeFrameTransport(deps) {
21151
21326
  getRemoteFrame,
21152
21327
  getNativeFullFrameRgb,
21153
21328
  getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
21329
+ getFullFrameJpegBase64,
21154
21330
  getNativeCropJpeg,
21155
21331
  captureCrop: createCaptureCrop({
21156
21332
  tryNativeCrop,
@@ -21515,23 +21691,47 @@ function wireSubscriptions(deps) {
21515
21691
  * `composeEventKinds` builds the `listEventKinds` payload for a camera:
21516
21692
  * (a) built-ins — motion + audio, always present;
21517
21693
  * (b) detection classes actually OBSERVED on the device (track history);
21518
- * (c) sensor kinds contributed by LINKED devices (device-manager
21519
- * `getLinkedDevices`), one descriptor per bound sensor cap present in
21520
- * the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
21521
- * (`getBindings`) — never a global cap enumeration (D12).
21694
+ * (c) sensor kinds contributed by the camera's OWN bindings and by its
21695
+ * LINKED devices (device-manager `getLinkedDevices`), one descriptor per
21696
+ * bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
21697
+ * Binding-driven per device (`getBindings`) — never a global cap
21698
+ * enumeration (D12).
21522
21699
  *
21523
21700
  * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
21524
- * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
21525
- * cheap at bus rate.
21701
+ * linked camera ids, plus the camera id set) with a TTL, so the
21702
+ * `DeviceStateChanged` handler stays cheap at bus rate.
21526
21703
  */
21527
21704
  /**
21705
+ * Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
21706
+ *
21707
+ * A sensor cap bound on a camera makes the camera both the source and the
21708
+ * subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
21709
+ * camera-side button. Those have no other route into the per-camera event
21710
+ * substrate, so the camera includes itself.
21711
+ *
21712
+ * `motion` is the exception, and today the ONLY one: camera motion already
21713
+ * has a dedicated camera-native path (`MotionAnalysis` /
21714
+ * `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
21715
+ * taxonomy lane). Every real camera provider registers a native `motion` cap
21716
+ * on the camera device, so self-inclusion would mint a SECOND, differently
21717
+ * named copy (`motion-sensor`) of every motion transition, at motion rate.
21718
+ *
21719
+ * Add a cap here only when the camera already emits that event through
21720
+ * another ingest path — never to reduce noise.
21721
+ */
21722
+ var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
21723
+ /** True when a camera may contribute `capName` for itself. */
21724
+ function selfCameraMayEmit(capName) {
21725
+ return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
21726
+ }
21727
+ /**
21528
21728
  * Full event-kind taxonomy a camera CAN emit (config-derived):
21529
21729
  * (a) motion built-in — always;
21530
21730
  * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
21531
21731
  * (c) audio macro + its subs when the classifier is enabled;
21532
21732
  * (d) package kinds when a package zone rule is enabled;
21533
- * (e) sensor/control kinds from LINKED devices (binding-driven), deduped
21534
- * per (kind, source deviceId).
21733
+ * (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
21734
+ * LINKED devices (binding-driven), deduped per (kind, source deviceId).
21535
21735
  * Every descriptor carries parentKind/level/iconId/labelKey via the single
21536
21736
  * taxonomy dictionary — no color/icon is declared here.
21537
21737
  */
@@ -21566,6 +21766,7 @@ async function composeEventKinds(deps, deviceId) {
21566
21766
  const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
21567
21767
  const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
21568
21768
  const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
21769
+ const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
21569
21770
  pushKind("motion");
21570
21771
  for (const macro of await macrosP ?? []) {
21571
21772
  pushKind(macro);
@@ -21582,26 +21783,30 @@ async function composeEventKinds(deps, deviceId) {
21582
21783
  const devices = (await linkedP)?.devices ?? [];
21583
21784
  const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
21584
21785
  const seen = /* @__PURE__ */ new Set();
21585
- for (const [index, linked] of devices.entries()) {
21586
- const entries = bindingsPerDevice[index]?.entries;
21587
- if (entries === void 0) continue;
21786
+ const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
21787
+ if (entries === void 0) return;
21588
21788
  for (const { capName } of entries) {
21789
+ if (isSelf && !selfCameraMayEmit(capName)) continue;
21589
21790
  const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
21590
21791
  if (descriptor === void 0) continue;
21591
- const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
21792
+ const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
21592
21793
  if (seen.has(dedupeKey)) continue;
21593
21794
  seen.add(dedupeKey);
21594
21795
  pushKind(descriptor.kind, {
21595
21796
  capName,
21596
- deviceId: linked.deviceId
21797
+ deviceId: sourceDeviceId
21597
21798
  });
21598
21799
  }
21599
- }
21800
+ };
21801
+ pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
21802
+ for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
21600
21803
  return out;
21601
21804
  }
21602
21805
  var DEFAULT_CACHE_TTL_MS = 6e4;
21603
21806
  /**
21604
- * TTL-cached reverse index: source deviceId → camera ids it is linked to.
21807
+ * TTL-cached reverse index: source deviceId → camera ids it is linked to,
21808
+ * plus the camera id SET the index was built from (so the ingest can ask
21809
+ * "is this device itself a camera" without a second enumeration).
21605
21810
  * Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
21606
21811
  * handler pays one map lookup per event in the common case.
21607
21812
  */
@@ -21609,6 +21814,8 @@ var LinkedCamerasCache = class {
21609
21814
  deps;
21610
21815
  ttlMs;
21611
21816
  index = /* @__PURE__ */ new Map();
21817
+ /** Every camera id seen by the last successful build. */
21818
+ cameras = /* @__PURE__ */ new Set();
21612
21819
  /** Ms timestamp of the last build; null = never built / invalidated. */
21613
21820
  builtAt = null;
21614
21821
  building = null;
@@ -21618,23 +21825,37 @@ var LinkedCamerasCache = class {
21618
21825
  }
21619
21826
  /** Camera ids linked to `sourceDeviceId` ([] when none). */
21620
21827
  async camerasFor(sourceDeviceId, nowMs = Date.now()) {
21621
- if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
21622
- this.building ??= this.rebuild(nowMs).finally(() => {
21623
- this.building = null;
21624
- });
21625
- await this.building;
21626
- }
21828
+ await this.ensureFresh(nowMs);
21627
21829
  return this.index.get(sourceDeviceId) ?? [];
21628
21830
  }
21831
+ /**
21832
+ * True when `deviceId` is ITSELF a camera. Answered off the same build as
21833
+ * {@link camerasFor} — a sensor cap bound on a camera needs both facts in
21834
+ * the same event, and a second enumeration would double the cost of the
21835
+ * ingest path for nothing.
21836
+ */
21837
+ async isCamera(deviceId, nowMs = Date.now()) {
21838
+ await this.ensureFresh(nowMs);
21839
+ return this.cameras.has(deviceId);
21840
+ }
21629
21841
  /** Drop the cached index (e.g. on link-topology change events). */
21630
21842
  invalidate() {
21631
21843
  this.builtAt = null;
21632
21844
  }
21633
21845
  /** Test/maintenance hook: replace the index directly. */
21634
- seed(index, builtAt) {
21846
+ seed(index, builtAt, cameraIds = []) {
21635
21847
  this.index = new Map(index);
21848
+ this.cameras = new Set(cameraIds);
21636
21849
  this.builtAt = builtAt;
21637
21850
  }
21851
+ /** Rebuild (single-flight) when never built or past the TTL. */
21852
+ async ensureFresh(nowMs) {
21853
+ if (this.builtAt !== null && nowMs - this.builtAt < this.ttlMs) return;
21854
+ this.building ??= this.rebuild(nowMs).finally(() => {
21855
+ this.building = null;
21856
+ });
21857
+ await this.building;
21858
+ }
21638
21859
  async rebuild(nowMs) {
21639
21860
  try {
21640
21861
  const cameraIds = await this.deps.cameras.listCameraIds();
@@ -21651,6 +21872,7 @@ var LinkedCamerasCache = class {
21651
21872
  this.deps.onError?.("getLinkedDevices", err);
21652
21873
  }
21653
21874
  this.index = next;
21875
+ this.cameras = new Set(cameraIds);
21654
21876
  this.builtAt = nowMs;
21655
21877
  } catch (err) {
21656
21878
  this.deps.onError?.("listCameraIds", err);
@@ -21659,16 +21881,25 @@ var LinkedCamerasCache = class {
21659
21881
  }
21660
21882
  };
21661
21883
  /**
21662
- * One `DeviceStateChanged` → N history rows (one per linked camera). The
21663
- * EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
21664
- * lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
21884
+ * One `DeviceStateChanged` → N history rows, one per camera the event belongs
21885
+ * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
21886
+ * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
21887
+ * camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
21888
+ * self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
21889
+ * churn costs one map lookup. Returns the number of rows inserted (0 when
21890
+ * unmapped, or when the device belongs to no camera at all).
21665
21891
  * Telemetry-lossy by design (D8) — inserts are best-effort.
21666
21892
  */
21667
21893
  async function ingestSensorStateChange(deps, data, timestamp) {
21668
21894
  const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
21669
21895
  if (descriptor === void 0) return 0;
21670
- const cameraIds = await deps.cache.camerasFor(data.deviceId);
21671
- if (cameraIds.length === 0) return 0;
21896
+ const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
21897
+ const isCamera = await deps.cache.isCamera(data.deviceId);
21898
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
21899
+ if (cameraIds.length === 0) {
21900
+ deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
21901
+ return 0;
21902
+ }
21672
21903
  const slice = data.slice;
21673
21904
  const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
21674
21905
  const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
@@ -21804,6 +22035,8 @@ var SNAPSHOT_MAX_WIDTH = 960;
21804
22035
  /** Bound the once-per-owner display-fallback memo so a long-lived process never
21805
22036
  * leaks it. */
21806
22037
  var MAX_LOGGED_DISPLAY_FALLBACKS = 5e3;
22038
+ /** One `pinned-frame resolve missed` line per device per minute. */
22039
+ var PINNED_MISS_WARN_THROTTLE_MS = 6e4;
21807
22040
  /**
21808
22041
  * Map a detection-frame pixel box (`fromW`×`fromH`, the ≤640 raster the tracker
21809
22042
  * ran on) onto the native full frame (`toW`×`toH`, the 960-downscaled native
@@ -21889,6 +22122,8 @@ var EventMediaDispatcher = class {
21889
22122
  /** Once-per-owner (`${eventId}:${kind}`) memo for the DISPLAY-crop fallback
21890
22123
  * info log — see {@link logDisplayFallbackOnce}. */
21891
22124
  loggedDisplayFallbacks = /* @__PURE__ */ new Set();
22125
+ /** Per-device throttle for {@link warnPinnedFrameMiss}. */
22126
+ lastPinnedMissWarnAt = /* @__PURE__ */ new Map();
21892
22127
  constructor(deps) {
21893
22128
  this.deps = deps;
21894
22129
  this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
@@ -21902,30 +22137,48 @@ var EventMediaDispatcher = class {
21902
22137
  * by which time the handle's ring slot is usually recycled and the by-handle
21903
22138
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
21904
22139
  * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
21905
- * lane. Returns `null` on a genuine live-window miss (logged at debug — the
21906
- * caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
21907
- * WARN-logged on failure, remains the fallback).
22140
+ * lane. Returns `null` on a genuine live-window miss.
22141
+ *
22142
+ * Both miss paths are WARN, throttled to one line per device per minute. They
22143
+ * are the FIRST half of a pair whose second half (`capture batch lost`) has
22144
+ * always been a warn: a batch that lost its frame is reported with
22145
+ * `pinned: false`, and until 2026-08-07 the reason the pin itself failed was
22146
+ * either at debug (the throw) or nowhere at all (the null return). 383 batch
22147
+ * losses in 3 h had `pinned: false` on 100% of them and nothing said why.
21908
22148
  */
21909
22149
  async resolvePinnedFrame(deviceId, frameHandle) {
21910
22150
  try {
21911
22151
  const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
21912
- if (!decoded) return null;
22152
+ if (!decoded) {
22153
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "recycled-before-pin");
22154
+ return null;
22155
+ }
21913
22156
  return {
21914
22157
  ...decoded,
21915
22158
  data: Buffer.from(decoded.data)
21916
22159
  };
21917
22160
  } catch (err) {
21918
- this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
21919
- tags: { deviceId },
21920
- meta: {
21921
- deviceId,
21922
- shmId: frameHandle.shmId,
21923
- error: String(err)
21924
- }
21925
- });
22161
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "threw", String(err));
21926
22162
  return null;
21927
22163
  }
21928
22164
  }
22165
+ /** One `pinned-frame resolve missed` line per device per minute. Throttled
22166
+ * because it fires per capture batch, and the batch's own loss line is not. */
22167
+ warnPinnedFrameMiss(deviceId, frameHandle, reason, error) {
22168
+ const now = Date.now();
22169
+ if (now - (this.lastPinnedMissWarnAt.get(deviceId) ?? 0) < PINNED_MISS_WARN_THROTTLE_MS) return;
22170
+ this.lastPinnedMissWarnAt.set(deviceId, now);
22171
+ this.deps.logger.warn("event media: pinned-frame resolve missed (live window)", {
22172
+ tags: { deviceId },
22173
+ meta: {
22174
+ reason,
22175
+ shmId: frameHandle.shmId,
22176
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
22177
+ handleNodeId: frameHandle.nodeId,
22178
+ ...error !== void 0 ? { error } : {}
22179
+ }
22180
+ });
22181
+ }
21929
22182
  /**
21930
22183
  * WARN-log a whole-batch capture abort: every early return in
21931
22184
  * {@link captureForFrame} loses ALL of the frame's targets at once —
@@ -22778,13 +23031,19 @@ var SyntheticTrackMaterializer = class {
22778
23031
  * Fetch the linked camera's snapshot under a bounded timeout. A hanging
22779
23032
  * snapshot cap is skipped (returns null + warns) so the track still lands;
22780
23033
  * a null / failed fetch is likewise reported and skipped.
23034
+ *
23035
+ * Deliberately NOT `force`. `snapshot.cap.ts` defines `force` as the
23036
+ * operator's refresh button — *"On battery cams this WILL wake the camera —
23037
+ * accept the cost only when the user explicitly asks for it"* — and a
23038
+ * synthetic track is materialised from an EVENT, with nobody asking. Forcing
23039
+ * here walked straight past the snapshot addon's sleep gate and woke a
23040
+ * sleeping battery camera per event (roadmap 4f). Without it the cache
23041
+ * answers: ≤10 s old on a mains camera, and on a battery camera the
23042
+ * intentionally stale-but-honest frame that costs no wake.
22781
23043
  */
22782
23044
  async fetchSnapshot(cameraId) {
22783
23045
  try {
22784
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({
22785
- deviceId: cameraId,
22786
- force: true
22787
- }), this.snapshotTimeoutMs);
23046
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
22788
23047
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
22789
23048
  return snap;
22790
23049
  } catch (err) {
@@ -25766,7 +26025,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25766
26025
  logger: logger.child("EventMediaDispatcher")
25767
26026
  });
25768
26027
  this.buildRecognizers(api, logger, stores, transport);
25769
- this.buildDetailDispatcher(api, logger);
26028
+ this.buildDetailDispatcher(api, logger, transport);
25770
26029
  const capProviders = this.buildAnalyticsProviders(api, logger);
25771
26030
  await this.buildNotificationCenter(api, logger, stores);
25772
26031
  await this.serveEventMediaDataPlane();
@@ -25949,7 +26208,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25949
26208
  let storage = this.ctx.kernel.storage;
25950
26209
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
25951
26210
  if (mediaRoot) {
25952
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-XPzbKGXi.js"));
26211
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CdY4IvEy.js"));
25953
26212
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
25954
26213
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
25955
26214
  }
@@ -26214,7 +26473,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26214
26473
  * demand via `pipelineRunner.runDetailSubtree`. The dispatcher owns the
26215
26474
  * per-device schedulers, concurrency cap, and 1 s tick; result routing +
26216
26475
  * the pinned call + crop fallback are injected here. */
26217
- buildDetailDispatcher(api, logger) {
26476
+ buildDetailDispatcher(api, logger, transport) {
26218
26477
  const runnerApi = api.pipelineRunner;
26219
26478
  this.detailDispatcher = new TrackDetailDispatcher({
26220
26479
  logger: logger.child("DetailDispatcher"),
@@ -26224,6 +26483,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26224
26483
  return runnerApi.runDetailSubtree.mutate(input);
26225
26484
  },
26226
26485
  routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
26486
+ captureFullFrameBase64: async (frame) => {
26487
+ if (frame.frameHandle === void 0) return null;
26488
+ const full = await transport.getFullFrameJpegBase64(frame.frameHandle, frame.frameWidth);
26489
+ return full === null ? null : {
26490
+ base64: full.base64,
26491
+ width: full.width,
26492
+ height: full.height
26493
+ };
26494
+ },
26227
26495
  captureCropBase64: async (frame) => {
26228
26496
  if (frame.frameHandle === void 0 || !this.captureCrop) return null;
26229
26497
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
@@ -26848,12 +27116,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26848
27116
  inPrevActive: false,
26849
27117
  positionsCount: positionsCountById.get(id) ?? 1
26850
27118
  }) === "resurrection") {
26851
- log.info("track resumed", { meta: {
26852
- trackId: id,
26853
- className: t.className,
26854
- source,
26855
- resurrected: true
26856
- } });
27119
+ log.info("track resumed", {
27120
+ tags: { deviceId },
27121
+ meta: {
27122
+ trackId: id,
27123
+ className: t.className,
27124
+ source,
27125
+ resurrected: true
27126
+ }
27127
+ });
26857
27128
  if (this.eventMediaDispatcher) {
26858
27129
  this.residents.setLastFrameAt(deviceId, id, result.timestamp);
26859
27130
  if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
@@ -26988,10 +27259,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26988
27259
  let lostTrackCount = 0;
26989
27260
  for (const id of prevIds) if (!currentTrackIds.has(id)) {
26990
27261
  lostTrackCount += 1;
26991
- log.info("track lost", { meta: {
26992
- trackId: id,
26993
- source
26994
- } });
27262
+ log.info("track lost", {
27263
+ tags: { deviceId },
27264
+ meta: {
27265
+ trackId: id,
27266
+ source
27267
+ }
27268
+ });
26995
27269
  }
26996
27270
  this.suppressedBirths.retain(key, currentTrackIds);
26997
27271
  this.deferredBirths.retain(key, currentTrackIds);
@@ -27228,6 +27502,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
27228
27502
  this.residents.markFirstFrameLanded(deviceId, trackId);
27229
27503
  }
27230
27504
  for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
27505
+ const landedSnapshots = new Set(res.storedSnapshots.map((s) => s.trackId));
27506
+ for (const target of snapshotTargets) {
27507
+ if (!target.appendSnapshot || landedSnapshots.has(target.trackId)) continue;
27508
+ const gaveBack = this.trackStore?.rollbackSnapshotPending(target.trackId, target.timestamp);
27509
+ log.warn("snapshot capture did not land", {
27510
+ tags: { deviceId },
27511
+ meta: {
27512
+ trackId: target.trackId,
27513
+ timestamp: target.timestamp,
27514
+ intervalReturned: gaveBack === true
27515
+ }
27516
+ });
27517
+ }
27231
27518
  })
27232
27519
  }).catch(() => {});
27233
27520
  }
@@ -29026,8 +29313,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29026
29313
  }
29027
29314
  /**
29028
29315
  * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
29029
- * mapped sensor cap. Resolves the linked-camera set through the TTL cache
29030
- * and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
29316
+ * mapped sensor cap. Resolves the camera set through the TTL cache (the
29317
+ * device's LINKED cameras, plus the device itself when it is a camera
29318
+ * carrying the cap) and inserts ONE row per camera. Best-effort
29319
+ * (telemetry-lossy).
29320
+ *
29321
+ * D8/D11 boundary, stated plainly: the ride here is the LOSSY
29322
+ * `DeviceStateChanged` bus, and there is no reconcile that replays a missed
29323
+ * press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
29324
+ * event is a missing history row and a missing notification — never wrong
29325
+ * durable state.
29031
29326
  */
29032
29327
  async handleSensorStateChanged(data, timestamp) {
29033
29328
  const store = this.sensorEventStore;
@@ -29037,7 +29332,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29037
29332
  await ingestSensorStateChange({
29038
29333
  sink: store,
29039
29334
  cache,
29040
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
29335
+ onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
29336
+ onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
29041
29337
  }, data, timestamp);
29042
29338
  } catch (err) {
29043
29339
  this.ctx.logger.warn("sensor-event ingest failed", {
@@ -29053,7 +29349,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29053
29349
  if (materializer === null || descriptor === void 0) return;
29054
29350
  try {
29055
29351
  const cameraIds = await cache.camerasFor(data.deviceId);
29056
- if (cameraIds.length === 0) return;
29352
+ if (cameraIds.length === 0) {
29353
+ this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
29354
+ tags: { deviceId: data.deviceId },
29355
+ meta: {
29356
+ capName: data.capName,
29357
+ kind: descriptor.kind
29358
+ }
29359
+ });
29360
+ return;
29361
+ }
29057
29362
  const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
29058
29363
  for (const cameraId of cameraIds) await materializer.materialize({
29059
29364
  cameraId,
@@ -29074,6 +29379,35 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29074
29379
  }
29075
29380
  }
29076
29381
  /**
29382
+ * Say out loud that a MAPPED sensor cap ingested nothing, and why.
29383
+ *
29384
+ * BOTH branches are `debug`, deliberately. After the self-camera fix neither
29385
+ * is a surprise any more — they are the two designed steady states, and both
29386
+ * ride at bus rate:
29387
+ *
29388
+ * - `camera-native-cap` is EVERY camera's `motion` slice. Every provider
29389
+ * binds a native `motion` cap on the camera device and motion churns
29390
+ * continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
29391
+ * log flood measured in millions of lines a day — its own outage.
29392
+ * - `no-camera` is every sensor in the fleet that no camera links.
29393
+ *
29394
+ * What must never happen again is the branch being INVISIBLE: a doorbell
29395
+ * press that ingested nothing produced not one line for months. A debug line
29396
+ * carrying `deviceId`, `capName`, `kind` and `reason` answers "why is 615
29397
+ * dark" the moment anyone asks it.
29398
+ */
29399
+ logSensorIngestDrop(data, reason, kind) {
29400
+ const message = reason === "camera-native-cap" ? "sensor-event ingest produced NO row — the camera already emits this cap through its own path" : "sensor-event ingest produced NO row — the device is not a camera and is linked to none";
29401
+ this.ctx.logger.debug(message, {
29402
+ tags: { deviceId: data.deviceId },
29403
+ meta: {
29404
+ capName: data.capName,
29405
+ kind,
29406
+ reason
29407
+ }
29408
+ });
29409
+ }
29410
+ /**
29077
29411
  * Resolve the NAME of a device (the linked sensor/control that produced a
29078
29412
  * synthetic event) via the device-manager cap. Best-effort — returns
29079
29413
  * undefined on any lookup failure or an unknown device so the synthetic