@camstack/addon-post-analysis 1.2.49 → 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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BaqsyWbd.js");
5
+ const require_dist = require("../dist-Bm5GpbGn.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -1583,72 +1583,12 @@ function evaluateRule(rule, subject, deviceState) {
1583
1583
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
1584
1584
  if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
1585
1585
  }
1586
- if (!isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
1586
+ if (!require_dist.isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
1587
1587
  return {
1588
1588
  matched: true,
1589
1589
  matchedOn: presentConditionIds(c)
1590
1590
  };
1591
1591
  }
1592
- var WEEKDAY_TO_DAY = {
1593
- Sun: 0,
1594
- Mon: 1,
1595
- Tue: 2,
1596
- Wed: 3,
1597
- Thu: 4,
1598
- Fri: 5,
1599
- Sat: 6
1600
- };
1601
- /** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
1602
- * An invalid/unknown IANA name falls back to the host timezone. */
1603
- function localDayMinute(atMs, timezone) {
1604
- const d = new Date(atMs);
1605
- if (timezone !== void 0) try {
1606
- const parts = new Intl.DateTimeFormat("en-US", {
1607
- timeZone: timezone,
1608
- weekday: "short",
1609
- hour: "numeric",
1610
- minute: "numeric",
1611
- hourCycle: "h23"
1612
- }).formatToParts(d);
1613
- let weekday;
1614
- let hour;
1615
- let minute;
1616
- for (const p of parts) if (p.type === "weekday") weekday = p.value;
1617
- else if (p.type === "hour") hour = Number(p.value);
1618
- else if (p.type === "minute") minute = Number(p.value);
1619
- const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
1620
- if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
1621
- day,
1622
- minute: hour * 60 + minute
1623
- };
1624
- } catch {}
1625
- return {
1626
- day: d.getDay(),
1627
- minute: d.getHours() * 60 + d.getMinutes()
1628
- };
1629
- }
1630
- /**
1631
- * Is the schedule active at `atMs`? No schedule = always active. Windows
1632
- * are OR'd; a window with `startMinute > endMinute` crosses midnight (it
1633
- * starts on a listed day and spills into the next). `invert` flips the
1634
- * result (active OUTSIDE the windows).
1635
- */
1636
- function isScheduleActive(schedule, atMs) {
1637
- if (schedule === void 0) return true;
1638
- const { day, minute } = localDayMinute(atMs, schedule.timezone);
1639
- const prevDay = (day + 6) % 7;
1640
- let inside = false;
1641
- for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
1642
- if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
1643
- inside = true;
1644
- break;
1645
- }
1646
- } else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
1647
- inside = true;
1648
- break;
1649
- }
1650
- return schedule.invert === true ? !inside : inside;
1651
- }
1652
1592
  /** Normalize a plate string: uppercase, alphanumeric only. */
