@camstack/addon-post-analysis 1.2.48 → 1.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as object, B as errMsg, C as buildEventKindDescriptor, E as defineCustomActions, F as readDeviceStateFrom, G as isDeviceScopedCap, H as DeviceType, I as subKindsOf, J as _enum, K as nodePin, L as vectorDimFromBase64, M as notificationRulesCapability, N as pipelineAnalyticsCapability, O as encodeVectorBase64, P as plateGalleryCapability, Q as number, R as videoclipsCapability, S as audioMetricsCapability, T as customAction, U as createEvent, V as BaseAddon, W as hydrateSchema, X as boolean, Y as array, Z as literal, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as addonWidgetsSourceCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as record, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as kebabToCamel, k as faceGalleryCapability, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, nt as unknown, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as sleep, r as EVENT_KIND_BY_CAP, rt as EventCategory, s as NC_TAXONOMY, t as BaseDevice, tt as string, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as cosineSimilarity, x as alarmPanelCapability, y as TrackSourceSchema, z as zoneAnalyticsCapability } from "../dist-4NhN1aPx.mjs";
1
+ import { $ as literal, A as faceGalleryCapability, B as videoclipsCapability, C as audioMetricsCapability, D as defineCustomActions, E as customAction, F as pipelineAnalyticsCapability, G as createEvent, H as errMsg, I as plateGalleryCapability, J as nodePin, K as hydrateSchema, L as readDeviceStateFrom, M as isScheduleActive, N as kebabToCamel, P as notificationRulesCapability, Q as boolean, R as subKindsOf, S as alarmPanelCapability, T as cosineSimilarity, U as BaseAddon, V as zoneAnalyticsCapability, W as DeviceType, X as _enum, Y as sleep, Z as array, _ as RetrainStatusSchema, a as MACRO_LABELS, at as EventCategory, b as TrackSourceSchema, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as number, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, it as unknown, k as encodeVectorBase64, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, nt as record, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as isDeviceScopedCap, r as EVENT_KIND_BY_CAP, rt as string, s as NC_TAXONOMY, t as BaseDevice, tt as object, u as NcRulePatchSchema, v as TimelapseRuleInputSchema, w as buildEventKindDescriptor, x as addonWidgetsSourceCapability, y as TimelapseRuleSchema, z as vectorDimFromBase64 } from "../dist-BhadCxSQ.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -1583,66 +1583,6 @@ function evaluateRule(rule, subject, deviceState) {
1583
1583
  matchedOn: presentConditionIds(c)
1584
1584
  };
1585
1585
  }
1586
- var WEEKDAY_TO_DAY = {
1587
- Sun: 0,
1588
- Mon: 1,
1589
- Tue: 2,
1590
- Wed: 3,
1591
- Thu: 4,
1592
- Fri: 5,
1593
- Sat: 6
1594
- };
1595
- /** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
1596
- * An invalid/unknown IANA name falls back to the host timezone. */
1597
- function localDayMinute(atMs, timezone) {
1598
- const d = new Date(atMs);
1599
- if (timezone !== void 0) try {
1600
- const parts = new Intl.DateTimeFormat("en-US", {
1601
- timeZone: timezone,
1602
- weekday: "short",
1603
- hour: "numeric",
1604
- minute: "numeric",
1605
- hourCycle: "h23"
1606
- }).formatToParts(d);
1607
- let weekday;
1608
- let hour;
1609
- let minute;
1610
- for (const p of parts) if (p.type === "weekday") weekday = p.value;
1611
- else if (p.type === "hour") hour = Number(p.value);
1612
- else if (p.type === "minute") minute = Number(p.value);
1613
- const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
1614
- if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
1615
- day,
1616
- minute: hour * 60 + minute
1617
- };
1618
- } catch {}
1619
- return {
1620
- day: d.getDay(),
1621
- minute: d.getHours() * 60 + d.getMinutes()
1622
- };
1623
- }
1624
- /**
1625
- * Is the schedule active at `atMs`? No schedule = always active. Windows
1626
- * are OR'd; a window with `startMinute > endMinute` crosses midnight (it
1627
- * starts on a listed day and spills into the next). `invert` flips the
1628
- * result (active OUTSIDE the windows).
1629
- */
1630
- function isScheduleActive(schedule, atMs) {
1631
- if (schedule === void 0) return true;
1632
- const { day, minute } = localDayMinute(atMs, schedule.timezone);
1633
- const prevDay = (day + 6) % 7;
1634
- let inside = false;
1635
- for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
1636
- if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
1637
- inside = true;
1638
- break;
1639
- }
1640
- } else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
1641
- inside = true;
1642
- break;
1643
- }
1644
- return schedule.invert === true ? !inside : inside;
1645
- }
1646
1586
  /** Normalize a plate string: uppercase, alphanumeric only. */