1653
1593
  function normalizePlate$1(value) {
1654
1594
  return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
@@ -13515,6 +13455,27 @@ var StoragePressureTracker = class {
13515
13455
  };
13516
13456
  //#endregion
13517
13457
  //#region src/pipeline-analytics/store/media-store.ts
13458
+ var OWNER_KINDS = [
13459
+ "event",
13460
+ "track",
13461
+ "face",
13462
+ "identity",
13463
+ "plate",
13464
+ "vehicle"
13465
+ ];
13466
+ /**
13467
+ * Narrow an arbitrary string to an `OwnerKind`.
13468
+ *
13469
+ * Exists so a caller that builds owner scopes from data (the training-export
13470
+ * plan walks `track` / `face` / `plate` / `event` as strings) can reach the
13471
+ * store without a cast. The list is derived from `OWNER_KINDS`, so a new owner
13472
+ * kind is admitted here the moment it is declared there — a hand-written value
13473
+ * list would silently reject it, which is the failure `TrackSourceSchema` was
13474
+ * already bitten by on the track row.
13475
+ */
13476
+ function isMediaOwnerKind(value) {
13477
+ return OWNER_KINDS.some((kind) => kind === value);
13478
+ }
13518
13479
  var MEDIA_COLLECTION = "pipeline-analytics:media";
13519
13480
  /** Owner-id prefix for a track's buffered FACE crop. Invariant established by
13520
13481
  * the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
@@ -14738,6 +14699,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
14738
14699
  bestEventId: bestEventId ?? t.trackId,
14739
14700
  windowMs: t.lastSeen - t.firstSeen,
14740
14701
  ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
14702
+ ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
14741
14703
  ...t.debug !== void 0 ? { debug: t.debug } : {}
14742
14704
  });
14743
14705
  }
@@ -15973,8 +15935,35 @@ function classifyTrackAppearance(input) {
15973
15935
  if (input.inPrevActive) return "continuing";
15974
15936
  return input.positionsCount > 1 ? "resurrection" : "birth";
15975
15937
  }
15976
- //#endregion
15977
- //#region src/pipeline-analytics/pipeline/track-flags.ts
15938
+ /** Thrown when the device is already holding its full staging budget. Distinct
15939
+ * type so a surface can tell "you are out of room" from "that track is gone". */
15940
+ var StagingBudgetExceededError = class extends Error {
15941
+ deviceId;
15942
+ staging;
15943
+ constructor(deviceId, staging) {
15944
+ super(`setTrackFlags: device ${deviceId} already has ${staging} tracks in retrain staging (limit 500). Un-mark some tracks before marking more.`);
15945
+ this.deviceId = deviceId;
15946
+ this.staging = staging;
15947
+ this.name = "StagingBudgetExceededError";
15948
+ }
15949
+ };
15950
+ /**
15951
+ * Thrown when a `markForTrain` write — in EITHER direction — targets a track
15952
+ * that has already been trained.
15953
+ *
15954
+ * Its own error type, next to the budget's, because the two refusals need
15955
+ * different sentences on the surface: one says "make room", the other says "this
15956
+ * track is already in the dataset". Collapsing them into a generic failure is
15957
+ * how a toggle ends up flipping back with nothing to explain it.
15958
+ */
15959
+ var TrackAlreadyTrainedError = class extends Error {
15960
+ trackId;
15961
+ constructor(trackId) {
15962
+ 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.`);
15963
+ this.trackId = trackId;
15964
+ this.name = "TrackAlreadyTrainedError";
15965
+ }
15966
+ };
15978
15967
  /**
15979
15968
  * Apply a partial flag patch and report the RESOLVED state of both flags.
15980
15969
  *
@@ -15987,19 +15976,63 @@ function classifyTrackAppearance(input) {
15987
15976
  * An empty patch is a pure read, not an error: the three calling surfaces build
15988
15977
  * the patch from their own toggle, and "nothing changed" is a legitimate outcome
15989
15978
  * that must not surface as a failure.
15990
- */
15991
- async function applyTrackFlags(store, input) {
15979
+ *
15980
+ * **`trained` is TERMINAL for this toggle, in both directions.** A trained
15981
+ * track's chosen frames are already copies inside the retrain dataset; staging
15982
+ * it again from a generic checkbox is how the same material gets annotated twice
15983
+ * under two different ground truths, and un-marking it would claim to undo a
15984
+ * training pass it cannot undo. The deliberate return to staging belongs to the
15985
+ * retrain page, which is also the only thing that produces `trained`. A `debug`
15986
+ * patch on a trained track is untouched by this — `debug` is not the lifecycle.
15987
+ *
15988
+ * **The budget is checked only when STAGING is being entered.** Un-marking is
15989
+ * always allowed and so is a `debug` patch — a limit that blocked the way OUT of
15990
+ * the limit would be a trap, and a device at its ceiling would become unable to
15991
+ * release the very storage the ceiling exists to protect. Re-marking a track
15992
+ * that is already staging is not a new hold either, so it does not consume
15993
+ * budget and cannot be refused by it.
15994
+ */
15995
+ async function applyTrackFlags(deps, input) {
15996
+ const { store, logger } = deps;
15992
15997
  const current = await store.readFlags(input.trackId);
15993
15998
  if (current === null) throw new Error(`setTrackFlags: unknown track ${input.trackId}`);
15999
+ if (input.flags.markForTrain !== void 0 && current.retrainStatus === "trained") {
16000
+ logger.warn("setTrackFlags refused — track already trained", {
16001
+ tags: { deviceId: input.deviceId },
16002
+ meta: {
16003
+ deviceId: input.deviceId,
16004
+ trackId: input.trackId,
16005
+ requested: input.flags.markForTrain
16006
+ }
16007
+ });
16008
+ throw new TrackAlreadyTrainedError(input.trackId);
16009
+ }
16010
+ if (input.flags.markForTrain === true && current.retrainStatus !== "staging") {
16011
+ const staging = await store.countStaging({ deviceId: input.deviceId });
16012
+ if (staging >= 500) {
16013
+ logger.warn("setTrackFlags refused — device staging budget full", {
16014
+ tags: { deviceId: input.deviceId },
16015
+ meta: {
16016
+ deviceId: input.deviceId,
16017
+ trackId: input.trackId,
16018
+ staging,
16019
+ limit: 500
16020
+ }
16021
+ });
16022
+ throw new StagingBudgetExceededError(input.deviceId, staging);
16023
+ }
16024
+ }
15994
16025
  const patch = {
15995
16026
  ...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
15996
16027
  ...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
15997
16028
  };
15998
16029
  if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
16030
+ const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
15999
16031
  return {
16000
16032
  trackId: input.trackId,
16001
- markForTrain: patch.markForTrain ?? current.markForTrain,
16002
- debug: patch.debug ?? current.debug
16033
+ markForTrain: retrainStatus === "staging",
16034
+ debug: patch.debug ?? current.debug,
16035
+ retrainStatus
16003
16036
  };
16004
16037
  }
16005
16038
  //#endregion
@@ -18890,8 +18923,10 @@ var TRACKS_COLUMNS = [
18890
18923
  type: "JSON"
18891
18924
  },
18892
18925
  {
18893
- name: "markForTrain",
18894
- type: "BOOLEAN"
18926
+ name: "retrainStatus",
18927
+ type: "TEXT",
18928
+ notNull: true,
18929
+ defaultValue: "none"
18895
18930
  },
18896
18931
  {
18897
18932
  name: "debug",
@@ -18968,7 +19003,10 @@ function cloneTrack(t) {
18968
19003
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
18969
19004
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
18970
19005
  ...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {},
18971
- ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
19006
+ ...t.retrainStatus !== void 0 ? {
19007
+ retrainStatus: t.retrainStatus,
19008
+ markForTrain: t.retrainStatus === "staging"
19009
+ } : {},
18972
19010
  ...t.debug !== void 0 ? { debug: t.debug } : {}
18973
19011
  };
18974
19012
  }
@@ -19250,24 +19288,30 @@ var TrackStore = class {
19250
19288
  }
19251
19289
  }
19252
19290
  /**
19253
- * Apply a PARTIAL patch of the per-track operator flags (`markForTrain`,
19291
+ * Apply a PARTIAL patch of the per-track operator state (`markForTrain`,
19254
19292
  * `debug`). Mirrors `setLabel`'s dual write — in-RAM active entry AND the
19255
- * persisted row — so a flag set on a LIVE track is carried into
19293
+ * persisted row — so a mark set on a LIVE track is carried into
19256
19294
  * `persistCompleted` at expiry instead of evaporating with the RAM state.
19257
19295
  *
19296
+ * `markForTrain` is a LIFECYCLE move, not a boolean write: `true` is
19297
+ * `→ staging`, `false` is `→ none`. This method does not police the move —
19298
+ * the `trained` refusal lives in `applyTrackFlags`, the single cap-level write
19299
+ * surface, which is the only caller that has already read the current state.
19300
+ *
19258
19301
  * An omitted key is left untouched; `false` is WRITTEN, not dropped, so an
19259
- * explicitly un-flagged track stays distinguishable from one never touched.
19302
+ * explicitly un-marked track stays distinguishable from one never touched.
19260
19303
  * An empty patch is a no-op (an update with no columns is not a useful
19261
19304
  * statement, and the store would reject or silently ignore it).
19262
19305
  */
19263
19306
  async setFlags(trackId, patch) {
19264
19307
  const data = {};
19265
- if (patch.markForTrain !== void 0) data["markForTrain"] = patch.markForTrain;
19308
+ const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
19309
+ if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
19266
19310
  if (patch.debug !== void 0) data["debug"] = patch.debug;
19267
19311
  if (Object.keys(data).length === 0) return;
19268
19312
  const active = this.active.get(trackId);
19269
19313
  if (active) {
19270
- if (patch.markForTrain !== void 0) active.markForTrain = patch.markForTrain;
19314
+ if (nextStatus !== void 0) active.retrainStatus = nextStatus;
19271
19315
  if (patch.debug !== void 0) active.debug = patch.debug;
19272
19316
  }
19273
19317
  try {
@@ -19284,20 +19328,25 @@ var TrackStore = class {
19284
19328
  }
19285
19329
  }
19286
19330
  /**
19287
- * Resolved flag state for a track — active entry first, then the persisted
19288
- * row. Absent collapses to `false`, which is what the cap returns to a caller
19289
- * driving a toggle. `null` when the track is unknown to both.
19331
+ * Resolved operator state for a track — active entry first, then the
19332
+ * persisted row. `null` when the track is unknown to both.
19333
+ *
19334
+ * Reports the LIFECYCLE STATUS, not the derived boolean, because the one
19335
+ * caller that matters decides on it: `applyTrackFlags` must tell `trained`
19336
+ * (refuse the write) from `none` (accept it), and both report
19337
+ * `markForTrain: false`. Absent collapses to `'none'` / `false` — the
19338
+ * "never touched" tri-state exists only in RAM and carries no decision.
19290
19339
  */
19291
19340
  async readFlags(trackId) {
19292
19341
  const active = this.active.get(trackId);
19293
19342
  if (active) return {
19294
- markForTrain: active.markForTrain === true,
19343
+ retrainStatus: active.retrainStatus ?? "none",
19295
19344
  debug: active.debug === true
19296
19345
  };
19297
19346
  const persisted = await this.getPersistedByTrackId(trackId);
19298
19347
  if (!persisted) return null;
19299
19348
  return {
19300
- markForTrain: persisted.markForTrain === true,
19349
+ retrainStatus: persisted.retrainStatus ?? "none",
19301
19350
  debug: persisted.debug === true
19302
19351
  };
19303
19352
  }
@@ -19362,10 +19411,11 @@ var TrackStore = class {
19362
19411
  * `offset` by the number of rows a page did NOT remove, which terminates
19363
19412
  * because the offset only grows and the result set is finite.
19364
19413
  *
19365
- * This is the seam a retention PIN needs. Pinning is now one exemption on one
19366
- * query — this one — instead of four exemptions across four sweeps, and the
19367
- * drain no longer breaks when it meets a row it must skip. The pin itself is
19368
- * deliberately NOT implemented here (D61).
19414
+ * This is the seam the retention PIN lands on, and it lands here ONLY: after
19415
+ * D61 the track row is the single cascading root, so one exemption on this one
19416
+ * query holds the track's events, crops, keyframes and CLIP vector with it —
19417
+ * where D59 had to plan for four exemptions across four disagreeing sweeps.
19418
+ * The pin is `whereIn: { retrainStatus: ['none','trained'] }` below (D81).
19369
19419
  *
19370
19420
  * Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
19371
19421
  * → strictly `< cutoffMs`. Best-effort — a query failure yields [].
@@ -19376,6 +19426,7 @@ var TrackStore = class {
19376
19426
  collection: TRACKS_COLLECTION,
19377
19427
  filter: {
19378
19428
  where: { deviceId },
19429
+ whereIn: { retrainStatus: ["none", "trained"] },
19379
19430
  whereBetween: { lastSeen: [0, cutoffMs - 1] },
19380
19431
  orderBy: {
19381
19432
  field: "lastSeen",
@@ -19399,6 +19450,87 @@ var TrackStore = class {
19399
19450
  }
19400
19451
  }
19401
19452
  /**
19453
+ * The device's STAGING tracks, newest first — the training-export selection.
19454
+ *
19455
+ * `retrainStatus = 'staging'` is exactly the complement of the sweep's
19456
+ * `whereIn ['none','trained']` (all three safe against NULL because the column
19457
+ * is NOT NULL DEFAULT 'none') — the load-bearing part: this is the set
19458
+ * retention has promised to still be there. `trained` is deliberately NOT in
19459
+ * it: the sweep is free to delete a trained track mid-pass, and exporting one
19460
+ * is how a manifest ends up describing files the archive does not contain.
19461
+ *
19462
+ * `projection: 'slim'` because an export enumerates media; it has no use for
19463
+ * the per-frame trajectory JSON, which is the heaviest column on the row.
19464
+ */
19465
+ async listStaging(deviceId, limit) {
19466
+ try {
19467
+ return (await this.store.query.query({
19468
+ collection: TRACKS_COLLECTION,
19469
+ filter: {
19470
+ where: {
19471
+ deviceId,
19472
+ retrainStatus: "staging"
19473
+ },
19474
+ orderBy: {
19475
+ field: "lastSeen",
19476
+ direction: "desc"
19477
+ },
19478
+ limit
19479
+ }
19480
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
19481
+ } catch (err) {
19482
+ this.logger.warn("TrackStore.listStaging failed", {
19483
+ tags: { deviceId },
19484
+ meta: {
19485
+ deviceId,
19486
+ limit,
19487
+ error: String(err)
19488
+ }
19489
+ });
19490
+ return [];
19491
+ }
19492
+ }
19493
+ /**
19494
+ * How many of this device's tracks are STAGING — optionally only those older
19495
+ * than a cutoff, i.e. exactly the rows a sweep just declined to touch.
19496
+ *
19497
+ * Two callers, one query. The sweep uses `olderThanMs` for its report; the
19498
+ * `setTrackFlags` body uses the unbounded form to enforce the per-device
19499
+ * staging budget, because staging is unbounded in TIME by construction and
19500
+ * something has to bound it in COUNT (D81, and D59 asked for exactly this).
19501
+ * `trained` tracks are NOT counted — they are evictable again, so they cost
19502
+ * the budget nothing.
19503
+ *
19504
+ * Best-effort — a failure reports 0 rather than disturbing the caller. The
19505
+ * budget check reads that as "there is room", which is the safe direction: a
19506
+ * failed count must not stop an operator marking a track.
19507
+ */
19508
+ async countStaging(query) {
19509
+ const { deviceId, olderThanMs } = query;
19510
+ try {
19511
+ return await this.store.count.query({
19512
+ collection: TRACKS_COLLECTION,
19513
+ filter: {
19514
+ where: {
19515
+ deviceId,
19516
+ retrainStatus: "staging"
19517
+ },
19518
+ ...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
19519
+ }
19520
+ });
19521
+ } catch (err) {
19522
+ this.logger.warn("TrackStore.countStaging failed", {
19523
+ tags: { deviceId },
19524
+ meta: {
19525
+ deviceId,
19526
+ olderThanMs,
19527
+ error: String(err)
19528
+ }
19529
+ });
19530
+ return 0;
19531
+ }
19532
+ }
19533
+ /**
19402
19534
  * The SUBSET of `trackIds` that still have a persisted row — the orphan
19403
19535
  * audit's liveness probe.
19404
19536
  *
@@ -19738,7 +19870,7 @@ var TrackStore = class {
19738
19870
  ...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
19739
19871
  ...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
19740
19872
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
19741
- ...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
19873
+ ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
19742
19874
  ...t.debug !== void 0 ? { debug: t.debug } : {},
19743
19875
  ...envelope !== null && dims !== void 0 ? {
19744
19876
  envMinX: envelope.minX,
@@ -19772,7 +19904,7 @@ var TrackStore = class {
19772
19904
  const bestEventId = data["bestEventId"];
19773
19905
  const importanceReason = data["importanceReason"];
19774
19906
  const audioLabels = data["audioLabels"];
19775
- const markForTrain = data["markForTrain"];
19907
+ const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
19776
19908
  const debug = data["debug"];
19777
19909
  const envMinX = data["envMinX"];
19778
19910
  const envMinY = data["envMinY"];
@@ -19804,7 +19936,10 @@ var TrackStore = class {
19804
19936
  ...typeof bestEventId === "string" ? { bestEventId } : {},
19805
19937
  ...typeof importanceReason === "string" ? { importanceReason } : {},
19806
19938
  ...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
19807
- ...typeof markForTrain === "boolean" ? { markForTrain } : {},
19939
+ ...retrainParsed.success ? {
19940
+ retrainStatus: retrainParsed.data,
19941
+ markForTrain: retrainParsed.data === "staging"
19942
+ } : {},
19808
19943
  ...typeof debug === "boolean" ? { debug } : {},
19809
19944
  ...envelope !== null ? { envelope } : {}
19810
19945
  };
@@ -21683,120 +21818,415 @@ function wireSubscriptions(deps) {
21683
21818
  }));
21684
21819
  return unsubs;
21685
21820
  }
21686
- //#endregion
21687
- //#region src/pipeline-analytics/services/event-kinds.ts
21688
21821
  /**
21689
- * Extensible per-device event kinds (Part B).
21690
- *
21691
- * `composeEventKinds` builds the `listEventKinds` payload for a camera:
21692
- * (a) built-ins — motion + audio, always present;
21693
- * (b) detection classes actually OBSERVED on the device (track history);
21694
- * (c) sensor kinds contributed by the camera's OWN bindings and by its
21695
- * LINKED devices (device-manager `getLinkedDevices`), one descriptor per
21696
- * bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
21697
- * Binding-driven per device (`getBindings`) — never a global cap
21698
- * enumeration (D12).
21699
- *
21700
- * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
21701
- * linked camera ids, plus the camera id set) with a TTL, so the
21702
- * `DeviceStateChanged` handler stays cheap at bus rate.
21822
+ * A baseline whose last sample is older than `baselineMs × this` no longer
21823
+ * describes the camera — an on-motion camera detaches for hours and comes
21824
+ * back to a different scene. Decaying it forward would make the first frame
21825
+ * back either a guaranteed marker (mean still holds the old quiet) or a
21826
+ * guaranteed miss; discarding it is the honest option and costs one warm-up.
21703
21827
  */
21828
+ var STALE_BASELINE_FACTOR = 5;
21704
21829
  /**
21705
- * Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
21706
- *
21707
- * A sensor cap bound on a camera makes the camera both the source and the
21708
- * subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
21709
- * camera-side button. Those have no other route into the per-camera event
21710
- * substrate, so the camera includes itself.
21711
- *
21712
- * `motion` is the exception, and today the ONLY one: camera motion already
21713
- * has a dedicated camera-native path (`MotionAnalysis` /
21714
- * `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
21715
- * taxonomy lane). Every real camera provider registers a native `motion` cap
21716
- * on the camera device, so self-inclusion would mint a SECOND, differently
21717
- * named copy (`motion-sensor`) of every motion transition, at motion rate.
21718
- *
21719
- * Add a cap here only when the camera already emits that event through
21720
- * another ingest path — never to reduce noise.
21830
+ * Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
21831
+ * has MAD 0 and would make every deviation infinite. 1 dB is below the
21832
+ * quantisation of any real level meter, so it only ever binds on a synthetic
21833
+ * or a genuinely dead-steady signal — where bar 2 is what should decide.
21721
21834
  */
21722
- var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
21723
- /** True when a camera may contribute `capName` for itself. */
21724
- function selfCameraMayEmit(capName) {
21725
- return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
21835
+ var MIN_MAD_DB = 1;
21836
+ function emptyAudioBaseline() {
21837
+ return {
21838
+ count: 0,
21839
+ mean: 0,
21840
+ mad: 0,
21841
+ firstMs: 0,
21842
+ lastMs: 0
21843
+ };
21844
+ }
21845
+ /** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
21846
+ function seed(sample) {
21847
+ return {
21848
+ count: 1,
21849
+ mean: sample.dbfs,
21850
+ mad: 0,
21851
+ firstMs: sample.timestampMs,
21852
+ lastMs: sample.timestampMs
21853
+ };
21726
21854
  }
21727
21855
  /**
21728
- * Full event-kind taxonomy a camera CAN emit (config-derived):
21729
- * (a) motion built-in — always;
21730
- * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
21731
- * (c) audio macro + its subs when the classifier is enabled;
21732
- * (d) package kinds when a package zone rule is enabled;
21733
- * (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
21734
- * LINKED devices (binding-driven), deduped per (kind, source deviceId).
21735
- * Every descriptor carries parentKind/level/iconId/labelKey via the single
21736
- * taxonomy dictionary — no color/icon is declared here.
21737
- */
21738
- /**
21739
- * Run one lookup, reporting a failure instead of propagating it.
21856
+ * Fold `sample` into `state` and decide whether it is a marker candidate.
21740
21857
  *
21741
- * `undefined` means "this one did not answer" — the caller degrades that part
21742
- * of the taxonomy and keeps the rest. That is the pre-existing contract (each
21743
- * section had its own try/catch); it is factored out here only because the
21744
- * sections now run concurrently, and a rejected promise nobody has awaited yet
21745
- * is an unhandled rejection.
21858
+ * The verdict is measured against the PRIOR baseline (this frame excluded) so
21859
+ * a lone spike is not diluted by itself — the same reasoning
21860
+ * `audio-level-detector` uses for its `priorMean`. The state returned always
21861
+ * includes the sample, whatever the verdict.
21746
21862
  */
21747
- async function attempt(deps, scope, run) {
21748
- try {
21749
- return await run();
21750
- } catch (err) {
21751
- deps.onError?.(scope, err);
21752
- return;
21753
- }
21754
- }
21755
- async function composeEventKinds(deps, deviceId) {
21756
- const out = [];
21757
- const cameraSource = {
21758
- capName: "pipeline-analytics",
21759
- deviceId
21863
+ function observeAudioAnomaly(state, sample, cfg) {
21864
+ const isFirst = state.count === 0;
21865
+ const gapMs = sample.timestampMs - state.lastMs;
21866
+ const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
21867
+ if (isFirst || isStale) return {
21868
+ verdict: { kind: "warmup" },
21869
+ nextState: seed(sample)
21760
21870
  };
21761
- const pushKind = (kind, source = cameraSource) => {
21762
- const d = require_dist.buildEventKindDescriptor(kind, source);
21763
- if (d !== null) out.push(d);
21871
+ const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
21872
+ const priorMean = state.mean;
21873
+ const priorMad = state.mad;
21874
+ const nextState = {
21875
+ count: state.count + 1,
21876
+ mean: priorMean + alpha * (sample.dbfs - priorMean),
21877
+ mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
21878
+ firstMs: state.firstMs,
21879
+ lastMs: sample.timestampMs
21764
21880
  };
21765
- const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
21766
- const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
21767
- const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
21768
- const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
21769
- const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
21770
- pushKind("motion");
21771
- for (const macro of await macrosP ?? []) {
21772
- pushKind(macro);
21773
- for (const sub of require_dist.subKindsOf(macro)) pushKind(sub.kind);
21774
- }
21775
- if (await audioP === true) {
21776
- pushKind("audio");
21777
- for (const sub of require_dist.subKindsOf("audio")) pushKind(sub.kind);
21778
- }
21779
- if (await packageP === true) {
21780
- pushKind("package");
21781
- for (const sub of require_dist.subKindsOf("package")) pushKind(sub.kind);
21782
- }
21783
- const devices = (await linkedP)?.devices ?? [];
21784
- const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
21785
- const seen = /* @__PURE__ */ new Set();
21786
- const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
21787
- if (entries === void 0) return;
21788
- for (const { capName } of entries) {
21789
- if (isSelf && !selfCameraMayEmit(capName)) continue;
21790
- const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
21791
- if (descriptor === void 0) continue;
21792
- const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
21793
- if (seen.has(dedupeKey)) continue;
21794
- seen.add(dedupeKey);
21795
- pushKind(descriptor.kind, {
21796
- capName,
21797
- deviceId: sourceDeviceId
21798
- });
21799
- }
21881
+ if (sample.dbfs <= cfg.silenceFloorDbfs) return {
21882
+ verdict: { kind: "silent" },
21883
+ nextState
21884
+ };
21885
+ if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
21886
+ verdict: { kind: "warmup" },
21887
+ nextState
21888
+ };
21889
+ if (sample.dbfs < cfg.minDbfs) return {
21890
+ verdict: {
21891
+ kind: "not-loud",
21892
+ dbfs: sample.dbfs
21893
+ },
21894
+ nextState
21895
+ };
21896
+ const deviationDb = sample.dbfs - priorMean;
21897
+ const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
21898
+ const measured = {
21899
+ dbfs: sample.dbfs,
21900
+ mean: priorMean,
21901
+ deviationDb,
21902
+ deviations
21903
+ };
21904
+ return {
21905
+ verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
21906
+ kind: "anomalous",
21907
+ ...measured
21908
+ } : {
21909
+ kind: "not-anomalous",
21910
+ ...measured
21911
+ },
21912
+ nextState
21913
+ };
21914
+ }
21915
+ //#endregion
21916
+ //#region src/pipeline-analytics/services/audio-marker-projector.ts
21917
+ /** One info line per camera per minute, and only when something qualified. */
21918
+ var METRICS_WINDOW_MS = 6e4;
21919
+ var HOUR_MS = 60 * 6e4;
21920
+ var DAY_MS = 24 * HOUR_MS;
21921
+ function emptyCounters(atMs) {
21922
+ return {
21923
+ windowStartMs: atMs,
21924
+ enabled: false,
21925
+ observed: 0,
21926
+ notLoud: 0,
21927
+ notAnomalous: 0,
21928
+ candidates: 0,
21929
+ suppressedMotion: 0,
21930
+ suppressedCooldown: 0,
21931
+ suppressedHourly: 0,
21932
+ suppressedDaily: 0,
21933
+ suppressedInFlight: 0,
21934
+ wouldFire: 0,
21935
+ materialized: 0,
21936
+ materializeFailed: 0
21937
+ };
21938
+ }
21939
+ var AudioMarkerProjector = class {
21940
+ deps;
21941
+ baselines = /* @__PURE__ */ new Map();
21942
+ rates = /* @__PURE__ */ new Map();
21943
+ counters = /* @__PURE__ */ new Map();
21944
+ inFlight = /* @__PURE__ */ new Set();
21945
+ constructor(deps) {
21946
+ this.deps = deps;
21947
+ }
21948
+ /**
21949
+ * Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
21950
+ * the common path must not allocate a promise, so the decision is taken
21951
+ * inline and only an actual marker goes async (fire-and-forget, bounded by
21952
+ * the in-flight guard and the rate ceilings).
21953
+ *
21954
+ * Call this BEFORE the classification/level route split so the baseline
21955
+ * sees every frame. A baseline fed only by the level route is blind to
21956
+ * exactly the loud, confidently-classified frames a marker is for.
21957
+ */
21958
+ observe(input, cfg) {
21959
+ const { deviceId, timestampMs } = input;
21960
+ const counters = this.countersFor(deviceId, timestampMs);
21961
+ counters.observed += 1;
21962
+ counters.enabled = cfg.enabled;
21963
+ const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
21964
+ timestampMs,
21965
+ dbfs: input.dbfs
21966
+ }, cfg);
21967
+ this.baselines.set(deviceId, res.nextState);
21968
+ this.maybeFlush(deviceId, timestampMs);
21969
+ switch (res.verdict.kind) {
21970
+ case "silent":
21971
+ case "warmup": return;
21972
+ case "not-loud":
21973
+ counters.notLoud += 1;
21974
+ return;
21975
+ case "not-anomalous":
21976
+ counters.notAnomalous += 1;
21977
+ return;
21978
+ case "anomalous": break;
21979
+ }
21980
+ counters.candidates += 1;
21981
+ if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
21982
+ counters.suppressedMotion += 1;
21983
+ return;
21984
+ }
21985
+ const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
21986
+ const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
21987
+ if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
21988
+ counters.suppressedCooldown += 1;
21989
+ return;
21990
+ }
21991
+ const recent = fired.filter((t) => timestampMs - t < DAY_MS);
21992
+ if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
21993
+ counters.suppressedHourly += 1;
21994
+ this.rates.set(deviceId, { firedAtMs: recent });
21995
+ return;
21996
+ }
21997
+ if (recent.length >= cfg.maxPerDay) {
21998
+ counters.suppressedDaily += 1;
21999
+ this.rates.set(deviceId, { firedAtMs: recent });
22000
+ return;
22001
+ }
22002
+ if (!cfg.enabled) {
22003
+ counters.wouldFire += 1;
22004
+ return;
22005
+ }
22006
+ if (this.inFlight.has(deviceId)) {
22007
+ counters.suppressedInFlight += 1;
22008
+ return;
22009
+ }
22010
+ this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
22011
+ counters.materialized += 1;
22012
+ this.inFlight.add(deviceId);
22013
+ this.materialize(input, counters);
22014
+ }
22015
+ /**
22016
+ * Emit and reset a camera's window if it has anything to say. Exposed so a
22017
+ * shutdown (or a test) can force the tally out rather than losing it.
22018
+ */
22019
+ flush(nowMs) {
22020
+ for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
22021
+ }
22022
+ /** Drop all per-camera state for a device (unbind / device removal). */
22023
+ forgetDevice(deviceId) {
22024
+ this.baselines.delete(deviceId);
22025
+ this.rates.delete(deviceId);
22026
+ this.counters.delete(deviceId);
22027
+ this.inFlight.delete(deviceId);
22028
+ }
22029
+ async materialize(input, counters) {
22030
+ const { deviceId, timestampMs } = input;
22031
+ try {
22032
+ const audioLabels = buildAudioLabels(input);
22033
+ await this.deps.sink.materialize({
22034
+ cameraId: deviceId,
22035
+ sourceDeviceId: deviceId,
22036
+ source: "audio",
22037
+ kind: "audio",
22038
+ timestamp: timestampMs,
22039
+ ...audioLabels !== void 0 ? { audioLabels } : {}
22040
+ });
22041
+ } catch (err) {
22042
+ counters.materialized -= 1;
22043
+ counters.materializeFailed += 1;
22044
+ this.deps.onError?.("materializeAudioMarker", err);
22045
+ this.deps.logger.warn("audio marker materialize failed", {
22046
+ tags: { deviceId },
22047
+ meta: { error: err instanceof Error ? err.message : String(err) }
22048
+ });
22049
+ } finally {
22050
+ this.inFlight.delete(deviceId);
22051
+ }
22052
+ }
22053
+ countersFor(deviceId, atMs) {
22054
+ const existing = this.counters.get(deviceId);
22055
+ if (existing !== void 0) return existing;
22056
+ const fresh = emptyCounters(atMs);
22057
+ this.counters.set(deviceId, fresh);
22058
+ return fresh;
22059
+ }
22060
+ maybeFlush(deviceId, nowMs) {
22061
+ const c = this.counters.get(deviceId);
22062
+ if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22063
+ this.emitWindow(deviceId, nowMs, false);
22064
+ }
22065
+ /**
22066
+ * One line per camera per window — but ONLY when a frame reached the
22067
+ * candidate gate. A camera that heard nothing extraordinary has nothing to
22068
+ * report, and a line per camera per minute forever would bury the ones that
22069
+ * do. `force` still honours that: it flushes the window, it does not invent
22070
+ * a reason to speak.
22071
+ */
22072
+ emitWindow(deviceId, nowMs, force) {
22073
+ const c = this.counters.get(deviceId);
22074
+ if (c === void 0) return;
22075
+ if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22076
+ if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
22077
+ tags: { deviceId },
22078
+ meta: {
22079
+ enabled: c.enabled,
22080
+ windowMs: nowMs - c.windowStartMs,
22081
+ observed: c.observed,
22082
+ notLoud: c.notLoud,
22083
+ notAnomalous: c.notAnomalous,
22084
+ candidates: c.candidates,
22085
+ suppressedMotion: c.suppressedMotion,
22086
+ suppressedCooldown: c.suppressedCooldown,
22087
+ suppressedHourly: c.suppressedHourly,
22088
+ suppressedDaily: c.suppressedDaily,
22089
+ suppressedInFlight: c.suppressedInFlight,
22090
+ wouldFire: c.wouldFire,
22091
+ materialized: c.materialized,
22092
+ materializeFailed: c.materializeFailed
22093
+ }
22094
+ });
22095
+ this.counters.set(deviceId, emptyCounters(nowMs));
22096
+ }
22097
+ };
22098
+ /**
22099
+ * The class heard on the anomalous frame, in the shape `Track.audioLabels`
22100
+ * already defines. Using that field is what lets a marker label itself
22101
+ * ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
22102
+ * the audio macro vocabulary collides with existing subs (`dog` is a COCO
22103
+ * animal, `siren` is a control), so a sub per audio class would put a barking
22104
+ * dog in the ANIMAL lane.
22105
+ */
22106
+ function buildAudioLabels(input) {
22107
+ if (input.className === void 0) return void 0;
22108
+ return [{
22109
+ label: input.className,
22110
+ peakScore: input.score ?? 0,
22111
+ count: 1,
22112
+ firstAt: input.timestampMs,
22113
+ lastAt: input.timestampMs
22114
+ }];
22115
+ }
22116
+ //#endregion
22117
+ //#region src/pipeline-analytics/services/event-kinds.ts
22118
+ /**
22119
+ * Extensible per-device event kinds (Part B).
22120
+ *
22121
+ * `composeEventKinds` builds the `listEventKinds` payload for a camera:
22122
+ * (a) built-ins — motion + audio, always present;
22123
+ * (b) detection classes actually OBSERVED on the device (track history);
22124
+ * (c) sensor kinds contributed by the camera's OWN bindings and by its
22125
+ * LINKED devices (device-manager `getLinkedDevices`), one descriptor per
22126
+ * bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
22127
+ * Binding-driven per device (`getBindings`) — never a global cap
22128
+ * enumeration (D12).
22129
+ *
22130
+ * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
22131
+ * linked camera ids, plus the camera id set) with a TTL, so the
22132
+ * `DeviceStateChanged` handler stays cheap at bus rate.
22133
+ */
22134
+ /**
22135
+ * Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
22136
+ *
22137
+ * A sensor cap bound on a camera makes the camera both the source and the
22138
+ * subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
22139
+ * camera-side button. Those have no other route into the per-camera event
22140
+ * substrate, so the camera includes itself.
22141
+ *
22142
+ * `motion` is the exception, and today the ONLY one: camera motion already
22143
+ * has a dedicated camera-native path (`MotionAnalysis` /
22144
+ * `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
22145
+ * taxonomy lane). Every real camera provider registers a native `motion` cap
22146
+ * on the camera device, so self-inclusion would mint a SECOND, differently
22147
+ * named copy (`motion-sensor`) of every motion transition, at motion rate.
22148
+ *
22149
+ * Add a cap here only when the camera already emits that event through
22150
+ * another ingest path — never to reduce noise.
22151
+ */
22152
+ var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
22153
+ /** True when a camera may contribute `capName` for itself. */
22154
+ function selfCameraMayEmit(capName) {
22155
+ return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
22156
+ }
22157
+ /**
22158
+ * Full event-kind taxonomy a camera CAN emit (config-derived):
22159
+ * (a) motion built-in — always;
22160
+ * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
22161
+ * (c) audio macro + its subs when the classifier is enabled;
22162
+ * (d) package kinds when a package zone rule is enabled;
22163
+ * (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
22164
+ * LINKED devices (binding-driven), deduped per (kind, source deviceId).
22165
+ * Every descriptor carries parentKind/level/iconId/labelKey via the single
22166
+ * taxonomy dictionary — no color/icon is declared here.
22167
+ */
22168
+ /**
22169
+ * Run one lookup, reporting a failure instead of propagating it.
22170
+ *
22171
+ * `undefined` means "this one did not answer" — the caller degrades that part
22172
+ * of the taxonomy and keeps the rest. That is the pre-existing contract (each
22173
+ * section had its own try/catch); it is factored out here only because the
22174
+ * sections now run concurrently, and a rejected promise nobody has awaited yet
22175
+ * is an unhandled rejection.
22176
+ */
22177
+ async function attempt(deps, scope, run) {
22178
+ try {
22179
+ return await run();
22180
+ } catch (err) {
22181
+ deps.onError?.(scope, err);
22182
+ return;
22183
+ }
22184
+ }
22185
+ async function composeEventKinds(deps, deviceId) {
22186
+ const out = [];
22187
+ const cameraSource = {
22188
+ capName: "pipeline-analytics",
22189
+ deviceId
22190
+ };
22191
+ const pushKind = (kind, source = cameraSource) => {
22192
+ const d = require_dist.buildEventKindDescriptor(kind, source);
22193
+ if (d !== null) out.push(d);
22194
+ };
22195
+ const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
22196
+ const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
22197
+ const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
22198
+ const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
22199
+ const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
22200
+ pushKind("motion");
22201
+ for (const macro of await macrosP ?? []) {
22202
+ pushKind(macro);
22203
+ for (const sub of require_dist.subKindsOf(macro)) pushKind(sub.kind);
22204
+ }
22205
+ if (await audioP === true) {
22206
+ pushKind("audio");
22207
+ for (const sub of require_dist.subKindsOf("audio")) pushKind(sub.kind);
22208
+ }
22209
+ if (await packageP === true) {
22210
+ pushKind("package");
22211
+ for (const sub of require_dist.subKindsOf("package")) pushKind(sub.kind);
22212
+ }
22213
+ const devices = (await linkedP)?.devices ?? [];
22214
+ const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
22215
+ const seen = /* @__PURE__ */ new Set();
22216
+ const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
22217
+ if (entries === void 0) return;
22218
+ for (const { capName } of entries) {
22219
+ if (isSelf && !selfCameraMayEmit(capName)) continue;
22220
+ const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
22221
+ if (descriptor === void 0) continue;
22222
+ const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
22223
+ if (seen.has(dedupeKey)) continue;
22224
+ seen.add(dedupeKey);
22225
+ pushKind(descriptor.kind, {
22226
+ capName,
22227
+ deviceId: sourceDeviceId
22228
+ });
22229
+ }
21800
22230
  };
21801
22231
  pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
21802
22232
  for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
@@ -22603,334 +23033,39 @@ var EventMediaDispatcher = class {
22603
23033
  this.deps.logger.warn(`event media: ${child.kind} failed`, {
22604
23034
  tags: { deviceId },
22605
23035
  meta: {
22606
- deviceId,
22607
- eventId: ev.eventId,
22608
- error: err instanceof Error ? err.message : String(err)
22609
- }
22610
- });
22611
- }
22612
- }
22613
- async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
22614
- const boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, tf.trackId, tf.bbox, tf.label);
22615
- if (!boxed) return false;
22616
- try {
22617
- await this.deps.mediaStore.put({
22618
- deviceId,
22619
- ownerKind: "track",
22620
- ownerId: tf.trackId,
22621
- kind: "firstFrame",
22622
- timestamp: tf.timestamp,
22623
- data: boxed
22624
- });
22625
- return true;
22626
- } catch (err) {
22627
- this.deps.logger.warn("event media: track frame failed", {
22628
- tags: { deviceId },
22629
- meta: {
22630
- deviceId,
22631
- trackId: tf.trackId,
22632
- error: err instanceof Error ? err.message : String(err)
22633
- }
22634
- });
22635
- return false;
22636
- }
22637
- }
22638
- };
22639
- /**
22640
- * A baseline whose last sample is older than `baselineMs × this` no longer
22641
- * describes the camera — an on-motion camera detaches for hours and comes
22642
- * back to a different scene. Decaying it forward would make the first frame
22643
- * back either a guaranteed marker (mean still holds the old quiet) or a
22644
- * guaranteed miss; discarding it is the honest option and costs one warm-up.
22645
- */
22646
- var STALE_BASELINE_FACTOR = 5;
22647
- /**
22648
- * Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
22649
- * has MAD 0 and would make every deviation infinite. 1 dB is below the
22650
- * quantisation of any real level meter, so it only ever binds on a synthetic
22651
- * or a genuinely dead-steady signal — where bar 2 is what should decide.
22652
- */
22653
- var MIN_MAD_DB = 1;
22654
- function emptyAudioBaseline() {
22655
- return {
22656
- count: 0,
22657
- mean: 0,
22658
- mad: 0,
22659
- firstMs: 0,
22660
- lastMs: 0
22661
- };
22662
- }
22663
- /** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
22664
- function seed(sample) {
22665
- return {
22666
- count: 1,
22667
- mean: sample.dbfs,
22668
- mad: 0,
22669
- firstMs: sample.timestampMs,
22670
- lastMs: sample.timestampMs
22671
- };
22672
- }
22673
- /**
22674
- * Fold `sample` into `state` and decide whether it is a marker candidate.
22675
- *
22676
- * The verdict is measured against the PRIOR baseline (this frame excluded) so
22677
- * a lone spike is not diluted by itself — the same reasoning
22678
- * `audio-level-detector` uses for its `priorMean`. The state returned always
22679
- * includes the sample, whatever the verdict.
22680
- */
22681
- function observeAudioAnomaly(state, sample, cfg) {
22682
- const isFirst = state.count === 0;
22683
- const gapMs = sample.timestampMs - state.lastMs;
22684
- const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
22685
- if (isFirst || isStale) return {
22686
- verdict: { kind: "warmup" },
22687
- nextState: seed(sample)
22688
- };
22689
- const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
22690
- const priorMean = state.mean;
22691
- const priorMad = state.mad;
22692
- const nextState = {
22693
- count: state.count + 1,
22694
- mean: priorMean + alpha * (sample.dbfs - priorMean),
22695
- mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
22696
- firstMs: state.firstMs,
22697
- lastMs: sample.timestampMs
22698
- };
22699
- if (sample.dbfs <= cfg.silenceFloorDbfs) return {
22700
- verdict: { kind: "silent" },
22701
- nextState
22702
- };
22703
- if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
22704
- verdict: { kind: "warmup" },
22705
- nextState
22706
- };
22707
- if (sample.dbfs < cfg.minDbfs) return {
22708
- verdict: {
22709
- kind: "not-loud",
22710
- dbfs: sample.dbfs
22711
- },
22712
- nextState
22713
- };
22714
- const deviationDb = sample.dbfs - priorMean;
22715
- const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
22716
- const measured = {
22717
- dbfs: sample.dbfs,
22718
- mean: priorMean,
22719
- deviationDb,
22720
- deviations
22721
- };
22722
- return {
22723
- verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
22724
- kind: "anomalous",
22725
- ...measured
22726
- } : {
22727
- kind: "not-anomalous",
22728
- ...measured
22729
- },
22730
- nextState
22731
- };
22732
- }
22733
- //#endregion
22734
- //#region src/pipeline-analytics/services/audio-marker-projector.ts
22735
- /** One info line per camera per minute, and only when something qualified. */
22736
- var METRICS_WINDOW_MS = 6e4;
22737
- var HOUR_MS = 60 * 6e4;
22738
- var DAY_MS = 24 * HOUR_MS;
22739
- function emptyCounters(atMs) {
22740
- return {
22741
- windowStartMs: atMs,
22742
- enabled: false,
22743
- observed: 0,
22744
- notLoud: 0,
22745
- notAnomalous: 0,
22746
- candidates: 0,
22747
- suppressedMotion: 0,
22748
- suppressedCooldown: 0,
22749
- suppressedHourly: 0,
22750
- suppressedDaily: 0,
22751
- suppressedInFlight: 0,
22752
- wouldFire: 0,
22753
- materialized: 0,
22754
- materializeFailed: 0
22755
- };
22756
- }
22757
- var AudioMarkerProjector = class {
22758
- deps;
22759
- baselines = /* @__PURE__ */ new Map();
22760
- rates = /* @__PURE__ */ new Map();
22761
- counters = /* @__PURE__ */ new Map();
22762
- inFlight = /* @__PURE__ */ new Set();
22763
- constructor(deps) {
22764
- this.deps = deps;
22765
- }
22766
- /**
22767
- * Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
22768
- * the common path must not allocate a promise, so the decision is taken
22769
- * inline and only an actual marker goes async (fire-and-forget, bounded by
22770
- * the in-flight guard and the rate ceilings).
22771
- *
22772
- * Call this BEFORE the classification/level route split so the baseline
22773
- * sees every frame. A baseline fed only by the level route is blind to
22774
- * exactly the loud, confidently-classified frames a marker is for.
22775
- */
22776
- observe(input, cfg) {
22777
- const { deviceId, timestampMs } = input;
22778
- const counters = this.countersFor(deviceId, timestampMs);
22779
- counters.observed += 1;
22780
- counters.enabled = cfg.enabled;
22781
- const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
22782
- timestampMs,
22783
- dbfs: input.dbfs
22784
- }, cfg);
22785
- this.baselines.set(deviceId, res.nextState);
22786
- this.maybeFlush(deviceId, timestampMs);
22787
- switch (res.verdict.kind) {
22788
- case "silent":
22789
- case "warmup": return;
22790
- case "not-loud":
22791
- counters.notLoud += 1;
22792
- return;
22793
- case "not-anomalous":
22794
- counters.notAnomalous += 1;
22795
- return;
22796
- case "anomalous": break;
22797
- }
22798
- counters.candidates += 1;
22799
- if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
22800
- counters.suppressedMotion += 1;
22801
- return;
22802
- }
22803
- const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
22804
- const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
22805
- if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
22806
- counters.suppressedCooldown += 1;
22807
- return;
22808
- }
22809
- const recent = fired.filter((t) => timestampMs - t < DAY_MS);
22810
- if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
22811
- counters.suppressedHourly += 1;
22812
- this.rates.set(deviceId, { firedAtMs: recent });
22813
- return;
22814
- }
22815
- if (recent.length >= cfg.maxPerDay) {
22816
- counters.suppressedDaily += 1;
22817
- this.rates.set(deviceId, { firedAtMs: recent });
22818
- return;
22819
- }
22820
- if (!cfg.enabled) {
22821
- counters.wouldFire += 1;
22822
- return;
22823
- }
22824
- if (this.inFlight.has(deviceId)) {
22825
- counters.suppressedInFlight += 1;
22826
- return;
22827
- }
22828
- this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
22829
- counters.materialized += 1;
22830
- this.inFlight.add(deviceId);
22831
- this.materialize(input, counters);
22832
- }
22833
- /**
22834
- * Emit and reset a camera's window if it has anything to say. Exposed so a
22835
- * shutdown (or a test) can force the tally out rather than losing it.
22836
- */
22837
- flush(nowMs) {
22838
- for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
22839
- }
22840
- /** Drop all per-camera state for a device (unbind / device removal). */
22841
- forgetDevice(deviceId) {
22842
- this.baselines.delete(deviceId);
22843
- this.rates.delete(deviceId);
22844
- this.counters.delete(deviceId);
22845
- this.inFlight.delete(deviceId);
23036
+ deviceId,
23037
+ eventId: ev.eventId,
23038
+ error: err instanceof Error ? err.message : String(err)
23039
+ }
23040
+ });
23041
+ }
22846
23042
  }