1647
1587
  function normalizePlate$1(value) {
1648
1588
  return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
@@ -6806,6 +6746,34 @@ function isEdgeAwareNewBest(current, candidate, hysteresis) {
6806
6746
  //#endregion
6807
6747
  //#region src/pipeline-analytics/pipeline/label-utils.ts
6808
6748
  /**
6749
+ * Every macro class the pipeline can carry on a detection. A `label` exists to
6750
+ * say something FINER than the class — a species, a vehicle type, a plate read,
6751
+ * an identity — so a value drawn from this set is by definition not a label,
6752
+ * whatever produced it.
6753
+ *
6754
+ * Typed against the macro unions so a new macro class is a compile error here
6755
+ * rather than a silent hole. `package` is a first-level macro of the default
6756
+ * detector that predates `FirstLevelMacro`, hence the explicit member.
6757
+ */
6758
+ var MACRO_CLASSES = new Set([
6759
+ "person",
6760
+ "vehicle",
6761
+ "animal",
6762
+ "package",
6763
+ "face",
6764
+ "plate",
6765
+ "audio"
6766
+ ]);
6767
+ /**
6768
+ * Whether a class token is a macro class. EXACT match, no case folding: plate
6769
+ * OCR text is upper-case and identities are capitalised, so `PLATE` (a real
6770
+ * read) and `Person` (a real name) must survive while `plate` / `person` (the
6771
+ * taxonomy tokens) must not.
6772
+ */
6773
+ function isMacroClass(value) {
6774
+ return MACRO_CLASSES.has(value);
6775
+ }
6776
+ /**
6809
6777
  * Opaque internal label markers that carry no human-readable meaning and must
6810
6778
  * never surface on an event/track. The face-embedding (arcface) step pushes a
6811
6779
  * `embedding:<dim>d` marker (score 1.0) onto the parent's labels — it is a
@@ -6836,12 +6804,26 @@ function pickTopLabel(labels, excludeClass) {
6836
6804
  /**
6837
6805
  * Resolve the label to stamp on an ObjectEvent / track: an enrichment label
6838
6806
  * (identity, plate text) takes precedence; otherwise the raw detector class
6839
- * (`originalClass`) when it differs from the macro class; otherwise nothing.
6807
+ * (`originalClass`) when it is genuinely FINER than the macro class; otherwise
6808
+ * nothing.
6809
+ *
6810
+ * "Finer" is load-bearing, and `originalClass !== className` alone does not
6811
+ * establish it. The two fields come from different time bases — `className` is
6812
+ * the tracker's confidence-weighted LIFETIME class vote, `originalClass` is the
6813
+ * class of the detection matched into the track THIS frame — and outside
6814
+ * `verbosity === 'full'` the runner ships no `debug.originalClass` at all, so
6815
+ * the FrameProcessor substitutes `det.macroClass`. A person track that a single
6816
+ * animal detection matched into therefore arrived here as
6817
+ * `{ className: 'person', originalClass: 'animal' }` and got `label: 'animal'`
6818
+ * written on it: a COARSE class in the slot reserved for finer information.
6819
+ * A macro class is never an answer, so it is never a label.
6840
6820
  */
6841
6821
  function resolveDetectionLabel(input) {
6842
6822
  const top = pickTopLabel(input.labels, input.className);
6843
6823
  if (top) return top;
6844
- return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
6824
+ const raw = input.originalClass;
6825
+ if (raw === void 0 || raw === input.className || isMacroClass(raw)) return void 0;
6826
+ return raw;
6845
6827
  }
6846
6828
  var DEFAULT_ZONE_TRANSITION_CONFIG = {
6847
6829
  enterFrames: 2,
@@ -11834,9 +11816,24 @@ var TrackDetailDispatcher = class {
11834
11816
  dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
11835
11817
  }
11836
11818
  /**
11837
- * Run the request once via the frameHandle, and — on a miss (null OR throw)
11838
- * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
11839
- * the detail list, or `null` when both attempts fail to produce a result.
11819
+ * Walk the crop-source ladder for one request, stopping at the first rung that
11820
+ * produces a result. Returns the detail list, or `null` when every rung failed.
11821
+ *
11822
+ * 1. `frameHandle` — cheapest when it hits: no payload moves and the runner's
11823
+ * leaf children re-cut their ROI at native resolution. Tried first, always.
11824
+ * 2. `frameJpeg` — the freshest frame's FULL raster, fetched from the node that
11825
+ * owns the handle and handed to the runner UNCUT, so the rectangle still
11826
+ * comes from the single derivation (D52). This rung exists because rung 1 is
11827
+ * structurally unreachable for most dispatches: the decode worker's native
11828
+ * lease is 1.2 s and a tick + inference routinely lands outside it, while
11829
+ * the retained store holds the frame for 3 s.
11830
+ * 3. `cropJpeg` — the pre-cut tile. Last, because the caller decided its
11831
+ * rectangle and because in practice the same native miss that emptied rung 1
11832
+ * empties this one too.
11833
+ *
11834
+ * On D9/D18: the payload that crosses in rung 2 is a COMPRESSED JPEG fetched on
11835
+ * demand, per TRACK per dispatch (≤2 in flight per device, ~1 Hz) and only after
11836
+ * rung 1 missed — never a frame-rate stream, and never raw pixels.
11840
11837
  */
11841
11838
  async runOnce(deviceId, dev, req, frame) {
11842
11839
  const parent = {
@@ -11853,7 +11850,39 @@ var TrackDetailDispatcher = class {
11853
11850
  }, frame.nodeId);
11854
11851
  if (primary !== null) return primary.details;
11855
11852
  } catch (err) {
11856
- this.deps.logger.debug("detail primary call failed — trying crop fallback", {
11853
+ this.deps.logger.debug("detail primary call failed — trying frame fallback", {
11854
+ tags: { deviceId },
11855
+ meta: {
11856
+ trackId: req.trackId,
11857
+ stepId: req.stepId,
11858
+ error: String(err)
11859
+ }
11860
+ });
11861
+ }
11862
+ if (this.deps.captureFullFrameBase64 !== void 0 && frame.frameHandle !== void 0) try {
11863
+ const full = await this.deps.captureFullFrameBase64(frame);
11864
+ 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", {
11865
+ tags: { deviceId },
11866
+ meta: {
11867
+ trackId: req.trackId,
11868
+ stepId: req.stepId,
11869
+ frameWidth: full.width,
11870
+ frameHeight: full.height,
11871
+ bboxFrameWidth: frame.frameWidth,
11872
+ bboxFrameHeight: frame.frameHeight
11873
+ }
11874
+ });
11875
+ else {
11876
+ const derived = await this.deps.runDetailSubtree({
11877
+ deviceId,
11878
+ frameJpeg: full.base64,
11879
+ parent,
11880
+ steps
11881
+ }, frame.nodeId);
11882
+ if (derived !== null) return derived.details;
11883
+ }
11884
+ } catch (err) {
11885
+ this.deps.logger.debug("detail full-frame fallback call failed", {
11857
11886
  tags: { deviceId },
11858
11887
  meta: {
11859
11888
  trackId: req.trackId,
@@ -11890,12 +11919,14 @@ var TrackDetailDispatcher = class {
11890
11919
  const now = Date.now();
11891
11920
  if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
11892
11921
  dev.lastFailWarnAt = now;
11893
- this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
11922
+ this.deps.logger.warn("runDetailSubtree produced no result (every crop source missed)", {
11894
11923
  tags: { deviceId },
11895
11924
  meta: {
11896
11925
  trackId: req.trackId,
11897
11926
  stepId: req.stepId,
11898
- reason: req.reason
11927
+ reason: req.reason,
11928
+ triedFullFrame: this.deps.captureFullFrameBase64 !== void 0,
11929
+ triedCropJpeg: this.deps.captureCropBase64 !== void 0
11899
11930
  }
11900
11931
  });
11901
11932
  }
@@ -13418,6 +13449,27 @@ var StoragePressureTracker = class {
13418
13449
  };
13419
13450
  //#endregion
13420
13451
  //#region src/pipeline-analytics/store/media-store.ts
13452
+ var OWNER_KINDS = [
13453
+ "event",
13454
+ "track",
13455
+ "face",
13456
+ "identity",
13457
+ "plate",
13458
+ "vehicle"
13459
+ ];
13460
+ /**
13461
+ * Narrow an arbitrary string to an `OwnerKind`.
13462
+ *
13463
+ * Exists so a caller that builds owner scopes from data (the training-export
13464
+ * plan walks `track` / `face` / `plate` / `event` as strings) can reach the
13465
+ * store without a cast. The list is derived from `OWNER_KINDS`, so a new owner
13466
+ * kind is admitted here the moment it is declared there — a hand-written value
13467
+ * list would silently reject it, which is the failure `TrackSourceSchema` was
13468
+ * already bitten by on the track row.
13469
+ */
13470
+ function isMediaOwnerKind(value) {
13471
+ return OWNER_KINDS.some((kind) => kind === value);
13472
+ }
13421
13473
  var MEDIA_COLLECTION = "pipeline-analytics:media";
13422
13474
  /** Owner-id prefix for a track's buffered FACE crop. Invariant established by
13423
13475
  * the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
@@ -14641,6 +14693,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
14641
14693
  bestEventId: bestEventId ?? t.trackId,
14642
14694
  windowMs: t.lastSeen - t.firstSeen,
14643
14695
  ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
14696
+ ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
14644
14697
  ...t.debug !== void 0 ? { debug: t.debug } : {}
14645
14698
  });
14646
14699
  }
@@ -15876,8 +15929,35 @@ function classifyTrackAppearance(input) {
15876
15929
  if (input.inPrevActive) return "continuing";
15877
15930
  return input.positionsCount > 1 ? "resurrection" : "birth";
15878
15931
  }
15879
- //#endregion
15880
- //#region src/pipeline-analytics/pipeline/track-flags.ts
15932
+ /** Thrown when the device is already holding its full staging budget. Distinct
15933
+ * type so a surface can tell "you are out of room" from "that track is gone". */
15934
+ var StagingBudgetExceededError = class extends Error {
15935
+ deviceId;
15936
+ staging;
15937
+ constructor(deviceId, staging) {
15938
+ super(`setTrackFlags: device ${deviceId} already has ${staging} tracks in retrain staging (limit 500). Un-mark some tracks before marking more.`);
15939
+ this.deviceId = deviceId;
15940
+ this.staging = staging;
15941
+ this.name = "StagingBudgetExceededError";
15942
+ }
15943
+ };
15944
+ /**
15945
+ * Thrown when a `markForTrain` write — in EITHER direction — targets a track
15946
+ * that has already been trained.
15947
+ *
15948
+ * Its own error type, next to the budget's, because the two refusals need
15949
+ * different sentences on the surface: one says "make room", the other says "this
15950
+ * track is already in the dataset". Collapsing them into a generic failure is
15951
+ * how a toggle ends up flipping back with nothing to explain it.
15952
+ */
15953
+ var TrackAlreadyTrainedError = class extends Error {
15954
+ trackId;
15955
+ constructor(trackId) {
15956
+ super(`setTrackFlags: track ${trackId} is already trained — its frames are copies in the retrain dataset. Re-staging it is an action of the retrain page, not this toggle.`);
15957
+ this.trackId = trackId;
15958
+ this.name = "TrackAlreadyTrainedError";
15959
+ }
15960
+ };
15881
15961
  /**
15882
15962
  * Apply a partial flag patch and report the RESOLVED state of both flags.
15883
15963
  *
@@ -15890,19 +15970,63 @@ function classifyTrackAppearance(input) {
15890
15970
  * An empty patch is a pure read, not an error: the three calling surfaces build
15891
15971
  * the patch from their own toggle, and "nothing changed" is a legitimate outcome
15892
15972
  * that must not surface as a failure.
15893
- */
15894
- async function applyTrackFlags(store, input) {
15973
+ *
15974
+ * **`trained` is TERMINAL for this toggle, in both directions.** A trained
15975
+ * track's chosen frames are already copies inside the retrain dataset; staging
15976
+ * it again from a generic checkbox is how the same material gets annotated twice
15977
+ * under two different ground truths, and un-marking it would claim to undo a
15978
+ * training pass it cannot undo. The deliberate return to staging belongs to the
15979
+ * retrain page, which is also the only thing that produces `trained`. A `debug`
15980
+ * patch on a trained track is untouched by this — `debug` is not the lifecycle.
15981
+ *
15982
+ * **The budget is checked only when STAGING is being entered.** Un-marking is
15983
+ * always allowed and so is a `debug` patch — a limit that blocked the way OUT of
15984
+ * the limit would be a trap, and a device at its ceiling would become unable to
15985
+ * release the very storage the ceiling exists to protect. Re-marking a track
15986
+ * that is already staging is not a new hold either, so it does not consume
15987
+ * budget and cannot be refused by it.
15988
+ */
15989
+ async function applyTrackFlags(deps, input) {
15990
+ const { store, logger } = deps;
15895
15991
  const current = await store.readFlags(input.trackId);
15896
15992
  if (current === null) throw new Error(`setTrackFlags: unknown track ${input.trackId}`);
15993
+ if (input.flags.markForTrain !== void 0 && current.retrainStatus === "trained") {
15994
+ logger.warn("setTrackFlags refused — track already trained", {
15995
+ tags: { deviceId: input.deviceId },
15996
+ meta: {
15997
+ deviceId: input.deviceId,
15998
+ trackId: input.trackId,
15999
+ requested: input.flags.markForTrain
16000
+ }
16001
+ });
16002
+ throw new TrackAlreadyTrainedError(input.trackId);
16003
+ }
16004
+ if (input.flags.markForTrain === true && current.retrainStatus !== "staging") {
16005
+ const staging = await store.countStaging({ deviceId: input.deviceId });
16006
+ if (staging >= 500) {
16007
+ logger.warn("setTrackFlags refused — device staging budget full", {
16008
+ tags: { deviceId: input.deviceId },
16009
+ meta: {
16010
+ deviceId: input.deviceId,
16011
+ trackId: input.trackId,
16012
+ staging,
16013
+ limit: 500
16014
+ }
16015
+ });
16016
+ throw new StagingBudgetExceededError(input.deviceId, staging);
16017
+ }
16018
+ }
15897
16019
  const patch = {
15898
16020
  ...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
15899
16021
  ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
15900
16022
  };
15901
16023
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
16024
+ const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
15902
16025
  return {
15903
16026
  trackId: input.trackId,
15904
- markForTrain: patch.markForTrain ?? current.markForTrain,
15905
- debug: patch.debug ?? current.debug
16027
+ markForTrain: retrainStatus === "staging",
16028
+ debug: patch.debug ?? current.debug,
16029
+ retrainStatus
15906
16030
  };
15907
16031
  }
15908
16032
  //#endregion
@@ -18619,6 +18743,20 @@ function rowToSensorEvent(id, data) {
18619
18743
  function isRecord(x) {
18620
18744
  return x !== null && typeof x === "object" && !Array.isArray(x);
18621
18745
  }
18746
+ /**
18747
+ * May the interval this attempt consumed be given back?
18748
+ *
18749
+ * Pure, and deliberately takes the CURRENT clock as well as the mark: a rollback
18750
+ * is only correct while this attempt is still the one the gate is standing on. A
18751
+ * later frame's pending mark, or a snapshot that landed in between, has already
18752
+ * moved the reference forward and restoring an older one would re-open a window
18753
+ * that is legitimately closed.
18754
+ */
18755
+ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
18756
+ if (mark === void 0) return false;
18757
+ if (mark.at !== currentLastSnapshotAt) return false;
18758
+ return mark.priorRollbacks < 3;
18759
+ }
18622
18760
  //#endregion
18623
18761
  //#region src/pipeline-analytics/store/recent-cursor.ts
18624
18762
  function encodeRecentCursor(cursor) {
@@ -18779,8 +18917,10 @@ var TRACKS_COLUMNS = [
18779
18917
  type: "JSON"
18780
18918
  },
18781
18919
  {
18782
- name: "markForTrain",
18783
- type: "BOOLEAN"
18920
+ name: "retrainStatus",
18921
+ type: "TEXT",
18922
+ notNull: true,
18923
+ defaultValue: "none"
18784
18924
  },
18785
18925
  {
18786
18926
  name: "debug",
@@ -18857,7 +18997,10 @@ function cloneTrack(t) {
18857
18997
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
18858
18998
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
18859
18999
  ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {},
18860
- ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
19000
+ ...t.retrainStatus !== void 0 ? {
19001
+ retrainStatus: t.retrainStatus,
19002
+ markForTrain: t.retrainStatus === "staging"
19003
+ } : {},
18861
19004
  ...t.debug !== void 0 ? { debug: t.debug } : {}
18862
19005
  };
18863
19006
  }
@@ -18952,6 +19095,8 @@ var TrackStore = class {
18952
19095
  t.snapshots.push(snapshot);
18953
19096
  t.lastSnapshotAt = snapshot.timestamp;
18954
19097
  t.lastSnapshotBbox = { ...snapshot.position.bbox };
19098
+ delete t.snapshotPending;
19099
+ t.snapshotRollbacks = 0;
18955
19100
  }
18956
19101
  /**
18957
19102
  * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
@@ -18963,18 +19108,57 @@ var TrackStore = class {
18963
19108
  * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
18964
19109
  * rolling `lastFrame`.
18965
19110
  *
18966
- * No rollback: if the write later fails the slot is simply lost (a rare dropped
18967
- * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
18968
- * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
18969
- * an unknown/expired track (the active entry is dropped on expiry, so there is
18970
- * no separate map to leak).
19111
+ * The advance is REVERSIBLE. It used to be permanent — this docblock said
19112
+ * "if the write later fails the slot is simply lost" — which made `snapshot`
19113
+ * the only media kind with no retry of any shape while every sibling advanced
19114
+ * its bookkeeping from the ids that actually persisted. See
19115
+ * {@link rollbackSnapshotPending} and `pipeline/snapshot-retry.ts`.
19116
+ *
19117
+ * `addSnapshot` re-stamps the same clock/bbox when the real snapshot lands, so
19118
+ * the two stay consistent. No-op for an unknown/expired track (the active entry
19119
+ * is dropped on expiry, so there is no separate map to leak).
18971
19120
  */
18972
19121
  markSnapshotPending(trackId, timestamp, bbox) {
18973
19122
  const t = this.active.get(trackId);
18974
19123
  if (!t) return;
19124
+ t.snapshotPending = {
19125
+ at: timestamp,
19126
+ previous: {
19127
+ at: t.lastSnapshotAt,
19128
+ bbox: t.lastSnapshotBbox ? { ...t.lastSnapshotBbox } : void 0
19129
+ },
19130
+ priorRollbacks: t.snapshotRollbacks ?? 0
19131
+ };
18975
19132
  t.lastSnapshotAt = timestamp;
18976
19133
  t.lastSnapshotBbox = { ...bbox };
18977
19134
  }
19135
+ /**
19136
+ * The capture decided at `timestamp` did NOT land: restore the gate reference
19137
+ * it displaced so the next frame re-evaluates instead of waiting out an
19138
+ * interval that was never used. Returns whether the slot was given back.
19139
+ *
19140
+ * Refused — deliberately — when the attempt is no longer the one the gate
19141
+ * stands on (a snapshot landed, or a newer frame already marked its own
19142
+ * pending), and once the bounded run of non-landings is spent
19143
+ * ({@link SNAPSHOT_MAX_ROLLBACKS}): a camera whose frames are permanently
19144
+ * unresolvable must not re-decide a snapshot on every frame forever.
19145
+ */
19146
+ rollbackSnapshotPending(trackId, timestamp) {
19147
+ const t = this.active.get(trackId);
19148
+ if (!t) return false;
19149
+ const mark = t.snapshotPending;
19150
+ if (mark?.at !== timestamp) return false;
19151
+ if (!canRollbackSnapshot(mark, t.lastSnapshotAt)) {
19152
+ delete t.snapshotPending;
19153
+ return false;
19154
+ }
19155
+ t.lastSnapshotAt = mark.previous.at;
19156
+ if (mark.previous.bbox) t.lastSnapshotBbox = { ...mark.previous.bbox };
19157
+ else delete t.lastSnapshotBbox;
19158
+ t.snapshotRollbacks = mark.priorRollbacks + 1;
19159
+ delete t.snapshotPending;
19160
+ return true;
19161
+ }
18978
19162
  lastSnapshotAt(trackId) {
18979
19163
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
18980
19164
  }
@@ -19098,24 +19282,30 @@ var TrackStore = class {
19098
19282
  }
19099
19283
  }
19100
19284
  /**
19101
- * Apply a PARTIAL patch of the per-track operator flags (`markForTrain`,
19285
+ * Apply a PARTIAL patch of the per-track operator state (`markForTrain`,
19102
19286
  * `debug`). Mirrors `setLabel`'s dual write — in-RAM active entry AND the
19103
- * persisted row — so a flag set on a LIVE track is carried into
19287
+ * persisted row — so a mark set on a LIVE track is carried into
19104
19288
  * `persistCompleted` at expiry instead of evaporating with the RAM state.
19105
19289
  *
19290
+ * `markForTrain` is a LIFECYCLE move, not a boolean write: `true` is
19291
+ * `→ staging`, `false` is `→ none`. This method does not police the move —
19292
+ * the `trained` refusal lives in `applyTrackFlags`, the single cap-level write
19293
+ * surface, which is the only caller that has already read the current state.
19294
+ *
19106
19295
  * An omitted key is left untouched; `false` is WRITTEN, not dropped, so an
19107
- * explicitly un-flagged track stays distinguishable from one never touched.
19296
+ * explicitly un-marked track stays distinguishable from one never touched.
19108
19297
  * An empty patch is a no-op (an update with no columns is not a useful
19109
19298
  * statement, and the store would reject or silently ignore it).
19110
19299
  */
19111
19300
  async setFlags(trackId, patch) {
19112
19301
  const data = {};
19113
- if (patch.markForTrain !== void 0) data["markForTrain"] = patch.markForTrain;
19302
+ const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
19303
+ if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
19114
19304
  if (patch.debug !== void 0) data["debug"] = patch.debug;
19115
19305
  if (Object.keys(data).length === 0) return;
19116
19306
  const active = this.active.get(trackId);
19117
19307
  if (active) {
19118
- if (patch.markForTrain !== void 0) active.markForTrain = patch.markForTrain;
19308
+ if (nextStatus !== void 0) active.retrainStatus = nextStatus;
19119
19309
  if (patch.debug !== void 0) active.debug = patch.debug;
19120
19310
  }
19121
19311
  try {
@@ -19132,20 +19322,25 @@ var TrackStore = class {
19132
19322
  }
19133
19323
  }
19134
19324
  /**
19135
- * Resolved flag state for a track — active entry first, then the persisted
19136
- * row. Absent collapses to `false`, which is what the cap returns to a caller
19137
- * driving a toggle. `null` when the track is unknown to both.
19325
+ * Resolved operator state for a track — active entry first, then the
19326
+ * persisted row. `null` when the track is unknown to both.
19327
+ *
19328
+ * Reports the LIFECYCLE STATUS, not the derived boolean, because the one
19329
+ * caller that matters decides on it: `applyTrackFlags` must tell `trained`
19330
+ * (refuse the write) from `none` (accept it), and both report
19331
+ * `markForTrain: false`. Absent collapses to `'none'` / `false` — the
19332
+ * "never touched" tri-state exists only in RAM and carries no decision.
19138
19333
  */
19139
19334
  async readFlags(trackId) {
19140
19335
  const active = this.active.get(trackId);
19141
19336
  if (active) return {
19142
- markForTrain: active.markForTrain === true,
19337
+ retrainStatus: active.retrainStatus ?? "none",
19143
19338
  debug: active.debug === true
19144
19339
  };
19145
19340
  const persisted = await this.getPersistedByTrackId(trackId);
19146
19341
  if (!persisted) return null;
19147
19342
  return {
19148
- markForTrain: persisted.markForTrain === true,
19343
+ retrainStatus: persisted.retrainStatus ?? "none",
19149
19344
  debug: persisted.debug === true
19150
19345
  };
19151
19346
  }
@@ -19210,10 +19405,11 @@ var TrackStore = class {
19210
19405
  * `offset` by the number of rows a page did NOT remove, which terminates
19211
19406
  * because the offset only grows and the result set is finite.
19212
19407
  *
19213
- * This is the seam a retention PIN needs. Pinning is now one exemption on one
19214
- * query — this one — instead of four exemptions across four sweeps, and the
19215
- * drain no longer breaks when it meets a row it must skip. The pin itself is
19216
- * deliberately NOT implemented here (D61).
19408
+ * This is the seam the retention PIN lands on, and it lands here ONLY: after
19409
+ * D61 the track row is the single cascading root, so one exemption on this one
19410
+ * query holds the track's events, crops, keyframes and CLIP vector with it —
19411
+ * where D59 had to plan for four exemptions across four disagreeing sweeps.
19412
+ * The pin is `whereIn: { retrainStatus: ['none','trained'] }` below (D81).
19217
19413
  *
19218
19414
  * Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
19219
19415
  * → strictly `< cutoffMs`. Best-effort — a query failure yields [].
@@ -19224,6 +19420,7 @@ var TrackStore = class {
19224
19420
  collection: TRACKS_COLLECTION,
19225
19421
  filter: {
19226
19422
  where: { deviceId },
19423
+ whereIn: { retrainStatus: ["none", "trained"] },
19227
19424
  whereBetween: { lastSeen: [0, cutoffMs - 1] },
19228
19425
  orderBy: {
19229
19426
  field: "lastSeen",
@@ -19247,6 +19444,87 @@ var TrackStore = class {
19247
19444
  }
19248
19445
  }
19249
19446
  /**
19447
+ * The device's STAGING tracks, newest first — the training-export selection.
19448
+ *
19449
+ * `retrainStatus = 'staging'` is exactly the complement of the sweep's
19450
+ * `whereIn ['none','trained']` (all three safe against NULL because the column
19451
+ * is NOT NULL DEFAULT 'none') — the load-bearing part: this is the set
19452
+ * retention has promised to still be there. `trained` is deliberately NOT in
19453
+ * it: the sweep is free to delete a trained track mid-pass, and exporting one
19454
+ * is how a manifest ends up describing files the archive does not contain.
19455
+ *
19456
+ * `projection: 'slim'` because an export enumerates media; it has no use for
19457
+ * the per-frame trajectory JSON, which is the heaviest column on the row.
19458
+ */
19459
+ async listStaging(deviceId, limit) {
19460
+ try {
19461
+ return (await this.store.query.query({
19462
+ collection: TRACKS_COLLECTION,
19463
+ filter: {
19464
+ where: {
19465
+ deviceId,
19466
+ retrainStatus: "staging"
19467
+ },
19468
+ orderBy: {
19469
+ field: "lastSeen",
19470
+ direction: "desc"
19471
+ },
19472
+ limit
19473
+ }
19474
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
19475
+ } catch (err) {
19476
+ this.logger.warn("TrackStore.listStaging failed", {
19477
+ tags: { deviceId },
19478
+ meta: {
19479
+ deviceId,
19480
+ limit,
19481
+ error: String(err)
19482
+ }
19483
+ });
19484
+ return [];
19485
+ }
19486
+ }
19487
+ /**
19488
+ * How many of this device's tracks are STAGING — optionally only those older
19489
+ * than a cutoff, i.e. exactly the rows a sweep just declined to touch.
19490
+ *
19491
+ * Two callers, one query. The sweep uses `olderThanMs` for its report; the
19492
+ * `setTrackFlags` body uses the unbounded form to enforce the per-device
19493
+ * staging budget, because staging is unbounded in TIME by construction and
19494
+ * something has to bound it in COUNT (D81, and D59 asked for exactly this).
19495
+ * `trained` tracks are NOT counted — they are evictable again, so they cost
19496
+ * the budget nothing.
19497
+ *
19498
+ * Best-effort — a failure reports 0 rather than disturbing the caller. The
19499
+ * budget check reads that as "there is room", which is the safe direction: a
19500
+ * failed count must not stop an operator marking a track.
19501
+ */
19502
+ async countStaging(query) {
19503
+ const { deviceId, olderThanMs } = query;
19504
+ try {
19505
+ return await this.store.count.query({
19506
+ collection: TRACKS_COLLECTION,
19507
+ filter: {
19508
+ where: {
19509
+ deviceId,
19510
+ retrainStatus: "staging"
19511
+ },
19512
+ ...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
19513
+ }
19514
+ });
19515
+ } catch (err) {
19516
+ this.logger.warn("TrackStore.countStaging failed", {
19517
+ tags: { deviceId },
19518
+ meta: {
19519
+ deviceId,
19520
+ olderThanMs,
19521
+ error: String(err)
19522
+ }
19523
+ });
19524
+ return 0;
19525
+ }
19526
+ }
19527
+ /**
19250
19528
  * The SUBSET of `trackIds` that still have a persisted row — the orphan
19251
19529
  * audit's liveness probe.
19252
19530
  *
@@ -19586,7 +19864,7 @@ var TrackStore = class {
19586
19864
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
19587
19865
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
19588
19866
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
19589
- ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
19867
+ ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
19590
19868
  ...t.debug !== void 0 ? { debug: t.debug } : {},
19591
19869
  ...envelope !== null && dims !== void 0 ? {
19592
19870
  envMinX: envelope.minX,
@@ -19620,7 +19898,7 @@ var TrackStore = class {
19620
19898
  const bestEventId = data["bestEventId"];
19621
19899
  const importanceReason = data["importanceReason"];
19622
19900
  const audioLabels = data["audioLabels"];
19623
- const markForTrain = data["markForTrain"];
19901
+ const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
19624
19902
  const debug = data["debug"];
19625
19903
  const envMinX = data["envMinX"];
19626
19904
  const envMinY = data["envMinY"];
@@ -19652,7 +19930,10 @@ var TrackStore = class {
19652
19930
  ...typeof bestEventId === "string" ? { bestEventId } : {},
19653
19931
  ...typeof importanceReason === "string" ? { importanceReason } : {},
19654
19932
  ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
19655
- ...typeof markForTrain === "boolean" ? { markForTrain } : {},
19933
+ ...retrainParsed.success ? {
19934
+ retrainStatus: retrainParsed.data,
19935
+ markForTrain: retrainParsed.data === "staging"
19936
+ } : {},
19656
19937
  ...typeof debug === "boolean" ? { debug } : {},
19657
19938
  ...envelope !== null ? { envelope } : {}
19658
19939
  };
@@ -21095,6 +21376,35 @@ function createNativeFrameTransport(deps) {
21095
21376
  };
21096
21377
  };
21097
21378
  const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
21379
+ const getFullFrameJpegBase64 = async (handle, maxWidth) => {
21380
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
21381
+ try {
21382
+ const full = await pipelineRunnerApi.getNativeCrop.query({
21383
+ handle,
21384
+ bbox: {
21385
+ x: 0,
21386
+ y: 0,
21387
+ w: 1,
21388
+ h: 1
21389
+ },
21390
+ maxWidth,
21391
+ encodeJpeg: true
21392
+ }, nodePin(handle.nodeId));
21393
+ if (!full?.jpeg || full.width <= 0 || full.height <= 0) return null;
21394
+ return {
21395
+ base64: full.jpeg,
21396
+ width: full.width,
21397
+ height: full.height,
21398
+ tier: full.tier === "ram-fullframe" ? "ram-fullframe" : "native"
21399
+ };
21400
+ } catch (err) {
21401
+ logger.debug("full-frame jpeg fetch failed", { meta: {
21402
+ nodeId: handle.nodeId,
21403
+ error: errMsg(err)
21404
+ } });
21405
+ return null;
21406
+ }
21407
+ };
21098
21408
  const cropMetricLogger = logger.child("NativeCrop");
21099
21409
  let nativeHits = 0;
21100
21410
  let nativeFallbacks = 0;
@@ -21145,6 +21455,7 @@ function createNativeFrameTransport(deps) {
21145
21455
  getRemoteFrame,
21146
21456
  getNativeFullFrameRgb,
21147
21457
  getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
21458
+ getFullFrameJpegBase64,
21148
21459
  getNativeCropJpeg,
21149
21460
  captureCrop: createCaptureCrop({
21150
21461
  tryNativeCrop,
@@ -21501,69 +21812,389 @@ function wireSubscriptions(deps) {
21501
21812
  }));
21502
21813
  return unsubs;
21503
21814
  }
21504
- //#endregion
21505
- //#region src/pipeline-analytics/services/event-kinds.ts
21506
21815
  /**
21507
- * Extensible per-device event kinds (Part B).
21508
- *
21509
- * `composeEventKinds` builds the `listEventKinds` payload for a camera:
21510
- * (a) built-ins — motion + audio, always present;
21511
- * (b) detection classes actually OBSERVED on the device (track history);
21512
- * (c) sensor kinds contributed by LINKED devices (device-manager
21513
- * `getLinkedDevices`), one descriptor per bound sensor cap present in
21514
- * the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
21515
- * (`getBindings`) — never a global cap enumeration (D12).
21516
- *
21517
- * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
21518
- * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
21519
- * cheap at bus rate.
21816
+ * A baseline whose last sample is older than `baselineMs × this` no longer
21817
+ * describes the camera — an on-motion camera detaches for hours and comes
21818
+ * back to a different scene. Decaying it forward would make the first frame
21819
+ * back either a guaranteed marker (mean still holds the old quiet) or a
21820
+ * guaranteed miss; discarding it is the honest option and costs one warm-up.
21520
21821
  */
21822
+ var STALE_BASELINE_FACTOR = 5;
21521
21823
  /**
21522
- * Full event-kind taxonomy a camera CAN emit (config-derived):
21523
- * (a) motion built-in — always;
21524
- * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
21525
- * (c) audio macro + its subs when the classifier is enabled;
21526
- * (d) package kinds when a package zone rule is enabled;
21527
- * (e) sensor/control kinds from LINKED devices (binding-driven), deduped
21528
- * per (kind, source deviceId).
21529
- * Every descriptor carries parentKind/level/iconId/labelKey via the single
21530
- * taxonomy dictionary — no color/icon is declared here.
21824
+ * Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
21825
+ * has MAD 0 and would make every deviation infinite. 1 dB is below the
21826
+ * quantisation of any real level meter, so it only ever binds on a synthetic
21827
+ * or a genuinely dead-steady signal — where bar 2 is what should decide.
21531
21828
  */
21829
+ var MIN_MAD_DB = 1;
21830
+ function emptyAudioBaseline() {
21831
+ return {
21832
+ count: 0,
21833
+ mean: 0,
21834
+ mad: 0,
21835
+ firstMs: 0,
21836
+ lastMs: 0
21837
+ };
21838
+ }
21839
+ /** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
21840
+ function seed(sample) {
21841
+ return {
21842
+ count: 1,
21843
+ mean: sample.dbfs,
21844
+ mad: 0,
21845
+ firstMs: sample.timestampMs,
21846
+ lastMs: sample.timestampMs
21847
+ };
21848
+ }
21532
21849
  /**
21533
- * Run one lookup, reporting a failure instead of propagating it.
21850
+ * Fold `sample` into `state` and decide whether it is a marker candidate.
21534
21851
  *
21535
- * `undefined` means "this one did not answer" — the caller degrades that part
21536
- * of the taxonomy and keeps the rest. That is the pre-existing contract (each
21537
- * section had its own try/catch); it is factored out here only because the
21538
- * sections now run concurrently, and a rejected promise nobody has awaited yet
21539
- * is an unhandled rejection.
21852
+ * The verdict is measured against the PRIOR baseline (this frame excluded) so
21853
+ * a lone spike is not diluted by itself — the same reasoning
21854
+ * `audio-level-detector` uses for its `priorMean`. The state returned always
21855
+ * includes the sample, whatever the verdict.
21540
21856
  */
21541
- async function attempt(deps, scope, run) {
21542
- try {
21543
- return await run();
21544
- } catch (err) {
21545
- deps.onError?.(scope, err);
21546
- return;
21547
- }
21548
- }
21549
- async function composeEventKinds(deps, deviceId) {
21550
- const out = [];
21551
- const cameraSource = {
21552
- capName: "pipeline-analytics",
21553
- deviceId
21857
+ function observeAudioAnomaly(state, sample, cfg) {
21858
+ const isFirst = state.count === 0;
21859
+ const gapMs = sample.timestampMs - state.lastMs;
21860
+ const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
21861
+ if (isFirst || isStale) return {
21862
+ verdict: { kind: "warmup" },
21863
+ nextState: seed(sample)
21554
21864
  };
21555
- const pushKind = (kind, source = cameraSource) => {
21556
- const d = buildEventKindDescriptor(kind, source);
21557
- if (d !== null) out.push(d);
21865
+ const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
21866
+ const priorMean = state.mean;
21867
+ const priorMad = state.mad;
21868
+ const nextState = {
21869
+ count: state.count + 1,
21870
+ mean: priorMean + alpha * (sample.dbfs - priorMean),
21871
+ mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
21872
+ firstMs: state.firstMs,
21873
+ lastMs: sample.timestampMs
21558
21874
  };
21559
- const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
21560
- const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
21561
- const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
21562
- const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
21563
- pushKind("motion");
21564
- for (const macro of await macrosP ?? []) {
21565
- pushKind(macro);
21566
- for (const sub of subKindsOf(macro)) pushKind(sub.kind);
21875
+ if (sample.dbfs <= cfg.silenceFloorDbfs) return {
21876
+ verdict: { kind: "silent" },
21877
+ nextState
21878
+ };
21879
+ if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
21880
+ verdict: { kind: "warmup" },
21881
+ nextState
21882
+ };
21883
+ if (sample.dbfs < cfg.minDbfs) return {
21884
+ verdict: {
21885
+ kind: "not-loud",
21886
+ dbfs: sample.dbfs
21887
+ },
21888
+ nextState
21889
+ };
21890
+ const deviationDb = sample.dbfs - priorMean;
21891
+ const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
21892
+ const measured = {
21893
+ dbfs: sample.dbfs,
21894
+ mean: priorMean,
21895
+ deviationDb,
21896
+ deviations
21897
+ };
21898
+ return {
21899
+ verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
21900
+ kind: "anomalous",
21901
+ ...measured
21902
+ } : {
21903
+ kind: "not-anomalous",
21904
+ ...measured
21905
+ },
21906
+ nextState
21907
+ };
21908
+ }
21909
+ //#endregion
21910
+ //#region src/pipeline-analytics/services/audio-marker-projector.ts
21911
+ /** One info line per camera per minute, and only when something qualified. */
21912
+ var METRICS_WINDOW_MS = 6e4;
21913
+ var HOUR_MS = 60 * 6e4;
21914
+ var DAY_MS = 24 * HOUR_MS;
21915
+ function emptyCounters(atMs) {
21916
+ return {
21917
+ windowStartMs: atMs,
21918
+ enabled: false,
21919
+ observed: 0,
21920
+ notLoud: 0,
21921
+ notAnomalous: 0,
21922
+ candidates: 0,
21923
+ suppressedMotion: 0,
21924
+ suppressedCooldown: 0,
21925
+ suppressedHourly: 0,
21926
+ suppressedDaily: 0,
21927
+ suppressedInFlight: 0,
21928
+ wouldFire: 0,
21929
+ materialized: 0,
21930
+ materializeFailed: 0
21931
+ };
21932
+ }
21933
+ var AudioMarkerProjector = class {
21934
+ deps;
21935
+ baselines = /* @__PURE__ */ new Map();
21936
+ rates = /* @__PURE__ */ new Map();
21937
+ counters = /* @__PURE__ */ new Map();
21938
+ inFlight = /* @__PURE__ */ new Set();
21939
+ constructor(deps) {
21940
+ this.deps = deps;
21941
+ }
21942
+ /**
21943
+ * Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
21944
+ * the common path must not allocate a promise, so the decision is taken
21945
+ * inline and only an actual marker goes async (fire-and-forget, bounded by
21946
+ * the in-flight guard and the rate ceilings).
21947
+ *
21948
+ * Call this BEFORE the classification/level route split so the baseline
21949
+ * sees every frame. A baseline fed only by the level route is blind to
21950
+ * exactly the loud, confidently-classified frames a marker is for.
21951
+ */
21952
+ observe(input, cfg) {
21953
+ const { deviceId, timestampMs } = input;
21954
+ const counters = this.countersFor(deviceId, timestampMs);
21955
+ counters.observed += 1;
21956
+ counters.enabled = cfg.enabled;
21957
+ const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
21958
+ timestampMs,
21959
+ dbfs: input.dbfs
21960
+ }, cfg);
21961
+ this.baselines.set(deviceId, res.nextState);
21962
+ this.maybeFlush(deviceId, timestampMs);
21963
+ switch (res.verdict.kind) {
21964
+ case "silent":
21965
+ case "warmup": return;
21966
+ case "not-loud":
21967
+ counters.notLoud += 1;
21968
+ return;
21969
+ case "not-anomalous":
21970
+ counters.notAnomalous += 1;
21971
+ return;
21972
+ case "anomalous": break;
21973
+ }
21974
+ counters.candidates += 1;
21975
+ if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
21976
+ counters.suppressedMotion += 1;
21977
+ return;
21978
+ }
21979
+ const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
21980
+ const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
21981
+ if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
21982
+ counters.suppressedCooldown += 1;
21983
+ return;
21984
+ }
21985
+ const recent = fired.filter((t) => timestampMs - t < DAY_MS);
21986
+ if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
21987
+ counters.suppressedHourly += 1;
21988
+ this.rates.set(deviceId, { firedAtMs: recent });
21989
+ return;
21990
+ }
21991
+ if (recent.length >= cfg.maxPerDay) {
21992
+ counters.suppressedDaily += 1;
21993
+ this.rates.set(deviceId, { firedAtMs: recent });
21994
+ return;
21995
+ }
21996
+ if (!cfg.enabled) {
21997
+ counters.wouldFire += 1;
21998
+ return;
21999
+ }
22000
+ if (this.inFlight.has(deviceId)) {
22001
+ counters.suppressedInFlight += 1;
22002
+ return;
22003
+ }
22004
+ this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
22005
+ counters.materialized += 1;
22006
+ this.inFlight.add(deviceId);
22007
+ this.materialize(input, counters);
22008
+ }
22009
+ /**
22010
+ * Emit and reset a camera's window if it has anything to say. Exposed so a
22011
+ * shutdown (or a test) can force the tally out rather than losing it.
22012
+ */
22013
+ flush(nowMs) {
22014
+ for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
22015
+ }
22016
+ /** Drop all per-camera state for a device (unbind / device removal). */
22017
+ forgetDevice(deviceId) {
22018
+ this.baselines.delete(deviceId);
22019
+ this.rates.delete(deviceId);
22020
+ this.counters.delete(deviceId);
22021
+ this.inFlight.delete(deviceId);
22022
+ }
22023
+ async materialize(input, counters) {
22024
+ const { deviceId, timestampMs } = input;
22025
+ try {
22026
+ const audioLabels = buildAudioLabels(input);
22027
+ await this.deps.sink.materialize({
22028
+ cameraId: deviceId,
22029
+ sourceDeviceId: deviceId,
22030
+ source: "audio",
22031
+ kind: "audio",
22032
+ timestamp: timestampMs,
22033
+ ...audioLabels !== void 0 ? { audioLabels } : {}
22034
+ });
22035
+ } catch (err) {
22036
+ counters.materialized -= 1;
22037
+ counters.materializeFailed += 1;
22038
+ this.deps.onError?.("materializeAudioMarker", err);
22039
+ this.deps.logger.warn("audio marker materialize failed", {
22040
+ tags: { deviceId },
22041
+ meta: { error: err instanceof Error ? err.message : String(err) }
22042
+ });
22043
+ } finally {
22044
+ this.inFlight.delete(deviceId);
22045
+ }
22046
+ }
22047
+ countersFor(deviceId, atMs) {
22048
+ const existing = this.counters.get(deviceId);
22049
+ if (existing !== void 0) return existing;
22050
+ const fresh = emptyCounters(atMs);
22051
+ this.counters.set(deviceId, fresh);
22052
+ return fresh;
22053
+ }
22054
+ maybeFlush(deviceId, nowMs) {
22055
+ const c = this.counters.get(deviceId);
22056
+ if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22057
+ this.emitWindow(deviceId, nowMs, false);
22058
+ }
22059
+ /**
22060
+ * One line per camera per window — but ONLY when a frame reached the
22061
+ * candidate gate. A camera that heard nothing extraordinary has nothing to
22062
+ * report, and a line per camera per minute forever would bury the ones that
22063
+ * do. `force` still honours that: it flushes the window, it does not invent
22064
+ * a reason to speak.
22065
+ */
22066
+ emitWindow(deviceId, nowMs, force) {
22067
+ const c = this.counters.get(deviceId);
22068
+ if (c === void 0) return;
22069
+ if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22070
+ if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
22071
+ tags: { deviceId },
22072
+ meta: {
22073
+ enabled: c.enabled,
22074
+ windowMs: nowMs - c.windowStartMs,
22075
+ observed: c.observed,
22076
+ notLoud: c.notLoud,
22077
+ notAnomalous: c.notAnomalous,
22078
+ candidates: c.candidates,
22079
+ suppressedMotion: c.suppressedMotion,
22080
+ suppressedCooldown: c.suppressedCooldown,
22081
+ suppressedHourly: c.suppressedHourly,
22082
+ suppressedDaily: c.suppressedDaily,
22083
+ suppressedInFlight: c.suppressedInFlight,
22084
+ wouldFire: c.wouldFire,
22085
+ materialized: c.materialized,
22086
+ materializeFailed: c.materializeFailed
22087
+ }
22088
+ });
22089
+ this.counters.set(deviceId, emptyCounters(nowMs));
22090
+ }
22091
+ };
22092
+ /**
22093
+ * The class heard on the anomalous frame, in the shape `Track.audioLabels`
22094
+ * already defines. Using that field is what lets a marker label itself
22095
+ * ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
22096
+ * the audio macro vocabulary collides with existing subs (`dog` is a COCO
22097
+ * animal, `siren` is a control), so a sub per audio class would put a barking
22098
+ * dog in the ANIMAL lane.
22099
+ */
22100
+ function buildAudioLabels(input) {
22101
+ if (input.className === void 0) return void 0;
22102
+ return [{
22103
+ label: input.className,
22104
+ peakScore: input.score ?? 0,
22105
+ count: 1,
22106
+ firstAt: input.timestampMs,
22107
+ lastAt: input.timestampMs
22108
+ }];
22109
+ }
22110
+ //#endregion
22111
+ //#region src/pipeline-analytics/services/event-kinds.ts
22112
+ /**
22113
+ * Extensible per-device event kinds (Part B).
22114
+ *
22115
+ * `composeEventKinds` builds the `listEventKinds` payload for a camera:
22116
+ * (a) built-ins — motion + audio, always present;
22117
+ * (b) detection classes actually OBSERVED on the device (track history);
22118
+ * (c) sensor kinds contributed by the camera's OWN bindings and by its
22119
+ * LINKED devices (device-manager `getLinkedDevices`), one descriptor per
22120
+ * bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
22121
+ * Binding-driven per device (`getBindings`) — never a global cap
22122
+ * enumeration (D12).
22123
+ *
22124
+ * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
22125
+ * linked camera ids, plus the camera id set) with a TTL, so the
22126
+ * `DeviceStateChanged` handler stays cheap at bus rate.
22127
+ */
22128
+ /**
22129
+ * Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
22130
+ *
22131
+ * A sensor cap bound on a camera makes the camera both the source and the
22132
+ * subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
22133
+ * camera-side button. Those have no other route into the per-camera event
22134
+ * substrate, so the camera includes itself.
22135
+ *
22136
+ * `motion` is the exception, and today the ONLY one: camera motion already
22137
+ * has a dedicated camera-native path (`MotionAnalysis` /
22138
+ * `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
22139
+ * taxonomy lane). Every real camera provider registers a native `motion` cap
22140
+ * on the camera device, so self-inclusion would mint a SECOND, differently
22141
+ * named copy (`motion-sensor`) of every motion transition, at motion rate.
22142
+ *
22143
+ * Add a cap here only when the camera already emits that event through
22144
+ * another ingest path — never to reduce noise.
22145
+ */
22146
+ var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
22147
+ /** True when a camera may contribute `capName` for itself. */
22148
+ function selfCameraMayEmit(capName) {
22149
+ return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
22150
+ }
22151
+ /**
22152
+ * Full event-kind taxonomy a camera CAN emit (config-derived):
22153
+ * (a) motion built-in — always;
22154
+ * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
22155
+ * (c) audio macro + its subs when the classifier is enabled;
22156
+ * (d) package kinds when a package zone rule is enabled;
22157
+ * (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
22158
+ * LINKED devices (binding-driven), deduped per (kind, source deviceId).
22159
+ * Every descriptor carries parentKind/level/iconId/labelKey via the single
22160
+ * taxonomy dictionary — no color/icon is declared here.
22161
+ */
22162
+ /**
22163
+ * Run one lookup, reporting a failure instead of propagating it.
22164
+ *
22165
+ * `undefined` means "this one did not answer" — the caller degrades that part
22166
+ * of the taxonomy and keeps the rest. That is the pre-existing contract (each
22167
+ * section had its own try/catch); it is factored out here only because the
22168
+ * sections now run concurrently, and a rejected promise nobody has awaited yet
22169
+ * is an unhandled rejection.
22170
+ */
22171
+ async function attempt(deps, scope, run) {
22172
+ try {
22173
+ return await run();
22174
+ } catch (err) {
22175
+ deps.onError?.(scope, err);
22176
+ return;
22177
+ }
22178
+ }
22179
+ async function composeEventKinds(deps, deviceId) {
22180
+ const out = [];
22181
+ const cameraSource = {
22182
+ capName: "pipeline-analytics",
22183
+ deviceId
22184
+ };
22185
+ const pushKind = (kind, source = cameraSource) => {
22186
+ const d = buildEventKindDescriptor(kind, source);
22187
+ if (d !== null) out.push(d);
22188
+ };
22189
+ const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
22190
+ const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
22191
+ const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
22192
+ const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
22193
+ const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
22194
+ pushKind("motion");
22195
+ for (const macro of await macrosP ?? []) {
22196
+ pushKind(macro);
22197
+ for (const sub of subKindsOf(macro)) pushKind(sub.kind);
21567
22198
  }
21568
22199
  if (await audioP === true) {
21569
22200
  pushKind("audio");
@@ -21576,26 +22207,30 @@ async function composeEventKinds(deps, deviceId) {
21576
22207
  const devices = (await linkedP)?.devices ?? [];
21577
22208
  const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
21578
22209
  const seen = /* @__PURE__ */ new Set();
21579
- for (const [index, linked] of devices.entries()) {
21580
- const entries = bindingsPerDevice[index]?.entries;
21581
- if (entries === void 0) continue;
22210
+ const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
22211
+ if (entries === void 0) return;
21582
22212
  for (const { capName } of entries) {
22213
+ if (isSelf && !selfCameraMayEmit(capName)) continue;
21583
22214
  const descriptor = EVENT_KIND_BY_CAP[capName];
21584
22215
  if (descriptor === void 0) continue;
21585
- const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
22216
+ const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
21586
22217
  if (seen.has(dedupeKey)) continue;
21587
22218
  seen.add(dedupeKey);
21588
22219
  pushKind(descriptor.kind, {
21589
22220
  capName,
21590
- deviceId: linked.deviceId
22221
+ deviceId: sourceDeviceId
21591
22222
  });
21592
22223
  }
21593
- }
22224
+ };
22225
+ pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
22226
+ for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
21594
22227
  return out;
21595
22228
  }
21596
22229
  var DEFAULT_CACHE_TTL_MS = 6e4;
21597
22230
  /**
21598
- * TTL-cached reverse index: source deviceId → camera ids it is linked to.
22231
+ * TTL-cached reverse index: source deviceId → camera ids it is linked to,
22232
+ * plus the camera id SET the index was built from (so the ingest can ask
22233
+ * "is this device itself a camera" without a second enumeration).
21599
22234
  * Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
21600
22235
  * handler pays one map lookup per event in the common case.
21601
22236
  */
@@ -21603,6 +22238,8 @@ var LinkedCamerasCache = class {
21603
22238
  deps;
21604
22239
  ttlMs;
21605
22240
  index = /* @__PURE__ */ new Map();
22241
+ /** Every camera id seen by the last successful build. */
22242
+ cameras = /* @__PURE__ */ new Set();
21606
22243
  /** Ms timestamp of the last build; null = never built / invalidated. */
21607
22244
  builtAt = null;
21608
22245
  building = null;
@@ -21612,23 +22249,37 @@ var LinkedCamerasCache = class {
21612
22249
  }
21613
22250
  /** Camera ids linked to `sourceDeviceId` ([] when none). */
21614
22251
  async camerasFor(sourceDeviceId, nowMs = Date.now()) {
21615
- if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
21616
- this.building ??= this.rebuild(nowMs).finally(() => {
21617
- this.building = null;
21618
- });
21619
- await this.building;
21620
- }
22252
+ await this.ensureFresh(nowMs);
21621
22253
  return this.index.get(sourceDeviceId) ?? [];
21622
22254
  }
22255
+ /**
22256
+ * True when `deviceId` is ITSELF a camera. Answered off the same build as
22257
+ * {@link camerasFor} — a sensor cap bound on a camera needs both facts in
22258
+ * the same event, and a second enumeration would double the cost of the
22259
+ * ingest path for nothing.
22260
+ */
22261
+ async isCamera(deviceId, nowMs = Date.now()) {
22262
+ await this.ensureFresh(nowMs);
22263
+ return this.cameras.has(deviceId);
22264
+ }
21623
22265
  /** Drop the cached index (e.g. on link-topology change events). */
21624
22266
  invalidate() {
21625
22267
  this.builtAt = null;
21626
22268
  }
21627
22269
  /** Test/maintenance hook: replace the index directly. */
21628
- seed(index, builtAt) {
22270
+ seed(index, builtAt, cameraIds = []) {
21629
22271
  this.index = new Map(index);
22272
+ this.cameras = new Set(cameraIds);
21630
22273
  this.builtAt = builtAt;
21631
22274
  }
22275
+ /** Rebuild (single-flight) when never built or past the TTL. */
22276
+ async ensureFresh(nowMs) {
22277
+ if (this.builtAt !== null && nowMs - this.builtAt < this.ttlMs) return;
22278
+ this.building ??= this.rebuild(nowMs).finally(() => {
22279
+ this.building = null;
22280
+ });
22281
+ await this.building;
22282
+ }
21632
22283
  async rebuild(nowMs) {
21633
22284
  try {
21634
22285
  const cameraIds = await this.deps.cameras.listCameraIds();
@@ -21645,6 +22296,7 @@ var LinkedCamerasCache = class {
21645
22296
  this.deps.onError?.("getLinkedDevices", err);
21646
22297
  }
21647
22298
  this.index = next;
22299
+ this.cameras = new Set(cameraIds);
21648
22300
  this.builtAt = nowMs;
21649
22301
  } catch (err) {
21650
22302
  this.deps.onError?.("listCameraIds", err);
@@ -21653,16 +22305,25 @@ var LinkedCamerasCache = class {
21653
22305
  }
21654
22306
  };
21655
22307
  /**
21656
- * One `DeviceStateChanged` → N history rows (one per linked camera). The
21657
- * EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
21658
- * lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
22308
+ * One `DeviceStateChanged` → N history rows, one per camera the event belongs
22309
+ * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
22310
+ * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
22311
+ * camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
22312
+ * self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
22313
+ * churn costs one map lookup. Returns the number of rows inserted (0 when
22314
+ * unmapped, or when the device belongs to no camera at all).
21659
22315
  * Telemetry-lossy by design (D8) — inserts are best-effort.
21660
22316
  */
21661
22317
  async function ingestSensorStateChange(deps, data, timestamp) {
21662
22318
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
21663
22319
  if (descriptor === void 0) return 0;
21664
- const cameraIds = await deps.cache.camerasFor(data.deviceId);
21665
- if (cameraIds.length === 0) return 0;
22320
+ const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
22321
+ const isCamera = await deps.cache.isCamera(data.deviceId);
22322
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
22323
+ if (cameraIds.length === 0) {
22324
+ deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
22325
+ return 0;
22326
+ }
21666
22327
  const slice = data.slice;
21667
22328
  const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
21668
22329
  const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
@@ -21798,6 +22459,8 @@ var SNAPSHOT_MAX_WIDTH = 960;
21798
22459
  /** Bound the once-per-owner display-fallback memo so a long-lived process never
21799
22460
  * leaks it. */
21800
22461
  var MAX_LOGGED_DISPLAY_FALLBACKS = 5e3;
22462
+ /** One `pinned-frame resolve missed` line per device per minute. */
22463
+ var PINNED_MISS_WARN_THROTTLE_MS = 6e4;
21801
22464
  /**
21802
22465
  * Map a detection-frame pixel box (`fromW`×`fromH`, the ≤640 raster the tracker
21803
22466
  * ran on) onto the native full frame (`toW`×`toH`, the 960-downscaled native
@@ -21883,6 +22546,8 @@ var EventMediaDispatcher = class {
21883
22546
  /** Once-per-owner (`${eventId}:${kind}`) memo for the DISPLAY-crop fallback
21884
22547
  * info log — see {@link logDisplayFallbackOnce}. */
21885
22548
  loggedDisplayFallbacks = /* @__PURE__ */ new Set();
22549
+ /** Per-device throttle for {@link warnPinnedFrameMiss}. */
22550
+ lastPinnedMissWarnAt = /* @__PURE__ */ new Map();
21886
22551
  constructor(deps) {
21887
22552
  this.deps = deps;
21888
22553
  this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
@@ -21896,30 +22561,48 @@ var EventMediaDispatcher = class {
21896
22561
  * by which time the handle's ring slot is usually recycled and the by-handle
21897
22562
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
21898
22563
  * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
21899
- * lane. Returns `null` on a genuine live-window miss (logged at debug — the
21900
- * caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
21901
- * WARN-logged on failure, remains the fallback).
22564
+ * lane. Returns `null` on a genuine live-window miss.
22565
+ *
22566
+ * Both miss paths are WARN, throttled to one line per device per minute. They
22567
+ * are the FIRST half of a pair whose second half (`capture batch lost`) has
22568
+ * always been a warn: a batch that lost its frame is reported with
22569
+ * `pinned: false`, and until 2026-08-07 the reason the pin itself failed was
22570
+ * either at debug (the throw) or nowhere at all (the null return). 383 batch
22571
+ * losses in 3 h had `pinned: false` on 100% of them and nothing said why.
21902
22572
  */
21903
22573
  async resolvePinnedFrame(deviceId, frameHandle) {
21904
22574
  try {
21905
22575
  const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
21906
- if (!decoded) return null;
22576
+ if (!decoded) {
22577
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "recycled-before-pin");
22578
+ return null;
22579
+ }
21907
22580
  return {
21908
22581
  ...decoded,
21909
22582
  data: Buffer.from(decoded.data)
21910
22583
  };
21911
22584
  } catch (err) {
21912
- this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
21913
- tags: { deviceId },
21914
- meta: {
21915
- deviceId,
21916
- shmId: frameHandle.shmId,
21917
- error: String(err)
21918
- }
21919
- });
22585
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "threw", String(err));
21920
22586
  return null;
21921
22587
  }
21922
22588
  }
22589
+ /** One `pinned-frame resolve missed` line per device per minute. Throttled
22590
+ * because it fires per capture batch, and the batch's own loss line is not. */
22591
+ warnPinnedFrameMiss(deviceId, frameHandle, reason, error) {
22592
+ const now = Date.now();
22593
+ if (now - (this.lastPinnedMissWarnAt.get(deviceId) ?? 0) < PINNED_MISS_WARN_THROTTLE_MS) return;
22594
+ this.lastPinnedMissWarnAt.set(deviceId, now);
22595
+ this.deps.logger.warn("event media: pinned-frame resolve missed (live window)", {
22596
+ tags: { deviceId },
22597
+ meta: {
22598
+ reason,
22599
+ shmId: frameHandle.shmId,
22600
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
22601
+ handleNodeId: frameHandle.nodeId,
22602
+ ...error !== void 0 ? { error } : {}
22603
+ }
22604
+ });
22605
+ }
21923
22606
  /**
21924
22607
  * WARN-log a whole-batch capture abort: every early return in
21925
22608
  * {@link captureForFrame} loses ALL of the frame's targets at once —
@@ -22371,307 +23054,12 @@ var EventMediaDispatcher = class {
22371
23054
  deviceId,
22372
23055
  trackId: tf.trackId,
22373
23056
  error: err instanceof Error ? err.message : String(err)
22374
- }
22375
- });
22376
- return false;
22377
- }
22378
- }
22379
- };
22380
- /**
22381
- * A baseline whose last sample is older than `baselineMs × this` no longer
22382
- * describes the camera — an on-motion camera detaches for hours and comes
22383
- * back to a different scene. Decaying it forward would make the first frame
22384
- * back either a guaranteed marker (mean still holds the old quiet) or a
22385
- * guaranteed miss; discarding it is the honest option and costs one warm-up.
22386
- */
22387
- var STALE_BASELINE_FACTOR = 5;
22388
- /**
22389
- * Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
22390
- * has MAD 0 and would make every deviation infinite. 1 dB is below the
22391
- * quantisation of any real level meter, so it only ever binds on a synthetic
22392
- * or a genuinely dead-steady signal — where bar 2 is what should decide.
22393
- */
22394
- var MIN_MAD_DB = 1;
22395
- function emptyAudioBaseline() {
22396
- return {
22397
- count: 0,
22398
- mean: 0,
22399
- mad: 0,
22400
- firstMs: 0,
22401
- lastMs: 0
22402
- };
22403
- }
22404
- /** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
22405
- function seed(sample) {
22406
- return {
22407
- count: 1,
22408
- mean: sample.dbfs,
22409
- mad: 0,
22410
- firstMs: sample.timestampMs,
22411
- lastMs: sample.timestampMs
22412
- };
22413
- }
22414
- /**
22415
- * Fold `sample` into `state` and decide whether it is a marker candidate.
22416
- *
22417
- * The verdict is measured against the PRIOR baseline (this frame excluded) so
22418
- * a lone spike is not diluted by itself — the same reasoning
22419
- * `audio-level-detector` uses for its `priorMean`. The state returned always
22420
- * includes the sample, whatever the verdict.
22421
- */
22422
- function observeAudioAnomaly(state, sample, cfg) {
22423
- const isFirst = state.count === 0;
22424
- const gapMs = sample.timestampMs - state.lastMs;
22425
- const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
22426
- if (isFirst || isStale) return {
22427
- verdict: { kind: "warmup" },
22428
- nextState: seed(sample)
22429
- };
22430
- const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
22431
- const priorMean = state.mean;
22432
- const priorMad = state.mad;
22433
- const nextState = {
22434
- count: state.count + 1,
22435
- mean: priorMean + alpha * (sample.dbfs - priorMean),
22436
- mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
22437
- firstMs: state.firstMs,
22438
- lastMs: sample.timestampMs
22439
- };
22440
- if (sample.dbfs <= cfg.silenceFloorDbfs) return {
22441
- verdict: { kind: "silent" },
22442
- nextState
22443
- };
22444
- if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
22445
- verdict: { kind: "warmup" },
22446
- nextState
22447
- };
22448
- if (sample.dbfs < cfg.minDbfs) return {
22449
- verdict: {
22450
- kind: "not-loud",
22451
- dbfs: sample.dbfs
22452
- },
22453
- nextState
22454
- };
22455
- const deviationDb = sample.dbfs - priorMean;
22456
- const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
22457
- const measured = {
22458
- dbfs: sample.dbfs,
22459
- mean: priorMean,
22460
- deviationDb,
22461
- deviations
22462
- };
22463
- return {
22464
- verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
22465
- kind: "anomalous",
22466
- ...measured
22467
- } : {
22468
- kind: "not-anomalous",
22469
- ...measured
22470
- },
22471
- nextState
22472
- };
22473
- }
22474
- //#endregion
22475
- //#region src/pipeline-analytics/services/audio-marker-projector.ts
22476
- /** One info line per camera per minute, and only when something qualified. */
22477
- var METRICS_WINDOW_MS = 6e4;
22478
- var HOUR_MS = 60 * 6e4;
22479
- var DAY_MS = 24 * HOUR_MS;
22480
- function emptyCounters(atMs) {
22481
- return {
22482
- windowStartMs: atMs,
22483
- enabled: false,
22484
- observed: 0,
22485
- notLoud: 0,
22486
- notAnomalous: 0,
22487
- candidates: 0,
22488
- suppressedMotion: 0,
22489
- suppressedCooldown: 0,
22490
- suppressedHourly: 0,
22491
- suppressedDaily: 0,
22492
- suppressedInFlight: 0,
22493
- wouldFire: 0,
22494
- materialized: 0,
22495
- materializeFailed: 0
22496
- };
22497
- }
22498
- var AudioMarkerProjector = class {
22499
- deps;
22500
- baselines = /* @__PURE__ */ new Map();
22501
- rates = /* @__PURE__ */ new Map();
22502
- counters = /* @__PURE__ */ new Map();
22503
- inFlight = /* @__PURE__ */ new Set();
22504
- constructor(deps) {
22505
- this.deps = deps;
22506
- }
22507
- /**
22508
- * Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
22509
- * the common path must not allocate a promise, so the decision is taken
22510
- * inline and only an actual marker goes async (fire-and-forget, bounded by
22511
- * the in-flight guard and the rate ceilings).
22512
- *
22513
- * Call this BEFORE the classification/level route split so the baseline
22514
- * sees every frame. A baseline fed only by the level route is blind to
22515
- * exactly the loud, confidently-classified frames a marker is for.
22516
- */
22517
- observe(input, cfg) {
22518
- const { deviceId, timestampMs } = input;
22519
- const counters = this.countersFor(deviceId, timestampMs);
22520
- counters.observed += 1;
22521
- counters.enabled = cfg.enabled;
22522
- const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
22523
- timestampMs,
22524
- dbfs: input.dbfs
22525
- }, cfg);
22526
- this.baselines.set(deviceId, res.nextState);
22527
- this.maybeFlush(deviceId, timestampMs);
22528
- switch (res.verdict.kind) {
22529
- case "silent":
22530
- case "warmup": return;
22531
- case "not-loud":
22532
- counters.notLoud += 1;
22533
- return;
22534
- case "not-anomalous":
22535
- counters.notAnomalous += 1;
22536
- return;
22537
- case "anomalous": break;
22538
- }
22539
- counters.candidates += 1;
22540
- if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
22541
- counters.suppressedMotion += 1;
22542
- return;
22543
- }
22544
- const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
22545
- const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
22546
- if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
22547
- counters.suppressedCooldown += 1;
22548
- return;
22549
- }
22550
- const recent = fired.filter((t) => timestampMs - t < DAY_MS);
22551
- if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
22552
- counters.suppressedHourly += 1;
22553
- this.rates.set(deviceId, { firedAtMs: recent });
22554
- return;
22555
- }
22556
- if (recent.length >= cfg.maxPerDay) {
22557
- counters.suppressedDaily += 1;
22558
- this.rates.set(deviceId, { firedAtMs: recent });
22559
- return;
22560
- }
22561
- if (!cfg.enabled) {
22562
- counters.wouldFire += 1;
22563
- return;
22564
- }
22565
- if (this.inFlight.has(deviceId)) {
22566
- counters.suppressedInFlight += 1;
22567
- return;
22568
- }
22569
- this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
22570
- counters.materialized += 1;
22571
- this.inFlight.add(deviceId);
22572
- this.materialize(input, counters);
22573
- }
22574
- /**
22575
- * Emit and reset a camera's window if it has anything to say. Exposed so a
22576
- * shutdown (or a test) can force the tally out rather than losing it.
22577
- */
22578
- flush(nowMs) {
22579
- for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
22580
- }
22581
- /** Drop all per-camera state for a device (unbind / device removal). */
22582
- forgetDevice(deviceId) {
22583
- this.baselines.delete(deviceId);
22584
- this.rates.delete(deviceId);
22585
- this.counters.delete(deviceId);
22586
- this.inFlight.delete(deviceId);
22587
- }
22588
- async materialize(input, counters) {
22589
- const { deviceId, timestampMs } = input;
22590
- try {
22591
- const audioLabels = buildAudioLabels(input);
22592
- await this.deps.sink.materialize({
22593
- cameraId: deviceId,
22594
- sourceDeviceId: deviceId,
22595
- source: "audio",
22596
- kind: "audio",
22597
- timestamp: timestampMs,
22598
- ...audioLabels !== void 0 ? { audioLabels } : {}
22599
- });
22600
- } catch (err) {
22601
- counters.materialized -= 1;
22602
- counters.materializeFailed += 1;
22603
- this.deps.onError?.("materializeAudioMarker", err);
22604
- this.deps.logger.warn("audio marker materialize failed", {
22605
- tags: { deviceId },
22606
- meta: { error: err instanceof Error ? err.message : String(err) }
23057
+ }
22607
23058
  });
22608
- } finally {
22609
- this.inFlight.delete(deviceId);
23059
+ return false;
22610
23060
  }
22611
23061
  }
22612
- countersFor(deviceId, atMs) {
22613
- const existing = this.counters.get(deviceId);
22614
- if (existing !== void 0) return existing;
22615
- const fresh = emptyCounters(atMs);
22616
- this.counters.set(deviceId, fresh);
22617
- return fresh;
22618
- }
22619
- maybeFlush(deviceId, nowMs) {
22620
- const c = this.counters.get(deviceId);
22621
- if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22622
- this.emitWindow(deviceId, nowMs, false);
22623
- }
22624
- /**
22625
- * One line per camera per window — but ONLY when a frame reached the
22626
- * candidate gate. A camera that heard nothing extraordinary has nothing to
22627
- * report, and a line per camera per minute forever would bury the ones that
22628
- * do. `force` still honours that: it flushes the window, it does not invent
22629
- * a reason to speak.
22630
- */
22631
- emitWindow(deviceId, nowMs, force) {
22632
- const c = this.counters.get(deviceId);
22633
- if (c === void 0) return;
22634
- if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22635
- if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
22636
- tags: { deviceId },
22637
- meta: {
22638
- enabled: c.enabled,
22639
- windowMs: nowMs - c.windowStartMs,
22640
- observed: c.observed,
22641
- notLoud: c.notLoud,
22642
- notAnomalous: c.notAnomalous,
22643
- candidates: c.candidates,
22644
- suppressedMotion: c.suppressedMotion,
22645
- suppressedCooldown: c.suppressedCooldown,
22646
- suppressedHourly: c.suppressedHourly,
22647
- suppressedDaily: c.suppressedDaily,
22648
- suppressedInFlight: c.suppressedInFlight,
22649
- wouldFire: c.wouldFire,
22650
- materialized: c.materialized,
22651
- materializeFailed: c.materializeFailed
22652
- }
22653
- });
22654
- this.counters.set(deviceId, emptyCounters(nowMs));
22655
- }
22656
23062
  };
22657
- /**
22658
- * The class heard on the anomalous frame, in the shape `Track.audioLabels`
22659
- * already defines. Using that field is what lets a marker label itself
22660
- * ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
22661
- * the audio macro vocabulary collides with existing subs (`dog` is a COCO
22662
- * animal, `siren` is a control), so a sub per audio class would put a barking
22663
- * dog in the ANIMAL lane.
22664
- */
22665
- function buildAudioLabels(input) {
22666
- if (input.className === void 0) return void 0;
22667
- return [{
22668
- label: input.className,
22669
- peakScore: input.score ?? 0,
22670
- count: 1,
22671
- firstAt: input.timestampMs,
22672
- lastAt: input.timestampMs
22673
- }];
22674
- }
22675
23063
  /** JPEG quality for the downscaled full frame — matches the crop path. */
22676
23064
  var FULL_FRAME_QUALITY = 80;
22677
23065
  /**
@@ -22772,13 +23160,19 @@ var SyntheticTrackMaterializer = class {
22772
23160
  * Fetch the linked camera's snapshot under a bounded timeout. A hanging
22773
23161
  * snapshot cap is skipped (returns null + warns) so the track still lands;
22774
23162
  * a null / failed fetch is likewise reported and skipped.
23163
+ *
23164
+ * Deliberately NOT `force`. `snapshot.cap.ts` defines `force` as the
23165
+ * operator's refresh button — *"On battery cams this WILL wake the camera —
23166
+ * accept the cost only when the user explicitly asks for it"* — and a
23167
+ * synthetic track is materialised from an EVENT, with nobody asking. Forcing
23168
+ * here walked straight past the snapshot addon's sleep gate and woke a
23169
+ * sleeping battery camera per event (roadmap 4f). Without it the cache
23170
+ * answers: ≤10 s old on a mains camera, and on a battery camera the
23171
+ * intentionally stale-but-honest frame that costs no wake.
22775
23172
  */
22776
23173
  async fetchSnapshot(cameraId) {
22777
23174
  try {
22778
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({
22779
- deviceId: cameraId,
22780
- force: true
22781
- }), this.snapshotTimeoutMs);
23175
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
22782
23176
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
22783
23177
  return snap;
22784
23178
  } catch (err) {
@@ -24678,6 +25072,478 @@ async function sweepTrackRetention(deps) {
24678
25072
  return totalTracks;
24679
25073
  }
24680
25074
  //#endregion
25075
+ //#region src/pipeline-analytics/training-export/dataset-plan.ts
25076
+ var FACE_OWNER_PREFIX = "face-";
25077
+ var PLATE_OWNER_PREFIX = "plate-";
25078
+ /**
25079
+ * Tracks one export pass will carry.
25080
+ *
25081
+ * It is the per-device pin budget (500) times the four cameras a homelab hub
25082
+ * plausibly runs, rounded — i.e. "every track that can be marked", not a
25083
+ * throttle the operator has to think about. The ceiling exists so a fleet-wide
25084
+ * export cannot become an unbounded walk if the pin budget is ever raised, and
25085
+ * `truncated` in the summary says when it bit rather than quietly shipping a
25086
+ * partial dataset as if it were the whole one.
25087
+ */
25088
+ var TRAINING_EXPORT_MAX_TRACKS = 2e3;
25089
+ /**
25090
+ * Archive path for one media file.
25091
+ *
25092
+ * The scope segment is not decoration: a track and its best object event can
25093
+ * both own a `crop` at the same millisecond, and without the segment the second
25094
+ * one would overwrite the first inside the archive — a silent loss that only
25095
+ * shows up as a dataset that is smaller than the manifest says.
25096
+ */
25097
+ function archivePathFor(params) {
25098
+ const leaf = params.singleInstance ? `${params.kind}.jpg` : `${params.kind}-${params.timestamp}.jpg`;
25099
+ const dir = params.scope === "" ? "" : `${params.scope}/`;
25100
+ return `tracks/${params.deviceId}/${params.trackId}/${dir}${leaf}`;
25101
+ }
25102
+ /**
25103
+ * A media key ends in `:<timestamp>` for the accumulating kinds and stops at the
25104
+ * kind for the single-instance ones. Reading it off the key rather than
25105
+ * re-deriving the kind set keeps this from drifting when a kind changes sides.
25106
+ */
25107
+ function isSingleInstanceKey(key, kind) {
25108
+ return key.endsWith(`:${kind}`);
25109
+ }
25110
+ /**
25111
+ * Build the plan. Index reads only — the returned plan holds no image bytes and
25112
+ * is safe to send over tRPC.
25113
+ */
25114
+ async function buildTrainingExportPlan(readers, input) {
25115
+ const tracks = [];
25116
+ const skippedPaths = [];
25117
+ let truncated = false;
25118
+ let remaining = input.maxTracks;
25119
+ for (const deviceId of input.deviceIds) {
25120
+ if (remaining <= 0) break;
25121
+ const marked = await readers.listMarkedTracks(deviceId, remaining + 1);
25122
+ if (marked.length > remaining) truncated = true;
25123
+ const take = marked.slice(0, remaining);
25124
+ remaining -= take.length;
25125
+ if (take.length === 0) continue;
25126
+ const eventLinks = await readers.listEventIdsForTracks(take.map((t) => t.trackId));
25127
+ const eventsByTrack = /* @__PURE__ */ new Map();
25128
+ for (const link of eventLinks) {
25129
+ const list = eventsByTrack.get(link.trackId);
25130
+ if (list) list.push(link.eventId);
25131
+ else eventsByTrack.set(link.trackId, [link.eventId]);
25132
+ }
25133
+ for (const track of take) {
25134
+ const files = [];
25135
+ const owners = [
25136
+ {
25137
+ ownerKind: "track",
25138
+ ownerId: track.trackId,
25139
+ scope: ""
25140
+ },
25141
+ {
25142
+ ownerKind: "face",
25143
+ ownerId: `${FACE_OWNER_PREFIX}${track.trackId}`,
25144
+ scope: "face"
25145
+ },
25146
+ {
25147
+ ownerKind: "plate",
25148
+ ownerId: `${PLATE_OWNER_PREFIX}${track.trackId}`,
25149
+ scope: "plate"
25150
+ },
25151
+ ...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
25152
+ ownerKind: "event",
25153
+ ownerId: eventId,
25154
+ scope: `events/${eventId}`
25155
+ }))
25156
+ ];
25157
+ for (const owner of owners) {
25158
+ const infos = await readers.listMediaInfo(owner.ownerKind, owner.ownerId);
25159
+ for (const info of infos) {
25160
+ const archivePath = archivePathFor({
25161
+ deviceId: track.deviceId,
25162
+ trackId: track.trackId,
25163
+ scope: owner.scope,
25164
+ kind: info.kind,
25165
+ timestamp: info.timestamp,
25166
+ singleInstance: isSingleInstanceKey(info.key, info.kind)
25167
+ });
25168
+ files.push({
25169
+ archivePath,
25170
+ mediaKey: info.key,
25171
+ kind: info.kind,
25172
+ sizeBytes: info.sizeBytes,
25173
+ timestamp: info.timestamp
25174
+ });
25175
+ }
25176
+ }
25177
+ tracks.push({
25178
+ trackId: track.trackId,
25179
+ deviceId: track.deviceId,
25180
+ className: track.className,
25181
+ ...track.label !== void 0 ? { label: track.label } : {},
25182
+ firstSeen: track.firstSeen,
25183
+ lastSeen: track.lastSeen,
25184
+ classes: track.classes ?? [],
25185
+ zonesVisited: track.zonesVisited,
25186
+ ...track.importance !== void 0 ? { importance: track.importance } : {},
25187
+ files
25188
+ });
25189
+ }
25190
+ }
25191
+ const perDevice = /* @__PURE__ */ new Map();
25192
+ let fileCount = 0;
25193
+ let byteCount = 0;
25194
+ for (const track of tracks) {
25195
+ const agg = perDevice.get(track.deviceId) ?? {
25196
+ tracks: 0,
25197
+ files: 0,
25198
+ bytes: 0
25199
+ };
25200
+ agg.tracks += 1;
25201
+ for (const file of track.files) {
25202
+ agg.files += 1;
25203
+ agg.bytes += file.sizeBytes;
25204
+ fileCount += 1;
25205
+ byteCount += file.sizeBytes;
25206
+ }
25207
+ perDevice.set(track.deviceId, agg);
25208
+ }
25209
+ return {
25210
+ generatedAt: readers.now(),
25211
+ tracks,
25212
+ totals: [...perDevice.entries()].map(([deviceId, agg]) => ({
25213
+ deviceId,
25214
+ ...agg
25215
+ })),
25216
+ trackCount: tracks.length,
25217
+ fileCount,
25218
+ byteCount,
25219
+ truncated,
25220
+ skippedPaths
25221
+ };
25222
+ }
25223
+ //#endregion
25224
+ //#region src/pipeline-analytics/training-export/tar-stream.ts
25225
+ /**
25226
+ * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
25227
+ * entry, without ever holding the archive in memory.
25228
+ *
25229
+ * ## Why hand-rolled rather than the `tar` package
25230
+ *
25231
+ * `tar` is in the workspace and is the right tool for what it does: archive a
25232
+ * set of paths under one `cwd`. This is a different job. A track's media lives
25233
+ * across several storage LOCATION roots (`eventMedia` and whatever else the
25234
+ * operator relocated to), under paths that encode nothing a training set wants —
25235
+ * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
25236
+ * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
25237
+ * of paths relative to a single cwd and gives no rename hook, so using it would
25238
+ * mean staging a whole shadow tree on disk first: a full second copy of the
25239
+ * dataset, written and then deleted, to avoid writing 100 lines.
25240
+ *
25241
+ * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
25242
+ * fully specified, and asserted against the system `tar` binary in the spec next
25243
+ * door rather than against my reading of the format.
25244
+ *
25245
+ * ## What this deliberately does NOT do
25246
+ *
25247
+ * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
25248
+ * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
25249
+ * splits a path across a 155-byte prefix and a 100-byte name, and
25250
+ * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
25251
+ * it and SAY it skipped it, rather than emitting a silently truncated entry.
25252
+ */
25253
+ var BLOCK_SIZE = 512;
25254
+ var NAME_MAX = 100;
25255
+ var PREFIX_MAX = 155;
25256
+ /**
25257
+ * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
25258
+ * be represented. The split must land on a `/`, and each half has its own cap.
25259
+ */
25260
+ function splitUstarName(name) {
25261
+ if (Buffer.byteLength(name) <= NAME_MAX) return {
25262
+ prefix: "",
25263
+ name
25264
+ };
25265
+ for (let i = name.length - 1; i >= 0; i--) {
25266
+ if (name[i] !== "/") continue;
25267
+ const head = name.slice(0, i);
25268
+ const tail = name.slice(i + 1);
25269
+ if (Buffer.byteLength(tail) > NAME_MAX) break;
25270
+ if (Buffer.byteLength(head) <= PREFIX_MAX) return {
25271
+ prefix: head,
25272
+ name: tail
25273
+ };
25274
+ }
25275
+ return null;
25276
+ }
25277
+ /** True when a path cannot be represented as a ustar entry name. */
25278
+ function tarNameTooLong(name) {
25279
+ return splitUstarName(name) === null;
25280
+ }
25281
+ function writeString(block, value, offset, length) {
25282
+ block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
25283
+ }
25284
+ /** ustar numeric field: zero-padded octal, NUL-terminated. */
25285
+ function writeOctal(block, value, offset, length) {
25286
+ const text = Math.floor(value).toString(8).padStart(length - 1, "0");
25287
+ block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
25288
+ }
25289
+ /**
25290
+ * The 512-byte header for one regular file.
25291
+ *
25292
+ * The checksum is computed with the checksum field itself read as eight spaces —
25293
+ * that is the format's rule, and getting it wrong produces an archive that looks
25294
+ * fine until something tries to read it.
25295
+ */
25296
+ function tarHeader(entry) {
25297
+ const split = splitUstarName(entry.name);
25298
+ if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
25299
+ const block = Buffer.alloc(BLOCK_SIZE);
25300
+ writeString(block, split.name, 0, NAME_MAX);
25301
+ writeOctal(block, 420, 100, 8);
25302
+ writeOctal(block, 0, 108, 8);
25303
+ writeOctal(block, 0, 116, 8);
25304
+ writeOctal(block, entry.body.length, 124, 12);
25305
+ writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
25306
+ block.write(" ", 148, 8, "ascii");
25307
+ block.write("0", 156, 1, "ascii");
25308
+ block.write("ustar\0", 257, 6, "ascii");
25309
+ block.write("00", 263, 2, "ascii");
25310
+ writeString(block, split.prefix, 345, PREFIX_MAX);
25311
+ let sum = 0;
25312
+ for (const byte of block) sum += byte;
25313
+ writeOctal(block, sum, 148, 7);
25314
+ block.write("\0", 154, 1, "ascii");
25315
+ block.write(" ", 155, 1, "ascii");
25316
+ return block;
25317
+ }
25318
+ /** Zero padding that rounds a body up to the 512-byte block. */
25319
+ function tarPadding(size) {
25320
+ const remainder = size % BLOCK_SIZE;
25321
+ return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
25322
+ }
25323
+ /** Header + body + padding for one entry, ready to write. */
25324
+ function tarBlocks(entry) {
25325
+ return [
25326
+ tarHeader(entry),
25327
+ entry.body,
25328
+ tarPadding(entry.body.length)
25329
+ ];
25330
+ }
25331
+ /** The two zero blocks that terminate an archive. */
25332
+ function tarTrailer() {
25333
+ return Buffer.alloc(BLOCK_SIZE * 2);
25334
+ }
25335
+ //#endregion
25336
+ //#region src/pipeline-analytics/training-export/training-export-stream.ts
25337
+ var MANIFEST_ENTRY_NAME = "manifest.json";
25338
+ function manifestTrack(track) {
25339
+ return {
25340
+ trackId: track.trackId,
25341
+ deviceId: track.deviceId,
25342
+ className: track.className,
25343
+ ...track.label !== void 0 ? { label: track.label } : {},
25344
+ firstSeen: track.firstSeen,
25345
+ lastSeen: track.lastSeen,
25346
+ classes: track.classes,
25347
+ zonesVisited: track.zonesVisited,
25348
+ ...track.importance !== void 0 ? { importance: track.importance } : {},
25349
+ files: track.files.map((f) => ({
25350
+ path: f.archivePath,
25351
+ kind: f.kind,
25352
+ timestamp: f.timestamp,
25353
+ sizeBytes: f.sizeBytes
25354
+ }))
25355
+ };
25356
+ }
25357
+ /**
25358
+ * The manifest body.
25359
+ *
25360
+ * `mediaKey` is deliberately NOT in it. The archive is a dataset handed to a
25361
+ * training pipeline, and a store-internal key is meaningless outside the hub
25362
+ * that issued it — worse, it invites a consumer to believe it can fetch by that
25363
+ * key later, on media the retention pin does not guarantee is still there once
25364
+ * the mark is removed. The archive path is the identity inside the archive.
25365
+ */
25366
+ function buildManifest(plan) {
25367
+ const body = {
25368
+ format: "camstack-training-export",
25369
+ version: 1,
25370
+ generatedAt: plan.generatedAt,
25371
+ selection: "tracks in retrain staging (retrainStatus: 'staging')",
25372
+ trackCount: plan.trackCount,
25373
+ fileCount: plan.fileCount,
25374
+ byteCount: plan.byteCount,
25375
+ truncated: plan.truncated,
25376
+ devices: plan.totals,
25377
+ tracks: plan.tracks.map(manifestTrack)
25378
+ };
25379
+ return Buffer.from(JSON.stringify(body, null, 2), "utf8");
25380
+ }
25381
+ /** Write the whole archive to the sink. */
25382
+ async function streamDataset(deps) {
25383
+ const { plan, sink, readBlob, logger, now, pace } = deps;
25384
+ const pacingMs = deps.pacingMs ?? 25;
25385
+ const mtimeMs = now();
25386
+ for (const block of tarBlocks({
25387
+ name: MANIFEST_ENTRY_NAME,
25388
+ body: buildManifest(plan),
25389
+ mtimeMs
25390
+ })) await sink.write(block);
25391
+ let filesWritten = 0;
25392
+ let bytesWritten = 0;
25393
+ let filesMissing = 0;
25394
+ let aborted = false;
25395
+ for (const track of plan.tracks) {
25396
+ for (const file of track.files) {
25397
+ if (deps.aborted?.() === true) {
25398
+ aborted = true;
25399
+ break;
25400
+ }
25401
+ if (tarNameTooLong(file.archivePath)) {
25402
+ filesMissing += 1;
25403
+ logger.warn("training export skipped a file — path too long for the archive", {
25404
+ tags: { deviceId: track.deviceId },
25405
+ meta: {
25406
+ deviceId: track.deviceId,
25407
+ trackId: track.trackId,
25408
+ path: file.archivePath
25409
+ }
25410
+ });
25411
+ continue;
25412
+ }
25413
+ const bytes = await readBlob(file.mediaKey);
25414
+ if (bytes === null) {
25415
+ filesMissing += 1;
25416
+ logger.warn("training export skipped a file — media unreadable", {
25417
+ tags: { deviceId: track.deviceId },
25418
+ meta: {
25419
+ deviceId: track.deviceId,
25420
+ trackId: track.trackId,
25421
+ mediaKey: file.mediaKey
25422
+ }
25423
+ });
25424
+ continue;
25425
+ }
25426
+ for (const block of tarBlocks({
25427
+ name: file.archivePath,
25428
+ body: bytes,
25429
+ mtimeMs
25430
+ })) await sink.write(block);
25431
+ filesWritten += 1;
25432
+ bytesWritten += bytes.length;
25433
+ if (pacingMs > 0) await pace(pacingMs);
25434
+ }
25435
+ if (aborted) break;
25436
+ }
25437
+ await sink.write(tarTrailer());
25438
+ await sink.end();
25439
+ logger.info("training export complete", { meta: {
25440
+ tracks: plan.trackCount,
25441
+ filesWritten,
25442
+ filesMissing,
25443
+ bytesWritten,
25444
+ truncated: plan.truncated,
25445
+ aborted
25446
+ } });
25447
+ return {
25448
+ filesWritten,
25449
+ bytesWritten,
25450
+ filesMissing,
25451
+ aborted
25452
+ };
25453
+ }
25454
+ //#endregion
25455
+ //#region src/pipeline-analytics/training-export/training-export-handler.ts
25456
+ var TRAINING_EXPORT_PREFIX = "training-export";
25457
+ /**
25458
+ * Parse `?deviceIds=615,617` into a device list. An absent or unparseable value
25459
+ * yields `[]`, which the caller reads as "every device with marked tracks" —
25460
+ * stated here rather than left to the reader because the empty array is doing
25461
+ * two jobs and only one of them is obvious.
25462
+ */
25463
+ function parseDeviceIds(url) {
25464
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
25465
+ const raw = new URLSearchParams(query).get("deviceIds");
25466
+ if (raw === null || raw.trim() === "") return [];
25467
+ const out = [];
25468
+ for (const part of raw.split(",")) {
25469
+ const n = Number.parseInt(part.trim(), 10);
25470
+ if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
25471
+ }
25472
+ return out;
25473
+ }
25474
+ /** Archive filename. Sanitised the same way the backup download is — a header
25475
+ * value carrying a CR or a quote is a response-splitting bug, not a filename. */
25476
+ function archiveFilename(nowMs) {
25477
+ return `camstack-training-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.tar`.replace(/[\r\n"]/g, "");
25478
+ }
25479
+ /** Adapt a `ServerResponse` to the sink, honouring backpressure. */
25480
+ function responseSink(res) {
25481
+ return {
25482
+ write: (chunk) => new Promise((resolve, reject) => {
25483
+ const ok = res.write(chunk, (err) => {
25484
+ if (err) reject(err);
25485
+ else if (ok) resolve();
25486
+ });
25487
+ if (!ok) res.once("drain", resolve);
25488
+ }),
25489
+ end: () => new Promise((resolve) => {
25490
+ res.end(resolve);
25491
+ })
25492
+ };
25493
+ }
25494
+ function createTrainingExportHandler(deps) {
25495
+ return async (req, res) => {
25496
+ const method = req.method ?? "GET";
25497
+ if (method !== "GET" && method !== "HEAD") {
25498
+ res.writeHead(405, { allow: "GET, HEAD" });
25499
+ res.end();
25500
+ return;
25501
+ }
25502
+ const deviceIds = parseDeviceIds(req.url ?? "");
25503
+ let plan;
25504
+ try {
25505
+ plan = await deps.buildPlan(deviceIds);
25506
+ } catch (err) {
25507
+ deps.logger.warn("training export plan failed", { meta: { error: String(err) } });
25508
+ res.writeHead(500, { "content-type": "text/plain" });
25509
+ res.end("training export plan failed");
25510
+ return;
25511
+ }
25512
+ res.writeHead(200, {
25513
+ "content-type": "application/x-tar",
25514
+ "content-disposition": `attachment; filename="${archiveFilename(deps.now())}"`,
25515
+ "cache-control": "no-store",
25516
+ "x-camstack-export-tracks": String(plan.trackCount),
25517
+ "x-camstack-export-files": String(plan.fileCount)
25518
+ });
25519
+ if (method === "HEAD") {
25520
+ res.end();
25521
+ return;
25522
+ }
25523
+ let clientGone = false;
25524
+ res.once("close", () => {
25525
+ clientGone = res.writableEnded === false;
25526
+ });
25527
+ try {
25528
+ await streamDataset({
25529
+ plan,
25530
+ sink: responseSink(res),
25531
+ readBlob: deps.readBlob,
25532
+ logger: deps.logger,
25533
+ now: deps.now,
25534
+ pace: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
25535
+ aborted: () => clientGone
25536
+ });
25537
+ } catch (err) {
25538
+ deps.logger.warn("training export stream failed mid-archive", { meta: {
25539
+ error: String(err),
25540
+ tracks: plan.trackCount
25541
+ } });
25542
+ res.destroy();
25543
+ }
25544
+ };
25545
+ }
25546
+ //#endregion
24681
25547
  //#region src/pipeline-analytics/zone-analytics-provider.ts
24682
25548
  var HISTORY_WINDOW_MS = 3600 * 1e3;
24683
25549
  /** Cap name for the runtime-state mirror — matches the cap's declared
@@ -25310,6 +26176,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
25310
26176
  retentionSweepTimer = null;
25311
26177
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
25312
26178
  eventMediaDataPlane = null;
26179
+ /** Handle for the training-export data-plane listener (dispose on shutdown). */
26180
+ trainingExportDataPlane = null;
25313
26181
  /** The NC artifact plane (signed public links for notification media) and its
25314
26182
  * data-plane handle. Null until served / when the facility is absent. */
25315
26183
  ncArtifactPlane = null;
@@ -25760,7 +26628,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
25760
26628
  logger: logger.child("EventMediaDispatcher")
25761
26629
  });