22847
- async materialize(input, counters) {
22848
- const { deviceId, timestampMs } = input;
23043
+ async writeTrackFrame(deviceId, frameHandle, frameData, fw, fh, tf) {
23044
+ const boxed = await this.boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, tf.trackId, tf.bbox, tf.label);
23045
+ if (!boxed) return false;
22849
23046
  try {
22850
- const audioLabels = buildAudioLabels(input);
22851
- await this.deps.sink.materialize({
22852
- cameraId: deviceId,
22853
- sourceDeviceId: deviceId,
22854
- source: "audio",
22855
- kind: "audio",
22856
- timestamp: timestampMs,
22857
- ...audioLabels !== void 0 ? { audioLabels } : {}
23047
+ await this.deps.mediaStore.put({
23048
+ deviceId,
23049
+ ownerKind: "track",
23050
+ ownerId: tf.trackId,
23051
+ kind: "firstFrame",
23052
+ timestamp: tf.timestamp,
23053
+ data: boxed
22858
23054
  });
23055
+ return true;
22859
23056
  } catch (err) {
22860
- counters.materialized -= 1;
22861
- counters.materializeFailed += 1;
22862
- this.deps.onError?.("materializeAudioMarker", err);
22863
- this.deps.logger.warn("audio marker materialize failed", {
23057
+ this.deps.logger.warn("event media: track frame failed", {
22864
23058
  tags: { deviceId },
22865
- meta: { error: err instanceof Error ? err.message : String(err) }
23059
+ meta: {
23060
+ deviceId,
23061
+ trackId: tf.trackId,
23062
+ error: err instanceof Error ? err.message : String(err)
23063
+ }
22866
23064
  });
22867
- } finally {
22868
- this.inFlight.delete(deviceId);
23065
+ return false;
22869
23066
  }
22870
23067
  }
22871
- countersFor(deviceId, atMs) {
22872
- const existing = this.counters.get(deviceId);
22873
- if (existing !== void 0) return existing;
22874
- const fresh = emptyCounters(atMs);
22875
- this.counters.set(deviceId, fresh);
22876
- return fresh;
22877
- }
22878
- maybeFlush(deviceId, nowMs) {
22879
- const c = this.counters.get(deviceId);
22880
- if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22881
- this.emitWindow(deviceId, nowMs, false);
22882
- }
22883
- /**
22884
- * One line per camera per window — but ONLY when a frame reached the
22885
- * candidate gate. A camera that heard nothing extraordinary has nothing to
22886
- * report, and a line per camera per minute forever would bury the ones that
22887
- * do. `force` still honours that: it flushes the window, it does not invent
22888
- * a reason to speak.
22889
- */
22890
- emitWindow(deviceId, nowMs, force) {
22891
- const c = this.counters.get(deviceId);
22892
- if (c === void 0) return;
22893
- if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
22894
- if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
22895
- tags: { deviceId },
22896
- meta: {
22897
- enabled: c.enabled,
22898
- windowMs: nowMs - c.windowStartMs,
22899
- observed: c.observed,
22900
- notLoud: c.notLoud,
22901
- notAnomalous: c.notAnomalous,
22902
- candidates: c.candidates,
22903
- suppressedMotion: c.suppressedMotion,
22904
- suppressedCooldown: c.suppressedCooldown,
22905
- suppressedHourly: c.suppressedHourly,
22906
- suppressedDaily: c.suppressedDaily,
22907
- suppressedInFlight: c.suppressedInFlight,
22908
- wouldFire: c.wouldFire,
22909
- materialized: c.materialized,
22910
- materializeFailed: c.materializeFailed
22911
- }
22912
- });
22913
- this.counters.set(deviceId, emptyCounters(nowMs));
22914
- }
22915
23068
  };
22916
- /**
22917
- * The class heard on the anomalous frame, in the shape `Track.audioLabels`
22918
- * already defines. Using that field is what lets a marker label itself
22919
- * ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
22920
- * the audio macro vocabulary collides with existing subs (`dog` is a COCO
22921
- * animal, `siren` is a control), so a sub per audio class would put a barking
22922
- * dog in the ANIMAL lane.
22923
- */
22924
- function buildAudioLabels(input) {
22925
- if (input.className === void 0) return void 0;
22926
- return [{
22927
- label: input.className,
22928
- peakScore: input.score ?? 0,
22929
- count: 1,
22930
- firstAt: input.timestampMs,
22931
- lastAt: input.timestampMs
22932
- }];
22933
- }
22934
23069
  /** JPEG quality for the downscaled full frame — matches the crop path. */
22935
23070
  var FULL_FRAME_QUALITY = 80;
22936
23071
  /**
@@ -24943,6 +25078,478 @@ async function sweepTrackRetention(deps) {
24943
25078
  return totalTracks;
24944
25079
  }
24945
25080
  //#endregion
25081
+ //#region src/pipeline-analytics/training-export/dataset-plan.ts
25082
+ var FACE_OWNER_PREFIX = "face-";
25083
+ var PLATE_OWNER_PREFIX = "plate-";
25084
+ /**
25085
+ * Tracks one export pass will carry.
25086
+ *
25087
+ * It is the per-device pin budget (500) times the four cameras a homelab hub
25088
+ * plausibly runs, rounded — i.e. "every track that can be marked", not a
25089
+ * throttle the operator has to think about. The ceiling exists so a fleet-wide
25090
+ * export cannot become an unbounded walk if the pin budget is ever raised, and
25091
+ * `truncated` in the summary says when it bit rather than quietly shipping a
25092
+ * partial dataset as if it were the whole one.
25093
+ */
25094
+ var TRAINING_EXPORT_MAX_TRACKS = 2e3;
25095
+ /**
25096
+ * Archive path for one media file.
25097
+ *
25098
+ * The scope segment is not decoration: a track and its best object event can
25099
+ * both own a `crop` at the same millisecond, and without the segment the second
25100
+ * one would overwrite the first inside the archive — a silent loss that only
25101
+ * shows up as a dataset that is smaller than the manifest says.
25102
+ */
25103
+ function archivePathFor(params) {
25104
+ const leaf = params.singleInstance ? `${params.kind}.jpg` : `${params.kind}-${params.timestamp}.jpg`;
25105
+ const dir = params.scope === "" ? "" : `${params.scope}/`;
25106
+ return `tracks/${params.deviceId}/${params.trackId}/${dir}${leaf}`;
25107
+ }
25108
+ /**
25109
+ * A media key ends in `:<timestamp>` for the accumulating kinds and stops at the
25110
+ * kind for the single-instance ones. Reading it off the key rather than
25111
+ * re-deriving the kind set keeps this from drifting when a kind changes sides.
25112
+ */
25113
+ function isSingleInstanceKey(key, kind) {
25114
+ return key.endsWith(`:${kind}`);
25115
+ }
25116
+ /**
25117
+ * Build the plan. Index reads only — the returned plan holds no image bytes and
25118
+ * is safe to send over tRPC.
25119
+ */
25120
+ async function buildTrainingExportPlan(readers, input) {
25121
+ const tracks = [];
25122
+ const skippedPaths = [];
25123
+ let truncated = false;
25124
+ let remaining = input.maxTracks;
25125
+ for (const deviceId of input.deviceIds) {
25126
+ if (remaining <= 0) break;
25127
+ const marked = await readers.listMarkedTracks(deviceId, remaining + 1);
25128
+ if (marked.length > remaining) truncated = true;
25129
+ const take = marked.slice(0, remaining);
25130
+ remaining -= take.length;
25131
+ if (take.length === 0) continue;
25132
+ const eventLinks = await readers.listEventIdsForTracks(take.map((t) => t.trackId));
25133
+ const eventsByTrack = /* @__PURE__ */ new Map();
25134
+ for (const link of eventLinks) {
25135
+ const list = eventsByTrack.get(link.trackId);
25136
+ if (list) list.push(link.eventId);
25137
+ else eventsByTrack.set(link.trackId, [link.eventId]);
25138
+ }
25139
+ for (const track of take) {
25140
+ const files = [];
25141
+ const owners = [
25142
+ {
25143
+ ownerKind: "track",
25144
+ ownerId: track.trackId,
25145
+ scope: ""
25146
+ },
25147
+ {
25148
+ ownerKind: "face",
25149
+ ownerId: `${FACE_OWNER_PREFIX}${track.trackId}`,
25150
+ scope: "face"
25151
+ },
25152
+ {
25153
+ ownerKind: "plate",
25154
+ ownerId: `${PLATE_OWNER_PREFIX}${track.trackId}`,
25155
+ scope: "plate"
25156
+ },
25157
+ ...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
25158
+ ownerKind: "event",
25159
+ ownerId: eventId,
25160
+ scope: `events/${eventId}`
25161
+ }))
25162
+ ];
25163
+ for (const owner of owners) {
25164
+ const infos = await readers.listMediaInfo(owner.ownerKind, owner.ownerId);
25165
+ for (const info of infos) {
25166
+ const archivePath = archivePathFor({
25167
+ deviceId: track.deviceId,
25168
+ trackId: track.trackId,
25169
+ scope: owner.scope,
25170
+ kind: info.kind,
25171
+ timestamp: info.timestamp,
25172
+ singleInstance: isSingleInstanceKey(info.key, info.kind)
25173
+ });
25174
+ files.push({
25175
+ archivePath,
25176
+ mediaKey: info.key,
25177
+ kind: info.kind,
25178
+ sizeBytes: info.sizeBytes,
25179
+ timestamp: info.timestamp
25180
+ });
25181
+ }
25182
+ }
25183
+ tracks.push({
25184
+ trackId: track.trackId,
25185
+ deviceId: track.deviceId,
25186
+ className: track.className,
25187
+ ...track.label !== void 0 ? { label: track.label } : {},
25188
+ firstSeen: track.firstSeen,
25189
+ lastSeen: track.lastSeen,
25190
+ classes: track.classes ?? [],
25191
+ zonesVisited: track.zonesVisited,
25192
+ ...track.importance !== void 0 ? { importance: track.importance } : {},
25193
+ files
25194
+ });
25195
+ }
25196
+ }
25197
+ const perDevice = /* @__PURE__ */ new Map();
25198
+ let fileCount = 0;
25199
+ let byteCount = 0;
25200
+ for (const track of tracks) {
25201
+ const agg = perDevice.get(track.deviceId) ?? {
25202
+ tracks: 0,
25203
+ files: 0,
25204
+ bytes: 0
25205
+ };
25206
+ agg.tracks += 1;
25207
+ for (const file of track.files) {
25208
+ agg.files += 1;
25209
+ agg.bytes += file.sizeBytes;
25210
+ fileCount += 1;
25211
+ byteCount += file.sizeBytes;
25212
+ }
25213
+ perDevice.set(track.deviceId, agg);
25214
+ }
25215
+ return {
25216
+ generatedAt: readers.now(),
25217
+ tracks,
25218
+ totals: [...perDevice.entries()].map(([deviceId, agg]) => ({
25219
+ deviceId,
25220
+ ...agg
25221
+ })),
25222
+ trackCount: tracks.length,
25223
+ fileCount,
25224
+ byteCount,
25225
+ truncated,
25226
+ skippedPaths
25227
+ };
25228
+ }
25229
+ //#endregion
25230
+ //#region src/pipeline-analytics/training-export/tar-stream.ts
25231
+ /**
25232
+ * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
25233
+ * entry, without ever holding the archive in memory.
25234
+ *
25235
+ * ## Why hand-rolled rather than the `tar` package
25236
+ *
25237
+ * `tar` is in the workspace and is the right tool for what it does: archive a
25238
+ * set of paths under one `cwd`. This is a different job. A track's media lives
25239
+ * across several storage LOCATION roots (`eventMedia` and whatever else the
25240
+ * operator relocated to), under paths that encode nothing a training set wants —
25241
+ * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
25242
+ * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
25243
+ * of paths relative to a single cwd and gives no rename hook, so using it would
25244
+ * mean staging a whole shadow tree on disk first: a full second copy of the
25245
+ * dataset, written and then deleted, to avoid writing 100 lines.
25246
+ *
25247
+ * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
25248
+ * fully specified, and asserted against the system `tar` binary in the spec next
25249
+ * door rather than against my reading of the format.
25250
+ *
25251
+ * ## What this deliberately does NOT do
25252
+ *
25253
+ * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
25254
+ * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
25255
+ * splits a path across a 155-byte prefix and a 100-byte name, and
25256
+ * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
25257
+ * it and SAY it skipped it, rather than emitting a silently truncated entry.
25258
+ */
25259
+ var BLOCK_SIZE = 512;
25260
+ var NAME_MAX = 100;
25261
+ var PREFIX_MAX = 155;
25262
+ /**
25263
+ * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
25264
+ * be represented. The split must land on a `/`, and each half has its own cap.
25265
+ */
25266
+ function splitUstarName(name) {
25267
+ if (Buffer.byteLength(name) <= NAME_MAX) return {
25268
+ prefix: "",
25269
+ name
25270
+ };
25271
+ for (let i = name.length - 1; i >= 0; i--) {
25272
+ if (name[i] !== "/") continue;
25273
+ const head = name.slice(0, i);
25274
+ const tail = name.slice(i + 1);
25275
+ if (Buffer.byteLength(tail) > NAME_MAX) break;
25276
+ if (Buffer.byteLength(head) <= PREFIX_MAX) return {
25277
+ prefix: head,
25278
+ name: tail
25279
+ };
25280
+ }
25281
+ return null;
25282
+ }
25283
+ /** True when a path cannot be represented as a ustar entry name. */
25284
+ function tarNameTooLong(name) {
25285
+ return splitUstarName(name) === null;
25286
+ }
25287
+ function writeString(block, value, offset, length) {
25288
+ block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
25289
+ }
25290
+ /** ustar numeric field: zero-padded octal, NUL-terminated. */
25291
+ function writeOctal(block, value, offset, length) {
25292
+ const text = Math.floor(value).toString(8).padStart(length - 1, "0");
25293
+ block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
25294
+ }
25295
+ /**
25296
+ * The 512-byte header for one regular file.
25297
+ *
25298
+ * The checksum is computed with the checksum field itself read as eight spaces —
25299
+ * that is the format's rule, and getting it wrong produces an archive that looks
25300
+ * fine until something tries to read it.
25301
+ */
25302
+ function tarHeader(entry) {
25303
+ const split = splitUstarName(entry.name);
25304
+ if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
25305
+ const block = Buffer.alloc(BLOCK_SIZE);
25306
+ writeString(block, split.name, 0, NAME_MAX);
25307
+ writeOctal(block, 420, 100, 8);
25308
+ writeOctal(block, 0, 108, 8);
25309
+ writeOctal(block, 0, 116, 8);
25310
+ writeOctal(block, entry.body.length, 124, 12);
25311
+ writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
25312
+ block.write(" ", 148, 8, "ascii");
25313
+ block.write("0", 156, 1, "ascii");
25314
+ block.write("ustar\0", 257, 6, "ascii");
25315
+ block.write("00", 263, 2, "ascii");
25316
+ writeString(block, split.prefix, 345, PREFIX_MAX);
25317
+ let sum = 0;
25318
+ for (const byte of block) sum += byte;
25319
+ writeOctal(block, sum, 148, 7);
25320
+ block.write("\0", 154, 1, "ascii");
25321
+ block.write(" ", 155, 1, "ascii");
25322
+ return block;
25323
+ }
25324
+ /** Zero padding that rounds a body up to the 512-byte block. */
25325
+ function tarPadding(size) {
25326
+ const remainder = size % BLOCK_SIZE;
25327
+ return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
25328
+ }
25329
+ /** Header + body + padding for one entry, ready to write. */
25330
+ function tarBlocks(entry) {
25331
+ return [
25332
+ tarHeader(entry),
25333
+ entry.body,
25334
+ tarPadding(entry.body.length)
25335
+ ];
25336
+ }
25337
+ /** The two zero blocks that terminate an archive. */
25338
+ function tarTrailer() {
25339
+ return Buffer.alloc(BLOCK_SIZE * 2);
25340
+ }
25341
+ //#endregion
25342
+ //#region src/pipeline-analytics/training-export/training-export-stream.ts
25343
+ var MANIFEST_ENTRY_NAME = "manifest.json";
25344
+ function manifestTrack(track) {
25345
+ return {
25346
+ trackId: track.trackId,
25347
+ deviceId: track.deviceId,
25348
+ className: track.className,
25349
+ ...track.label !== void 0 ? { label: track.label } : {},
25350
+ firstSeen: track.firstSeen,
25351
+ lastSeen: track.lastSeen,
25352
+ classes: track.classes,
25353
+ zonesVisited: track.zonesVisited,
25354
+ ...track.importance !== void 0 ? { importance: track.importance } : {},
25355
+ files: track.files.map((f) => ({
25356
+ path: f.archivePath,
25357
+ kind: f.kind,
25358
+ timestamp: f.timestamp,
25359
+ sizeBytes: f.sizeBytes
25360
+ }))
25361
+ };
25362
+ }
25363
+ /**
25364
+ * The manifest body.
25365
+ *
25366
+ * `mediaKey` is deliberately NOT in it. The archive is a dataset handed to a
25367
+ * training pipeline, and a store-internal key is meaningless outside the hub
25368
+ * that issued it — worse, it invites a consumer to believe it can fetch by that
25369
+ * key later, on media the retention pin does not guarantee is still there once
25370
+ * the mark is removed. The archive path is the identity inside the archive.
25371
+ */
25372
+ function buildManifest(plan) {
25373
+ const body = {
25374
+ format: "camstack-training-export",
25375
+ version: 1,
25376
+ generatedAt: plan.generatedAt,
25377
+ selection: "tracks in retrain staging (retrainStatus: 'staging')",
25378
+ trackCount: plan.trackCount,
25379
+ fileCount: plan.fileCount,
25380
+ byteCount: plan.byteCount,
25381
+ truncated: plan.truncated,
25382
+ devices: plan.totals,
25383
+ tracks: plan.tracks.map(manifestTrack)
25384
+ };
25385
+ return Buffer.from(JSON.stringify(body, null, 2), "utf8");
25386
+ }
25387
+ /** Write the whole archive to the sink. */
25388
+ async function streamDataset(deps) {
25389
+ const { plan, sink, readBlob, logger, now, pace } = deps;
25390
+ const pacingMs = deps.pacingMs ?? 25;
25391
+ const mtimeMs = now();
25392
+ for (const block of tarBlocks({
25393
+ name: MANIFEST_ENTRY_NAME,
25394
+ body: buildManifest(plan),
25395
+ mtimeMs
25396
+ })) await sink.write(block);
25397
+ let filesWritten = 0;
25398
+ let bytesWritten = 0;
25399
+ let filesMissing = 0;
25400
+ let aborted = false;
25401
+ for (const track of plan.tracks) {
25402
+ for (const file of track.files) {
25403
+ if (deps.aborted?.() === true) {
25404
+ aborted = true;
25405
+ break;
25406
+ }
25407
+ if (tarNameTooLong(file.archivePath)) {
25408
+ filesMissing += 1;
25409
+ logger.warn("training export skipped a file — path too long for the archive", {
25410
+ tags: { deviceId: track.deviceId },
25411
+ meta: {
25412
+ deviceId: track.deviceId,
25413
+ trackId: track.trackId,
25414
+ path: file.archivePath
25415
+ }
25416
+ });
25417
+ continue;
25418
+ }
25419
+ const bytes = await readBlob(file.mediaKey);
25420
+ if (bytes === null) {
25421
+ filesMissing += 1;
25422
+ logger.warn("training export skipped a file — media unreadable", {
25423
+ tags: { deviceId: track.deviceId },
25424
+ meta: {
25425
+ deviceId: track.deviceId,
25426
+ trackId: track.trackId,
25427
+ mediaKey: file.mediaKey
25428
+ }
25429
+ });
25430
+ continue;
25431
+ }
25432
+ for (const block of tarBlocks({
25433
+ name: file.archivePath,
25434
+ body: bytes,
25435
+ mtimeMs
25436
+ })) await sink.write(block);
25437
+ filesWritten += 1;
25438
+ bytesWritten += bytes.length;
25439
+ if (pacingMs > 0) await pace(pacingMs);
25440
+ }
25441
+ if (aborted) break;
25442
+ }
25443
+ await sink.write(tarTrailer());
25444
+ await sink.end();
25445
+ logger.info("training export complete", { meta: {
25446
+ tracks: plan.trackCount,
25447
+ filesWritten,
25448
+ filesMissing,
25449
+ bytesWritten,
25450
+ truncated: plan.truncated,
25451
+ aborted
25452
+ } });
25453
+ return {
25454
+ filesWritten,
25455
+ bytesWritten,
25456
+ filesMissing,
25457
+ aborted
25458
+ };
25459
+ }
25460
+ //#endregion
25461
+ //#region src/pipeline-analytics/training-export/training-export-handler.ts
25462
+ var TRAINING_EXPORT_PREFIX = "training-export";
25463
+ /**
25464
+ * Parse `?deviceIds=615,617` into a device list. An absent or unparseable value
25465
+ * yields `[]`, which the caller reads as "every device with marked tracks" —
25466
+ * stated here rather than left to the reader because the empty array is doing
25467
+ * two jobs and only one of them is obvious.
25468
+ */
25469
+ function parseDeviceIds(url) {
25470
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
25471
+ const raw = new URLSearchParams(query).get("deviceIds");
25472
+ if (raw === null || raw.trim() === "") return [];
25473
+ const out = [];
25474
+ for (const part of raw.split(",")) {
25475
+ const n = Number.parseInt(part.trim(), 10);
25476
+ if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
25477
+ }
25478
+ return out;
25479
+ }
25480
+ /** Archive filename. Sanitised the same way the backup download is — a header
25481
+ * value carrying a CR or a quote is a response-splitting bug, not a filename. */
25482
+ function archiveFilename(nowMs) {
25483
+ return `camstack-training-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.tar`.replace(/[\r\n"]/g, "");
25484
+ }
25485
+ /** Adapt a `ServerResponse` to the sink, honouring backpressure. */
25486
+ function responseSink(res) {
25487
+ return {
25488
+ write: (chunk) => new Promise((resolve, reject) => {
25489
+ const ok = res.write(chunk, (err) => {
25490
+ if (err) reject(err);
25491
+ else if (ok) resolve();
25492
+ });
25493
+ if (!ok) res.once("drain", resolve);
25494
+ }),
25495
+ end: () => new Promise((resolve) => {
25496
+ res.end(resolve);
25497
+ })
25498
+ };
25499
+ }
25500
+ function createTrainingExportHandler(deps) {
25501
+ return async (req, res) => {
25502
+ const method = req.method ?? "GET";
25503
+ if (method !== "GET" && method !== "HEAD") {
25504
+ res.writeHead(405, { allow: "GET, HEAD" });
25505
+ res.end();
25506
+ return;
25507
+ }
25508
+ const deviceIds = parseDeviceIds(req.url ?? "");
25509
+ let plan;
25510
+ try {
25511
+ plan = await deps.buildPlan(deviceIds);
25512
+ } catch (err) {
25513
+ deps.logger.warn("training export plan failed", { meta: { error: String(err) } });
25514
+ res.writeHead(500, { "content-type": "text/plain" });
25515
+ res.end("training export plan failed");
25516
+ return;
25517
+ }
25518
+ res.writeHead(200, {
25519
+ "content-type": "application/x-tar",
25520
+ "content-disposition": `attachment; filename="${archiveFilename(deps.now())}"`,
25521
+ "cache-control": "no-store",
25522
+ "x-camstack-export-tracks": String(plan.trackCount),
25523
+ "x-camstack-export-files": String(plan.fileCount)
25524
+ });
25525
+ if (method === "HEAD") {
25526
+ res.end();
25527
+ return;
25528
+ }
25529
+ let clientGone = false;
25530
+ res.once("close", () => {
25531
+ clientGone = res.writableEnded === false;
25532
+ });
25533
+ try {
25534
+ await streamDataset({
25535
+ plan,
25536
+ sink: responseSink(res),
25537
+ readBlob: deps.readBlob,
25538
+ logger: deps.logger,
25539
+ now: deps.now,
25540
+ pace: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
25541
+ aborted: () => clientGone
25542
+ });
25543
+ } catch (err) {
25544
+ deps.logger.warn("training export stream failed mid-archive", { meta: {
25545
+ error: String(err),
25546
+ tracks: plan.trackCount
25547
+ } });
25548
+ res.destroy();
25549
+ }
25550
+ };
25551
+ }
25552
+ //#endregion
24946
25553
  //#region src/pipeline-analytics/zone-analytics-provider.ts
24947
25554
  var HISTORY_WINDOW_MS = 3600 * 1e3;
24948
25555
  /** Cap name for the runtime-state mirror — matches the cap's declared
@@ -25575,6 +26182,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
25575
26182
  retentionSweepTimer = null;
25576
26183
  /** Handle for the event-media data-plane listener (dispose on shutdown). */
25577
26184
  eventMediaDataPlane = null;
26185
+ /** Handle for the training-export data-plane listener (dispose on shutdown). */
26186
+ trainingExportDataPlane = null;
25578
26187
  /** The NC artifact plane (signed public links for notification media) and its
25579
26188
  * data-plane handle. Null until served / when the facility is absent. */
25580
26189
  ncArtifactPlane = null;
@@ -26208,7 +26817,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26208
26817
  let storage = this.ctx.kernel.storage;
26209
26818
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
26210
26819
  if (mediaRoot) {
26211
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CdY4IvEy.js"));
26820
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-HEkeoPPw.js"));
26212
26821
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
26213
26822
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
26214
26823
  }
@@ -26717,11 +27326,99 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26717
27326
  }) ?? null;