25762
26630
  this.buildRecognizers(api, logger, stores, transport);
25763
- this.buildDetailDispatcher(api, logger);
26631
+ this.buildDetailDispatcher(api, logger, transport);
25764
26632
  const capProviders = this.buildAnalyticsProviders(api, logger);
25765
26633
  await this.buildNotificationCenter(api, logger, stores);
25766
26634
  await this.serveEventMediaDataPlane();
@@ -25943,7 +26811,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
25943
26811
  let storage = this.ctx.kernel.storage;
25944
26812
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
25945
26813
  if (mediaRoot) {
25946
- const { FilesystemStorageProvider } = await import("../node-Bjc_xs4R.mjs");
26814
+ const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
25947
26815
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
25948
26816
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
25949
26817
  }
@@ -26208,7 +27076,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26208
27076
  * demand via `pipelineRunner.runDetailSubtree`. The dispatcher owns the
26209
27077
  * per-device schedulers, concurrency cap, and 1 s tick; result routing +
26210
27078
  * the pinned call + crop fallback are injected here. */
26211
- buildDetailDispatcher(api, logger) {
27079
+ buildDetailDispatcher(api, logger, transport) {
26212
27080
  const runnerApi = api.pipelineRunner;
26213
27081
  this.detailDispatcher = new TrackDetailDispatcher({
26214
27082
  logger: logger.child("DetailDispatcher"),
@@ -26218,6 +27086,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26218
27086
  return runnerApi.runDetailSubtree.mutate(input);
26219
27087
  },
26220
27088
  routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
27089
+ captureFullFrameBase64: async (frame) => {
27090
+ if (frame.frameHandle === void 0) return null;
27091
+ const full = await transport.getFullFrameJpegBase64(frame.frameHandle, frame.frameWidth);
27092
+ return full === null ? null : {
27093
+ base64: full.base64,
27094
+ width: full.width,
27095
+ height: full.height
27096
+ };
27097
+ },
26221
27098
  captureCropBase64: async (frame) => {
26222
27099
  if (frame.frameHandle === void 0 || !this.captureCrop) return null;
26223
27100
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
@@ -26443,11 +27320,99 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26443
27320
  }) ?? null;
26444
27321
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
26445
27322
  await this.serveNcArtifactPlane();
27323
+ await this.serveTrainingExportPlane();
26446
27324
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
26447
27325
  } catch (err) {
26448
27326
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
26449
27327
  }
26450
27328
  }
27329
+ /**
27330
+ * The training-export data plane. Separate listener, `access: 'admin'` —
27331
+ * marking a track is a viewer-level curation act (D81), but walking off with
27332
+ * every stored frame behind the marked set is not.
27333
+ */
27334
+ async serveTrainingExportPlane() {
27335
+ try {
27336
+ const handler = createTrainingExportHandler({
27337
+ buildPlan: (deviceIds) => this.buildTrainingExportPlan(deviceIds),
27338
+ readBlob: async (mediaKey) => {
27339
+ const file = await (this.mediaStore?.getByKey(mediaKey) ?? Promise.resolve(null));
27340
+ return file === null ? null : Buffer.from(file.base64, "base64");
27341
+ },
27342
+ logger: this.ctx.logger,
27343
+ now: () => Date.now()
27344
+ });
27345
+ this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
27346
+ prefix: "training-export",
27347
+ access: "admin",
27348
+ handler
27349
+ }) ?? null;
27350
+ } catch (err) {
27351
+ this.ctx.logger.warn("training-export data-plane failed to serve", { meta: { error: errMsg(err) } });
27352
+ }
27353
+ }
27354
+ /**
27355
+ * Plan the training export — INDEX READS ONLY. No blob is touched here, which
27356
+ * is what lets `getTrainingExportSummary` answer "how big is this" for the
27357
+ * cost of a few KB per track instead of the gigabytes the answer is about.
27358
+ *
27359
+ * An empty `deviceIds` means every device that has tracks, resolved from the
27360
+ * track store rather than from the device manager: a camera that was removed
27361
+ * can still own marked tracks, and refusing to export them because the device
27362
+ * is gone would lose exactly the material an operator kept on purpose.
27363
+ */
27364
+ async buildTrainingExportPlan(deviceIds) {
27365
+ const trackStore = this.trackStore;
27366
+ const mediaStore = this.mediaStore;
27367
+ const eventStore = this.eventStore;
27368
+ if (!trackStore || !mediaStore) throw new Error("training export: analytics stores unavailable");
27369
+ return buildTrainingExportPlan({
27370
+ listMarkedTracks: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
27371
+ listMediaInfo: async (ownerKind, ownerId) => {
27372
+ if (!isMediaOwnerKind(ownerKind)) return [];
27373
+ return mediaStore.listInfoByOwner(ownerKind, ownerId);
27374
+ },
27375
+ listEventIdsForTracks: async (trackIds) => {
27376
+ if (!eventStore || trackIds.length === 0) return [];
27377
+ return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
27378
+ eventId: e.id,
27379
+ trackId: e.trackId
27380
+ }]);
27381
+ },
27382
+ now: () => Date.now()
27383
+ }, {
27384
+ deviceIds: deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds(),
27385
+ maxTracks: TRAINING_EXPORT_MAX_TRACKS
27386
+ });
27387
+ }
27388
+ /**
27389
+ * Estimate the training export without producing it — the cheap question
27390
+ * (D56's discipline, applied to a pass that runs no inference at all).
27391
+ */
27392
+ async getTrainingExportSummary(input) {
27393
+ const plan = await this.buildTrainingExportPlan(input.deviceIds ?? []);
27394
+ this.ctx.logger.info("training export summary", { meta: {
27395
+ tracks: plan.trackCount,
27396
+ files: plan.fileCount,
27397
+ bytes: plan.byteCount,
27398
+ truncated: plan.truncated
27399
+ } });
27400
+ return {
27401
+ generatedAt: plan.generatedAt,
27402
+ trackCount: plan.trackCount,
27403
+ fileCount: plan.fileCount,
27404
+ byteCount: plan.byteCount,
27405
+ truncated: plan.truncated,
27406
+ devices: plan.totals
27407
+ };
27408
+ }
27409
+ /** Where to stream the dataset from. The bytes never come through the cap. */
27410
+ async getTrainingExportUrl(input) {
27411
+ if (this.trainingExportDataPlane === null) throw new Error("training export: data plane unavailable on this node");
27412
+ const ids = input.deviceIds ?? [];
27413
+ const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
27414
+ return { url: `/addon/${this.ctx.id}/${TRAINING_EXPORT_PREFIX}${query}` };
27415
+ }
26451
27416
  /** Object/face embedding dispatcher — migrated from the retired
26452
27417
  * enrichment-engine addon. It subscribes to DetectionResult, resolves the
26453
27418
  * frame via the runner transport, crops each qualifying detection, and
@@ -26698,6 +27663,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26698
27663
  this.eventMediaDataPlane = null;
26699
27664
  this.eventMediaBaseUrl = null;
26700
27665
  this.eventMediaDispatcher = null;
27666
+ await this.trainingExportDataPlane?.dispose();
27667
+ this.trainingExportDataPlane = null;
26701
27668
  }
26702
27669
  async handleInferenceResult(data) {
26703
27670
  if (this.shuttingDown) return;
@@ -26842,12 +27809,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26842
27809
  inPrevActive: false,
26843
27810
  positionsCount: positionsCountById.get(id) ?? 1
26844
27811
  }) === "resurrection") {
26845
- log.info("track resumed", { meta: {
26846
- trackId: id,
26847
- className: t.className,
26848
- source,
26849
- resurrected: true
26850
- } });
27812
+ log.info("track resumed", {
27813
+ tags: { deviceId },
27814
+ meta: {
27815
+ trackId: id,
27816
+ className: t.className,
27817
+ source,
27818
+ resurrected: true
27819
+ }
27820
+ });
26851
27821
  if (this.eventMediaDispatcher) {
26852
27822
  this.residents.setLastFrameAt(deviceId, id, result.timestamp);
26853
27823
  if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
@@ -26982,10 +27952,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26982
27952
  let lostTrackCount = 0;
26983
27953
  for (const id of prevIds) if (!currentTrackIds.has(id)) {
26984
27954
  lostTrackCount += 1;
26985
- log.info("track lost", { meta: {
26986
- trackId: id,
26987
- source
26988
- } });
27955
+ log.info("track lost", {
27956
+ tags: { deviceId },
27957
+ meta: {
27958
+ trackId: id,
27959
+ source
27960
+ }
27961
+ });
26989
27962
  }
26990
27963
  this.suppressedBirths.retain(key, currentTrackIds);
26991
27964
  this.deferredBirths.retain(key, currentTrackIds);
@@ -27222,6 +28195,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27222
28195
  this.residents.markFirstFrameLanded(deviceId, trackId);
27223
28196
  }
27224
28197
  for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
28198
+ const landedSnapshots = new Set(res.storedSnapshots.map((s) => s.trackId));
28199
+ for (const target of snapshotTargets) {
28200
+ if (!target.appendSnapshot || landedSnapshots.has(target.trackId)) continue;
28201
+ const gaveBack = this.trackStore?.rollbackSnapshotPending(target.trackId, target.timestamp);
28202
+ log.warn("snapshot capture did not land", {
28203
+ tags: { deviceId },
28204
+ meta: {
28205
+ trackId: target.trackId,
28206
+ timestamp: target.timestamp,
28207
+ intervalReturned: gaveBack === true
28208
+ }
28209
+ });
28210
+ }
27225
28211
  })
27226
28212
  }).catch(() => {});
27227
28213
  }
@@ -29020,8 +30006,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29020
30006
  }
29021
30007
  /**
29022
30008
  * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
29023
- * mapped sensor cap. Resolves the linked-camera set through the TTL cache
29024
- * and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
30009
+ * mapped sensor cap. Resolves the camera set through the TTL cache (the
30010
+ * device's LINKED cameras, plus the device itself when it is a camera
30011
+ * carrying the cap) and inserts ONE row per camera. Best-effort
30012
+ * (telemetry-lossy).
30013
+ *
30014
+ * D8/D11 boundary, stated plainly: the ride here is the LOSSY
30015
+ * `DeviceStateChanged` bus, and there is no reconcile that replays a missed
30016
+ * press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
30017
+ * event is a missing history row and a missing notification — never wrong
30018
+ * durable state.
29025
30019
  */