26718
27327
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
26719
27328
  await this.serveNcArtifactPlane();
27329
+ await this.serveTrainingExportPlane();
26720
27330
  this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
26721
27331
  } catch (err) {
26722
27332
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
26723
27333
  }
26724
27334
  }
27335
+ /**
27336
+ * The training-export data plane. Separate listener, `access: 'admin'` —
27337
+ * marking a track is a viewer-level curation act (D81), but walking off with
27338
+ * every stored frame behind the marked set is not.
27339
+ */
27340
+ async serveTrainingExportPlane() {
27341
+ try {
27342
+ const handler = createTrainingExportHandler({
27343
+ buildPlan: (deviceIds) => this.buildTrainingExportPlan(deviceIds),
27344
+ readBlob: async (mediaKey) => {
27345
+ const file = await (this.mediaStore?.getByKey(mediaKey) ?? Promise.resolve(null));
27346
+ return file === null ? null : Buffer.from(file.base64, "base64");
27347
+ },
27348
+ logger: this.ctx.logger,
27349
+ now: () => Date.now()
27350
+ });
27351
+ this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
27352
+ prefix: "training-export",
27353
+ access: "admin",
27354
+ handler
27355
+ }) ?? null;
27356
+ } catch (err) {
27357
+ this.ctx.logger.warn("training-export data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
27358
+ }
27359
+ }
27360
+ /**
27361
+ * Plan the training export — INDEX READS ONLY. No blob is touched here, which
27362
+ * is what lets `getTrainingExportSummary` answer "how big is this" for the
27363
+ * cost of a few KB per track instead of the gigabytes the answer is about.
27364
+ *
27365
+ * An empty `deviceIds` means every device that has tracks, resolved from the
27366
+ * track store rather than from the device manager: a camera that was removed
27367
+ * can still own marked tracks, and refusing to export them because the device
27368
+ * is gone would lose exactly the material an operator kept on purpose.
27369
+ */
27370
+ async buildTrainingExportPlan(deviceIds) {
27371
+ const trackStore = this.trackStore;
27372
+ const mediaStore = this.mediaStore;
27373
+ const eventStore = this.eventStore;
27374
+ if (!trackStore || !mediaStore) throw new Error("training export: analytics stores unavailable");
27375
+ return buildTrainingExportPlan({
27376
+ listMarkedTracks: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
27377
+ listMediaInfo: async (ownerKind, ownerId) => {
27378
+ if (!isMediaOwnerKind(ownerKind)) return [];
27379
+ return mediaStore.listInfoByOwner(ownerKind, ownerId);
27380
+ },
27381
+ listEventIdsForTracks: async (trackIds) => {
27382
+ if (!eventStore || trackIds.length === 0) return [];
27383
+ return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
27384
+ eventId: e.id,
27385
+ trackId: e.trackId
27386
+ }]);
27387
+ },
27388
+ now: () => Date.now()
27389
+ }, {
27390
+ deviceIds: deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds(),
27391
+ maxTracks: TRAINING_EXPORT_MAX_TRACKS
27392
+ });
27393
+ }
27394
+ /**
27395
+ * Estimate the training export without producing it — the cheap question
27396
+ * (D56's discipline, applied to a pass that runs no inference at all).
27397
+ */
27398
+ async getTrainingExportSummary(input) {
27399
+ const plan = await this.buildTrainingExportPlan(input.deviceIds ?? []);
27400
+ this.ctx.logger.info("training export summary", { meta: {
27401
+ tracks: plan.trackCount,
27402
+ files: plan.fileCount,
27403
+ bytes: plan.byteCount,
27404
+ truncated: plan.truncated
27405
+ } });
27406
+ return {
27407
+ generatedAt: plan.generatedAt,
27408
+ trackCount: plan.trackCount,
27409
+ fileCount: plan.fileCount,
27410
+ byteCount: plan.byteCount,
27411
+ truncated: plan.truncated,
27412
+ devices: plan.totals
27413
+ };
27414
+ }
27415
+ /** Where to stream the dataset from. The bytes never come through the cap. */
27416
+ async getTrainingExportUrl(input) {
27417
+ if (this.trainingExportDataPlane === null) throw new Error("training export: data plane unavailable on this node");
27418
+ const ids = input.deviceIds ?? [];
27419
+ const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
27420
+ return { url: `/addon/${this.ctx.id}/${TRAINING_EXPORT_PREFIX}${query}` };
27421
+ }
26725
27422
  /** Object/face embedding dispatcher — migrated from the retired
26726
27423
  * enrichment-engine addon. It subscribes to DetectionResult, resolves the
26727
27424
  * frame via the runner transport, crops each qualifying detection, and
@@ -26972,6 +27669,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
26972
27669
  this.eventMediaDataPlane = null;
26973
27670
  this.eventMediaBaseUrl = null;
26974
27671
  this.eventMediaDispatcher = null;
27672
+ await this.trainingExportDataPlane?.dispose();
27673
+ this.trainingExportDataPlane = null;
26975
27674
  }
26976
27675
  async handleInferenceResult(data) {
26977
27676
  if (this.shuttingDown) return;
@@ -30124,15 +30823,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30124
30823
  }
30125
30824
  /**
30126
30825
  * Set the per-track operator flags (`markForTrain`, `debug`). Partial patch —
30127
- * see `applyTrackFlags`. The flags carry no behaviour yet; this is the write
30128
- * surface the three UI toggles share.
30826
+ * see `applyTrackFlags`. `markForTrain` moves the retrain LIFECYCLE
30827
+ * (`none ↔ staging`, D81), and `staging` is what PINS the track against the
30828
+ * retention sweep — which is why the body enforces a per-device staging budget
30829
+ * and refuses any move on an already-`trained` track. `debug` is attention
30830
+ * only. This is the write surface the three UI toggles share.
30129
30831
  */
30130
30832
  async setTrackFlags(input) {
30131
30833
  const trackStore = this.trackStore;
30132
30834
  if (!trackStore) throw new Error("setTrackFlags: track store unavailable");
30133
30835
  const out = await applyTrackFlags({
30134
- readFlags: (trackId) => trackStore.readFlags(trackId),
30135
- setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch)
30836
+ store: {
30837
+ readFlags: (trackId) => trackStore.readFlags(trackId),
30838
+ setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
30839
+ countStaging: (query) => trackStore.countStaging(query)
30840
+ },
30841
+ logger: this.ctx.logger
30136
30842
  }, input);
30137
30843
  this.ctx.logger.info("track operator flags set", {
30138
30844
  tags: { deviceId: input.deviceId },
@@ -30140,6 +30846,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30140
30846
  deviceId: input.deviceId,
30141
30847
  trackId: input.trackId,
30142
30848
  markForTrain: out.markForTrain,
30849
+ retrainStatus: out.retrainStatus,
30143
30850
  debug: out.debug
30144
30851
  }
30145
30852
  });
@@ -30263,6 +30970,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30263
30970
  deviceId,
30264
30971
  cutoffMs
30265
30972
  });
30973
+ const staging = await this.trackStore?.countStaging({
30974
+ deviceId,
30975
+ olderThanMs: cutoffMs
30976
+ });
30977
+ if (staging !== void 0 && staging > 0) this.ctx.logger.info("track retention held staging tracks (retrainStatus)", {
30978
+ tags: { deviceId },
30979
+ meta: {
30980
+ deviceId,
30981
+ staging,
30982
+ cutoffMs,
30983
+ deleted: counts.tracks
30984
+ }
30985
+ });
30266
30986
  if (counts.tracks > 0) this.eventsOpsLog?.append({
30267
30987
  op: "prune",
30268
30988
  reason: "retention",