29026
30020
  async handleSensorStateChanged(data, timestamp) {
29027
30021
  const store = this.sensorEventStore;
@@ -29031,7 +30025,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29031
30025
  await ingestSensorStateChange({
29032
30026
  sink: store,
29033
30027
  cache,
29034
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
30028
+ onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
30029
+ onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
29035
30030
  }, data, timestamp);
29036
30031
  } catch (err) {
29037
30032
  this.ctx.logger.warn("sensor-event ingest failed", {
@@ -29047,7 +30042,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29047
30042
  if (materializer === null || descriptor === void 0) return;
29048
30043
  try {
29049
30044
  const cameraIds = await cache.camerasFor(data.deviceId);
29050
- if (cameraIds.length === 0) return;
30045
+ if (cameraIds.length === 0) {
30046
+ this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
30047
+ tags: { deviceId: data.deviceId },
30048
+ meta: {
30049
+ capName: data.capName,
30050
+ kind: descriptor.kind
30051
+ }
30052
+ });
30053
+ return;
30054
+ }
29051
30055
  const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
29052
30056
  for (const cameraId of cameraIds) await materializer.materialize({
29053
30057
  cameraId,
@@ -29068,6 +30072,35 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29068
30072
  }
29069
30073
  }
29070
30074
  /**
30075
+ * Say out loud that a MAPPED sensor cap ingested nothing, and why.
30076
+ *
30077
+ * BOTH branches are `debug`, deliberately. After the self-camera fix neither
30078
+ * is a surprise any more — they are the two designed steady states, and both
30079
+ * ride at bus rate:
30080
+ *
30081
+ * - `camera-native-cap` is EVERY camera's `motion` slice. Every provider
30082
+ * binds a native `motion` cap on the camera device and motion churns
30083
+ * continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
30084
+ * log flood measured in millions of lines a day — its own outage.
30085
+ * - `no-camera` is every sensor in the fleet that no camera links.
30086
+ *
30087
+ * What must never happen again is the branch being INVISIBLE: a doorbell
30088
+ * press that ingested nothing produced not one line for months. A debug line
30089
+ * carrying `deviceId`, `capName`, `kind` and `reason` answers "why is 615
30090
+ * dark" the moment anyone asks it.
30091
+ */
30092
+ logSensorIngestDrop(data, reason, kind) {
30093
+ 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";
30094
+ this.ctx.logger.debug(message, {
30095
+ tags: { deviceId: data.deviceId },
30096
+ meta: {
30097
+ capName: data.capName,
30098
+ kind,
30099
+ reason
30100
+ }
30101
+ });
30102
+ }
30103
+ /**
29071
30104
  * Resolve the NAME of a device (the linked sensor/control that produced a
29072
30105
  * synthetic event) via the device-manager cap. Best-effort — returns
29073
30106
  * undefined on any lookup failure or an unknown device so the synthetic
@@ -29784,15 +30817,22 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29784
30817
  }
29785
30818
  /**
29786
30819
  * Set the per-track operator flags (`markForTrain`, `debug`). Partial patch —
29787
- * see `applyTrackFlags`. The flags carry no behaviour yet; this is the write
29788
- * surface the three UI toggles share.
30820
+ * see `applyTrackFlags`. `markForTrain` moves the retrain LIFECYCLE
30821
+ * (`none ↔ staging`, D81), and `staging` is what PINS the track against the
30822
+ * retention sweep — which is why the body enforces a per-device staging budget
30823
+ * and refuses any move on an already-`trained` track. `debug` is attention
30824
+ * only. This is the write surface the three UI toggles share.
29789
30825
  */
29790
30826
  async setTrackFlags(input) {
29791
30827
  const trackStore = this.trackStore;
29792
30828
  if (!trackStore) throw new Error("setTrackFlags: track store unavailable");
29793
30829
  const out = await applyTrackFlags({
29794
- readFlags: (trackId) => trackStore.readFlags(trackId),
29795
- setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch)
30830
+ store: {
30831
+ readFlags: (trackId) => trackStore.readFlags(trackId),
30832
+ setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
30833
+ countStaging: (query) => trackStore.countStaging(query)
30834
+ },
30835
+ logger: this.ctx.logger
29796
30836
  }, input);
29797
30837
  this.ctx.logger.info("track operator flags set", {
29798
30838
  tags: { deviceId: input.deviceId },
@@ -29800,6 +30840,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29800
30840
  deviceId: input.deviceId,
29801
30841
  trackId: input.trackId,
29802
30842
  markForTrain: out.markForTrain,
30843
+ retrainStatus: out.retrainStatus,
29803
30844
  debug: out.debug
29804
30845
  }
29805
30846
  });
@@ -29923,6 +30964,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29923
30964
  deviceId,
29924
30965
  cutoffMs
29925
30966
  });
30967
+ const staging = await this.trackStore?.countStaging({
30968
+ deviceId,
30969
+ olderThanMs: cutoffMs
30970
+ });
30971
+ if (staging !== void 0 && staging > 0) this.ctx.logger.info("track retention held staging tracks (retrainStatus)", {
30972
+ tags: { deviceId },
30973
+ meta: {
30974
+ deviceId,
30975
+ staging,
30976
+ cutoffMs,
30977
+ deleted: counts.tracks
30978
+ }
30979
+ });
29926
30980
  if (counts.tracks > 0) this.eventsOpsLog?.append({
29927
30981
  op: "prune",
29928
30982
  reason: "retention",