@camstack/addon-post-analysis 1.2.102 → 1.2.104

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.
@@ -20739,6 +20739,7 @@ DeviceType.Camera, method(object({
20739
20739
  isBattery: boolean(),
20740
20740
  reason: _enum([
20741
20741
  "disabled",
20742
+ "offline",
20742
20743
  "sleeping",
20743
20744
  "unreachable",
20744
20745
  "waking"
@@ -20784,9 +20785,17 @@ targets: array(object({
20784
20785
  sleeping: boolean(),
20785
20786
  /** Current device state rendered over the cached frame. State images
20786
20787
  * remain authoritative even when their photographic background is
20787
- * old; null means the link must carry a current camera frame. */
20788
+ * old; null means the link must carry a current camera frame.
20789
+ *
20790
+ * **This is the ONLY source a surface may derive an inactive tile
20791
+ * from.** A client that reads a device's `online`/`disabled` flags and
20792
+ * draws its own verdict will disagree with the pixels it is painting
20793
+ * — the frame comes from here, and so must the sentence over it
20794
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
20795
+ * state. */
20788
20796
  stateReason: _enum([
20789
20797
  "disabled",
20798
+ "offline",
20790
20799
  "sleeping",
20791
20800
  "unreachable",
20792
20801
  "waking"
@@ -20770,6 +20770,7 @@ DeviceType.Camera, method(object({
20770
20770
  isBattery: boolean(),
20771
20771
  reason: _enum([
20772
20772
  "disabled",
20773
+ "offline",
20773
20774
  "sleeping",
20774
20775
  "unreachable",
20775
20776
  "waking"
@@ -20815,9 +20816,17 @@ targets: array(object({
20815
20816
  sleeping: boolean(),
20816
20817
  /** Current device state rendered over the cached frame. State images
20817
20818
  * remain authoritative even when their photographic background is
20818
- * old; null means the link must carry a current camera frame. */
20819
+ * old; null means the link must carry a current camera frame.
20820
+ *
20821
+ * **This is the ONLY source a surface may derive an inactive tile
20822
+ * from.** A client that reads a device's `online`/`disabled` flags and
20823
+ * draws its own verdict will disagree with the pixels it is painting
20824
+ * — the frame comes from here, and so must the sentence over it
20825
+ * (D190). Pair with `ageMs` for the age of the photograph behind the
20826
+ * state. */
20819
20827
  stateReason: _enum([
20820
20828
  "disabled",
20829
+ "offline",
20821
20830
  "sleeping",
20822
20831
  "unreachable",
20823
20832
  "waking"
@@ -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-BwxXdV9d.js");
5
+ const require_dist = require("../dist-KXhjlCmh.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-CZm8slPB.mjs";
1
+ import { O as PoolMemoryWatchdog, Q as embeddingEncoderCapability, dt as resolvePoolMemoryPolicy, ot as parseProcStatus, tt as hfModelUrl, yt as BaseAddon } from "../dist-IZLiqXRa.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.92",
21
+ version: "1.2.93",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.92",
39
+ version: "1.2.93",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -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-BwxXdV9d.js");
5
+ const require_dist = require("../dist-KXhjlCmh.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -8302,6 +8302,10 @@ var en_default = {
8302
8302
  "one": "plate",
8303
8303
  "other": "plates"
8304
8304
  },
8305
+ "class.audio": {
8306
+ "one": "sound",
8307
+ "other": "sounds"
8308
+ },
8305
8309
  "class.other": "{{name}}",
8306
8310
  "group.others.person": {
8307
8311
  "one": "{{count}} other person",
@@ -8520,6 +8524,10 @@ var it_default = {
8520
8524
  "one": "targa",
8521
8525
  "other": "targhe"
8522
8526
  },
8527
+ "class.audio": {
8528
+ "one": "suono",
8529
+ "other": "suoni"
8530
+ },
8523
8531
  "class.other": "{{name}}",
8524
8532
  "group.others.person": {
8525
8533
  "one": "{{count}} altra persona",
@@ -18163,7 +18171,7 @@ function applyTemplatePatch(merged, template) {
18163
18171
  return withoutTemplate;
18164
18172
  }
18165
18173
  /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
18166
- function isRecord$1(value) {
18174
+ function isRecord$2(value) {
18167
18175
  return typeof value === "object" && value !== null && !Array.isArray(value);
18168
18176
  }
18169
18177
  /**
@@ -18193,7 +18201,7 @@ function isRecord$1(value) {
18193
18201
  * the same reason.
18194
18202
  */
18195
18203
  function withCreatedAt(rowId, raw, synthesised, now) {
18196
- if (!isRecord$1(raw)) return raw;
18204
+ if (!isRecord$2(raw)) return raw;
18197
18205
  if (typeof raw["createdAt"] === "number") return raw;
18198
18206
  const stamped = synthesised.get(rowId) ?? now();
18199
18207
  synthesised.set(rowId, stamped);
@@ -25198,7 +25206,16 @@ function clamp01$1(x) {
25198
25206
  if (x > 1) return 1;
25199
25207
  return x;
25200
25208
  }
25201
- /** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
25209
+ /**
25210
+ * Rank a class name into [0,1] by how event-worthy it is. Case-insensitive.
25211
+ *
25212
+ * `audio` falls through to {@link CLASS_RANK_DEFAULT} on purpose — a sound is
25213
+ * not a subject in frame. Before D189 an audio episode carried the HEARD class
25214
+ * here, so `dog`/`cat`/`animal` landed in {@link ANIMAL_CLASSES} and a bark
25215
+ * ranked as if an animal had walked past, while `speech` (500 of the 576 audio
25216
+ * rows measured live) already ranked default. That boost was an accident of the
25217
+ * class collision, not a decision. Pinned by `key-event-scoring.spec.ts`.
25218
+ */
25202
25219
  function classRank(className) {
25203
25220
  const c = className.toLowerCase();
25204
25221
  if (PERSON_CLASSES.has(c)) return 1;
@@ -40540,10 +40557,38 @@ function buildRecordedStillPorts(input) {
40540
40557
  })
40541
40558
  };
40542
40559
  }
40543
- /** Where the completion marker lives. */
40560
+ /**
40561
+ * Where the completion marker lives.
40562
+ *
40563
+ * @durable class=config owner=pipeline-analytics
40564
+ * write="one marker row when a boot migration completes; overwritten only by
40565
+ * a re-run of the same migration"
40566
+ * loss="a re-scan of the analytics store on the next boot — idempotent,
40567
+ * never destructive"
40568
+ * retention="never pruned: one row per completed migration, bounded by the
40569
+ * number of migrations ever written (two today)"
40570
+ */
40544
40571
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40545
40572
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40546
40573
  /**
40574
+ * The owner declares its marker collection — like every other store in this
40575
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40576
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40577
+ * never existed, so the engine refused every marker write, the catch below
40578
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40579
+ * boot from the day the migration shipped.
40580
+ */
40581
+ async function declareMigrationMarkers(store) {
40582
+ await store.declareCollection.mutate({
40583
+ collection: MIGRATION_MARKER_COLLECTION,
40584
+ columns: [{
40585
+ name: "data",
40586
+ type: "TEXT",
40587
+ notNull: true
40588
+ }]
40589
+ });
40590
+ }
40591
+ /**
40547
40592
  * The decision for ONE row, exposed so the rule is testable without a store.
40548
40593
  *
40549
40594
  * `null` = leave the row alone.
@@ -43702,11 +43747,11 @@ function rowToSensorEvent(id, data) {
43702
43747
  deviceId: Number(data["deviceId"]),
43703
43748
  sourceDeviceId: Number(data["sourceDeviceId"]),
43704
43749
  kind: String(data["kind"]),
43705
- value: isRecord(value) ? value : null,
43750
+ value: isRecord$1(value) ? value : null,
43706
43751
  timestamp: Number(data["timestamp"])
43707
43752
  };
43708
43753
  }
43709
- function isRecord(x) {
43754
+ function isRecord$1(x) {
43710
43755
  return x !== null && typeof x === "object" && !Array.isArray(x);
43711
43756
  }
43712
43757
  /**
@@ -43724,6 +43769,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43724
43769
  return mark.priorRollbacks < 3;
43725
43770
  }
43726
43771
  //#endregion
43772
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43773
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43774
+ var AUDIO_TRACK_CLASS = "audio";
43775
+ /**
43776
+ * True when this row predates the contract: audio-sourced, but carrying a
43777
+ * naked audio macro as its class.
43778
+ *
43779
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43780
+ * vocabulary after a row was written would then read as modern forever. The
43781
+ * question is the simple one: is this an audio row that does not say `audio`?
43782
+ */
43783
+ function isLegacyAudioTrackClass(track) {
43784
+ return track.source === "audio" && track.className !== "audio";
43785
+ }
43786
+ /**
43787
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43788
+ * there is nothing to do, so the hot path (every non-audio track, and every
43789
+ * audio track written after this shipped) allocates nothing.
43790
+ *
43791
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43792
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43793
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43794
+ * can never be the step that DESTROYS the only copy of what was heard.
43795
+ */
43796
+ function normalizeAudioTrackClass(track) {
43797
+ if (!isLegacyAudioTrackClass(track)) return track;
43798
+ const heard = {
43799
+ label: track.className,
43800
+ peakScore: 0,
43801
+ count: 1,
43802
+ firstAt: track.firstSeen,
43803
+ lastAt: track.lastSeen
43804
+ };
43805
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43806
+ return {
43807
+ ...track,
43808
+ className: AUDIO_TRACK_CLASS,
43809
+ audioLabels
43810
+ };
43811
+ }
43812
+ //#endregion
43727
43813
  //#region src/pipeline-analytics/store/recent-cursor.ts
43728
43814
  function encodeRecentCursor(cursor) {
43729
43815
  return Buffer.from(JSON.stringify({
@@ -44023,6 +44109,24 @@ var TRACKS_INDEXES = [{
44023
44109
  name: "idx_tracks_device_firstSeen",
44024
44110
  columns: ["deviceId", "firstSeen"]
44025
44111
  }];
44112
+ function isRecord(v) {
44113
+ return typeof v === "object" && v !== null;
44114
+ }
44115
+ /** Validate the JSON column back into `Track.audioLabels` — a malformed row
44116
+ * yields null rather than a lying shape (this column now carries the meaning
44117
+ * of an audio row, D189; it must not be trusted by assertion). */
44118
+ function readAudioLabels(v) {
44119
+ if (!Array.isArray(v)) return null;
44120
+ const out = [];
44121
+ for (const e of v) if (isRecord(e) && typeof e["label"] === "string" && typeof e["peakScore"] === "number" && typeof e["count"] === "number" && typeof e["firstAt"] === "number" && typeof e["lastAt"] === "number") out.push({
44122
+ label: e["label"],
44123
+ peakScore: e["peakScore"],
44124
+ count: e["count"],
44125
+ firstAt: e["firstAt"],
44126
+ lastAt: e["lastAt"]
44127
+ });
44128
+ return out.length > 0 ? out : null;
44129
+ }
44026
44130
  /** Serialize the per-label aggregate map into the `Track.audioLabels`
44027
44131
  * array shape, most-frequent label first. */
44028
44132
  function audioLabelsToArray(agg) {
@@ -45223,8 +45327,9 @@ var TrackStore = class {
45223
45327
  });
45224
45328
  const names = /* @__PURE__ */ new Set();
45225
45329
  for (const r of rows) {
45330
+ const isAudio = r.data["source"] === "audio";
45226
45331
  const className = r.data["className"];
45227
- if (typeof className === "string" && className.length > 0) names.add(className);
45332
+ if (typeof className === "string" && className.length > 0) names.add(isAudio ? AUDIO_TRACK_CLASS : className);
45228
45333
  const classes = r.data["classes"];
45229
45334
  if (Array.isArray(classes)) {
45230
45335
  for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
@@ -45353,7 +45458,7 @@ var TrackStore = class {
45353
45458
  const importance = data["importance"];
45354
45459
  const bestEventId = data["bestEventId"];
45355
45460
  const importanceReason = data["importanceReason"];
45356
- const audioLabels = data["audioLabels"];
45461
+ const audioLabels = readAudioLabels(data["audioLabels"]);
45357
45462
  const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
45358
45463
  const debug = data["debug"];
45359
45464
  const favourited = data["favourited"];
@@ -45371,7 +45476,7 @@ var TrackStore = class {
45371
45476
  maxX: envMaxX,
45372
45477
  maxY: envMaxY
45373
45478
  } : null;
45374
- return {
45479
+ return normalizeAudioTrackClass({
45375
45480
  trackId: id,
45376
45481
  deviceId: Number(data["deviceId"]),
45377
45482
  className: String(data["className"]),
@@ -45402,7 +45507,7 @@ var TrackStore = class {
45402
45507
  ...closedReason !== void 0 ? { closedReason } : {},
45403
45508
  observations,
45404
45509
  ...envelope !== null ? { envelope } : {}
45405
- };
45510
+ });
45406
45511
  }
45407
45512
  };
45408
45513
  //#endregion
@@ -47735,7 +47840,7 @@ var AudioMarkerProjector = class {
47735
47840
  cameraId: deviceId,
47736
47841
  sourceDeviceId: deviceId,
47737
47842
  source: "audio",
47738
- kind: "audio",
47843
+ kind: AUDIO_TRACK_CLASS,
47739
47844
  timestamp: timestampMs,
47740
47845
  ...audioLabels !== void 0 ? { audioLabels } : {}
47741
47846
  });
@@ -48943,6 +49048,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48943
49048
  * {@link isSpatialTrack}.
48944
49049
  */
48945
49050
  var DEFAULT_DEBOUNCE_MS = 1e4;
49051
+ /**
49052
+ * What the debounce treats as "the same event happening again".
49053
+ *
49054
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
49055
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
49056
+ * keying on `kind` alone would collapse this to one bucket per camera. The
49057
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
49058
+ * is a legitimate second episode — and a per-camera bucket would drop it.
49059
+ *
49060
+ * Only the FIRST label is used: a materialize carries the one class the episode
49061
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
49062
+ * different buckets, which is the opposite of what a debounce is for.
49063
+ */
49064
+ function debounceKey(input) {
49065
+ const heard = input.audioLabels?.[0]?.label;
49066
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
49067
+ }
48946
49068
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48947
49069
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48948
49070
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48985,7 +49107,7 @@ var SyntheticTrackMaterializer = class {
48985
49107
  snapshotTimeoutMs;
48986
49108
  makeId;
48987
49109
  now;
48988
- /** Last snapshot time per `${cameraId}:${kind}`. */
49110
+ /** Last snapshot time per {@link debounceKey}. */
48989
49111
  lastAt = /* @__PURE__ */ new Map();
48990
49112
  constructor(deps) {
48991
49113
  this.deps = deps;
@@ -49024,7 +49146,7 @@ var SyntheticTrackMaterializer = class {
49024
49146
  * still lands a track (with no media) — it never blocks the durable record.
49025
49147
  */
49026
49148
  async materialize(input) {
49027
- const key = `${input.cameraId}:${input.kind}`;
49149
+ const key = debounceKey(input);
49028
49150
  const last = this.lastAt.get(key);
49029
49151
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
49030
49152
  this.lastAt.set(key, this.now());
@@ -50591,6 +50713,126 @@ function evaluatePeriodicSnapshot(input) {
50591
50713
  movedFraction
50592
50714
  };
50593
50715
  }
50716
+ /** Where the completion marker lives — the SAME collection
50717
+ * `runLabelTierMigration` owns and classifies; referencing the constant
50718
+ * instead of repeating the literal keeps one declarer for the guards. */
50719
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = MIGRATION_MARKER_COLLECTION;
50720
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50721
+ /**
50722
+ * The decision for ONE stored row, exposed so the rule is testable without a
50723
+ * store. `null` = leave the row alone.
50724
+ *
50725
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50726
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50727
+ * read-side normalization and hide the very rows it is looking for.
50728
+ */
50729
+ function decideAudioClassRowMigration(data) {
50730
+ if (data["source"] !== "audio") return null;
50731
+ const className = data["className"];
50732
+ if (typeof className !== "string" || className.length === 0) return null;
50733
+ if (className === "audio") return null;
50734
+ const stored = data["audioLabels"];
50735
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50736
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50737
+ return {
50738
+ className: AUDIO_TRACK_CLASS,
50739
+ audioLabels: [{
50740
+ label: className,
50741
+ peakScore: 0,
50742
+ count: 1,
50743
+ firstAt: firstSeen,
50744
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50745
+ }]
50746
+ };
50747
+ }
50748
+ /**
50749
+ * Run the pass over the tracks collection, once.
50750
+ *
50751
+ * The marker short-circuits a completed migration; it is written only when the
50752
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50753
+ * of declaring itself done.
50754
+ */
50755
+ async function runAudioTrackClassMigration(deps, collection) {
50756
+ try {
50757
+ const marker = await deps.store.get.query({
50758
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50759
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50760
+ });
50761
+ if (marker !== null && marker !== void 0) return null;
50762
+ } catch {}
50763
+ let scanned = 0;
50764
+ let rewritten = 0;
50765
+ let labelsRecovered = 0;
50766
+ let skipped = 0;
50767
+ let offset = 0;
50768
+ let truncated = false;
50769
+ for (;;) {
50770
+ if (scanned >= 2e5) {
50771
+ truncated = true;
50772
+ break;
50773
+ }
50774
+ const rows = await deps.store.query.query({
50775
+ collection,
50776
+ filter: {
50777
+ limit: 500,
50778
+ offset
50779
+ }
50780
+ });
50781
+ if (rows.length === 0) break;
50782
+ for (const row of rows) {
50783
+ scanned++;
50784
+ const patch = decideAudioClassRowMigration(row.data);
50785
+ if (patch === null) {
50786
+ skipped++;
50787
+ continue;
50788
+ }
50789
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50790
+ try {
50791
+ await deps.store.update.mutate({
50792
+ collection,
50793
+ id: row.id,
50794
+ data: patch
50795
+ });
50796
+ rewritten++;
50797
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50798
+ } catch (err) {
50799
+ deps.logger.warn("audio track class migration row failed", {
50800
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50801
+ meta: {
50802
+ collection,
50803
+ id: row.id,
50804
+ error: err instanceof Error ? err.message : String(err)
50805
+ }
50806
+ });
50807
+ skipped++;
50808
+ }
50809
+ }
50810
+ if (rows.length < 500) break;
50811
+ offset += rows.length;
50812
+ }
50813
+ if (!truncated) try {
50814
+ await deps.store.set.mutate({
50815
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50816
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50817
+ value: {
50818
+ completedAt: deps.now(),
50819
+ rewritten
50820
+ }
50821
+ });
50822
+ } catch (err) {
50823
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50824
+ }
50825
+ const stats = {
50826
+ collection,
50827
+ scanned,
50828
+ rewritten,
50829
+ labelsRecovered,
50830
+ skipped,
50831
+ truncated
50832
+ };
50833
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50834
+ return stats;
50835
+ }
50594
50836
  //#endregion
50595
50837
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50596
50838
  /**
@@ -53009,7 +53251,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53009
53251
  AnalyticsLts.declare(api.settingsStore),
53010
53252
  SceneStore.declare(api.settingsStore),
53011
53253
  NotificationCenter.declare(api.settingsStore),
53012
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53254
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53255
+ declareMigrationMarkers(api.settingsStore)
53013
53256
  ]);
53014
53257
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
53015
53258
  try {
@@ -53027,6 +53270,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53027
53270
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
53028
53271
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53029
53272
  });
53273
+ runAudioTrackClassMigration({
53274
+ store: api.settingsStore,
53275
+ logger: this.ctx.logger,
53276
+ now: () => Date.now()
53277
+ }, TRACKS_COLLECTION).catch((err) => {
53278
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53279
+ });
53030
53280
  }
53031
53281
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
53032
53282
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -56302,7 +56552,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
56302
56552
  cameraId: input.deviceId,
56303
56553
  sourceDeviceId: input.deviceId,
56304
56554
  source: "audio",
56305
- kind: ref.className,
56555
+ kind: AUDIO_TRACK_CLASS,
56306
56556
  timestamp: input.timestamp,
56307
56557
  audioLabels: [{
56308
56558
  label: ref.className,
@@ -1,4 +1,4 @@
1
- import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-CZm8slPB.mjs";
1
+ import { $ as encodeVectorBase64, A as RetrainStatusSchema, At as literal, B as TrackSourceSchema, C as NcSnoozeSchema, Ct as isDeviceScopedCap, D as OpsLogEntrySchema, Dt as array, E as NcTaxonomySchema, Et as _enum, F as SceneMonitorSchema, Ft as string, G as audioMetricsCapability, H as addonWidgetsSourceCapability, I as TIMELAPSE_DENSE_FLOOR_SEC, It as unknown, J as cosineSimilarity$1, K as audioModeOf, L as TimelapseRuleInputSchema, Lt as EventCategory, M as SCENE_DEFAULT_ANCHOR_THRESHOLD, Mt as object, N as SCENE_DEFAULT_UNCOVERED_POLICY, Nt as partialRecord, Ot as boolean, P as SCENE_DIVERGED, Pt as record, R as TimelapseRulePatchSchema, S as NcSnoozeInputSchema, St as hydrateSchema, T as NcSystemEventKindSchema, Tt as sleep, U as alarmPanelCapability, V as VISIT_MERGE_GAP_MS, W as assertTimelapseCadences, X as defineCustomActions, Y as customAction, Z as deriveRecordingMode, _ as NcRuleInputSchema, _t as zoneAnalyticsCapability, at as notificationRulesCapability, b as NcRuleTargetSchema, bt as DeviceType, c as EVENT_PAD_MS, ct as plateGalleryCapability, d as NC_ALARM_SYSTEM_EVENT_KINDS, et as faceGalleryCapability, f as NC_CONDITION_CATALOG, ft as sceneMonitorCapability, g as NcConditionDescriptorSchema, gt as videoclipsCapability, h as NC_TAXONOMY, ht as vectorDimFromBase64, i as DETECTION_MACRO_CLASSES, it as kebabToCamel, jt as number, k as RECORDING_EXPORT_MAX_READ_BYTES, kt as discriminatedUnion, l as LabelAttributionSchema, lt as readDeviceStateFrom, mt as systemEventFilterApplies, n as DEFAULT_EVENT_COLOR, nt as isDetectionMacroClass, o as DeclaredDevices, p as NC_DEFAULT_SNOOZE_MINUTES, pt as subKindsOf, q as buildEventKindDescriptor, rt as isScheduleActive, s as EVENT_KIND_BY_CAP, st as pipelineAnalyticsCapability, t as BaseDevice, u as MACRO_LABELS, ut as readTimelapseGeneratedAt, v as NcRulePatchSchema, vt as errMsg, w as NcSnoozeSuppressedSchema, wt as nodePin, x as NcScheduleSchema, xt as createEvent, y as NcRuleSchema, yt as BaseAddon, z as TimelapseRuleSchema } from "../dist-IZLiqXRa.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -8295,6 +8295,10 @@ var en_default = {
8295
8295
  "one": "plate",
8296
8296
  "other": "plates"
8297
8297
  },
8298
+ "class.audio": {
8299
+ "one": "sound",
8300
+ "other": "sounds"
8301
+ },
8298
8302
  "class.other": "{{name}}",
8299
8303
  "group.others.person": {
8300
8304
  "one": "{{count}} other person",
@@ -8513,6 +8517,10 @@ var it_default = {
8513
8517
  "one": "targa",
8514
8518
  "other": "targhe"
8515
8519
  },
8520
+ "class.audio": {
8521
+ "one": "suono",
8522
+ "other": "suoni"
8523
+ },
8516
8524
  "class.other": "{{name}}",
8517
8525
  "group.others.person": {
8518
8526
  "one": "{{count}} altra persona",
@@ -18141,7 +18149,7 @@ function applyTemplatePatch(merged, template) {
18141
18149
  return withoutTemplate;
18142
18150
  }
18143
18151
  /** Narrowing guard — the persisted `rule` column is `unknown` until parsed. */
18144
- function isRecord$1(value) {
18152
+ function isRecord$2(value) {
18145
18153
  return typeof value === "object" && value !== null && !Array.isArray(value);
18146
18154
  }
18147
18155
  /**
@@ -18171,7 +18179,7 @@ function isRecord$1(value) {
18171
18179
  * the same reason.
18172
18180
  */
18173
18181
  function withCreatedAt(rowId, raw, synthesised, now) {
18174
- if (!isRecord$1(raw)) return raw;
18182
+ if (!isRecord$2(raw)) return raw;
18175
18183
  if (typeof raw["createdAt"] === "number") return raw;
18176
18184
  const stamped = synthesised.get(rowId) ?? now();
18177
18185
  synthesised.set(rowId, stamped);
@@ -25176,7 +25184,16 @@ function clamp01$1(x) {
25176
25184
  if (x > 1) return 1;
25177
25185
  return x;
25178
25186
  }
25179
- /** Rank a class name into [0,1] by how event-worthy it is. Case-insensitive. */
25187
+ /**
25188
+ * Rank a class name into [0,1] by how event-worthy it is. Case-insensitive.
25189
+ *
25190
+ * `audio` falls through to {@link CLASS_RANK_DEFAULT} on purpose — a sound is
25191
+ * not a subject in frame. Before D189 an audio episode carried the HEARD class
25192
+ * here, so `dog`/`cat`/`animal` landed in {@link ANIMAL_CLASSES} and a bark
25193
+ * ranked as if an animal had walked past, while `speech` (500 of the 576 audio
25194
+ * rows measured live) already ranked default. That boost was an accident of the
25195
+ * class collision, not a decision. Pinned by `key-event-scoring.spec.ts`.
25196
+ */
25180
25197
  function classRank(className) {
25181
25198
  const c = className.toLowerCase();
25182
25199
  if (PERSON_CLASSES.has(c)) return 1;
@@ -40480,10 +40497,38 @@ function buildRecordedStillPorts(input) {
40480
40497
  })
40481
40498
  };
40482
40499
  }
40483
- /** Where the completion marker lives. */
40500
+ /**
40501
+ * Where the completion marker lives.
40502
+ *
40503
+ * @durable class=config owner=pipeline-analytics
40504
+ * write="one marker row when a boot migration completes; overwritten only by
40505
+ * a re-run of the same migration"
40506
+ * loss="a re-scan of the analytics store on the next boot — idempotent,
40507
+ * never destructive"
40508
+ * retention="never pruned: one row per completed migration, bounded by the
40509
+ * number of migrations ever written (two today)"
40510
+ */
40484
40511
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40485
40512
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40486
40513
  /**
40514
+ * The owner declares its marker collection — like every other store in this
40515
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40516
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40517
+ * never existed, so the engine refused every marker write, the catch below
40518
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40519
+ * boot from the day the migration shipped.
40520
+ */
40521
+ async function declareMigrationMarkers(store) {
40522
+ await store.declareCollection.mutate({
40523
+ collection: MIGRATION_MARKER_COLLECTION,
40524
+ columns: [{
40525
+ name: "data",
40526
+ type: "TEXT",
40527
+ notNull: true
40528
+ }]
40529
+ });
40530
+ }
40531
+ /**
40487
40532
  * The decision for ONE row, exposed so the rule is testable without a store.
40488
40533
  *
40489
40534
  * `null` = leave the row alone.
@@ -43642,11 +43687,11 @@ function rowToSensorEvent(id, data) {
43642
43687
  deviceId: Number(data["deviceId"]),
43643
43688
  sourceDeviceId: Number(data["sourceDeviceId"]),
43644
43689
  kind: String(data["kind"]),
43645
- value: isRecord(value) ? value : null,
43690
+ value: isRecord$1(value) ? value : null,
43646
43691
  timestamp: Number(data["timestamp"])
43647
43692
  };
43648
43693
  }
43649
- function isRecord(x) {
43694
+ function isRecord$1(x) {
43650
43695
  return x !== null && typeof x === "object" && !Array.isArray(x);
43651
43696
  }
43652
43697
  /**
@@ -43664,6 +43709,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43664
43709
  return mark.priorRollbacks < 3;
43665
43710
  }
43666
43711
  //#endregion
43712
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43713
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43714
+ var AUDIO_TRACK_CLASS = "audio";
43715
+ /**
43716
+ * True when this row predates the contract: audio-sourced, but carrying a
43717
+ * naked audio macro as its class.
43718
+ *
43719
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43720
+ * vocabulary after a row was written would then read as modern forever. The
43721
+ * question is the simple one: is this an audio row that does not say `audio`?
43722
+ */
43723
+ function isLegacyAudioTrackClass(track) {
43724
+ return track.source === "audio" && track.className !== "audio";
43725
+ }
43726
+ /**
43727
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43728
+ * there is nothing to do, so the hot path (every non-audio track, and every
43729
+ * audio track written after this shipped) allocates nothing.
43730
+ *
43731
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43732
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43733
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43734
+ * can never be the step that DESTROYS the only copy of what was heard.
43735
+ */
43736
+ function normalizeAudioTrackClass(track) {
43737
+ if (!isLegacyAudioTrackClass(track)) return track;
43738
+ const heard = {
43739
+ label: track.className,
43740
+ peakScore: 0,
43741
+ count: 1,
43742
+ firstAt: track.firstSeen,
43743
+ lastAt: track.lastSeen
43744
+ };
43745
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43746
+ return {
43747
+ ...track,
43748
+ className: AUDIO_TRACK_CLASS,
43749
+ audioLabels
43750
+ };
43751
+ }
43752
+ //#endregion
43667
43753
  //#region src/pipeline-analytics/store/recent-cursor.ts
43668
43754
  function encodeRecentCursor(cursor) {
43669
43755
  return Buffer.from(JSON.stringify({
@@ -43963,6 +44049,24 @@ var TRACKS_INDEXES = [{
43963
44049
  name: "idx_tracks_device_firstSeen",
43964
44050
  columns: ["deviceId", "firstSeen"]
43965
44051
  }];
44052
+ function isRecord(v) {
44053
+ return typeof v === "object" && v !== null;
44054
+ }
44055
+ /** Validate the JSON column back into `Track.audioLabels` — a malformed row
44056
+ * yields null rather than a lying shape (this column now carries the meaning
44057
+ * of an audio row, D189; it must not be trusted by assertion). */
44058
+ function readAudioLabels(v) {
44059
+ if (!Array.isArray(v)) return null;
44060
+ const out = [];
44061
+ for (const e of v) if (isRecord(e) && typeof e["label"] === "string" && typeof e["peakScore"] === "number" && typeof e["count"] === "number" && typeof e["firstAt"] === "number" && typeof e["lastAt"] === "number") out.push({
44062
+ label: e["label"],
44063
+ peakScore: e["peakScore"],
44064
+ count: e["count"],
44065
+ firstAt: e["firstAt"],
44066
+ lastAt: e["lastAt"]
44067
+ });
44068
+ return out.length > 0 ? out : null;
44069
+ }
43966
44070
  /** Serialize the per-label aggregate map into the `Track.audioLabels`
43967
44071
  * array shape, most-frequent label first. */
43968
44072
  function audioLabelsToArray(agg) {
@@ -45163,8 +45267,9 @@ var TrackStore = class {
45163
45267
  });
45164
45268
  const names = /* @__PURE__ */ new Set();
45165
45269
  for (const r of rows) {
45270
+ const isAudio = r.data["source"] === "audio";
45166
45271
  const className = r.data["className"];
45167
- if (typeof className === "string" && className.length > 0) names.add(className);
45272
+ if (typeof className === "string" && className.length > 0) names.add(isAudio ? AUDIO_TRACK_CLASS : className);
45168
45273
  const classes = r.data["classes"];
45169
45274
  if (Array.isArray(classes)) {
45170
45275
  for (const c of classes) if (typeof c === "string" && c.length > 0) names.add(c);
@@ -45293,7 +45398,7 @@ var TrackStore = class {
45293
45398
  const importance = data["importance"];
45294
45399
  const bestEventId = data["bestEventId"];
45295
45400
  const importanceReason = data["importanceReason"];
45296
- const audioLabels = data["audioLabels"];
45401
+ const audioLabels = readAudioLabels(data["audioLabels"]);
45297
45402
  const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
45298
45403
  const debug = data["debug"];
45299
45404
  const favourited = data["favourited"];
@@ -45311,7 +45416,7 @@ var TrackStore = class {
45311
45416
  maxX: envMaxX,
45312
45417
  maxY: envMaxY
45313
45418
  } : null;
45314
- return {
45419
+ return normalizeAudioTrackClass({
45315
45420
  trackId: id,
45316
45421
  deviceId: Number(data["deviceId"]),
45317
45422
  className: String(data["className"]),
@@ -45342,7 +45447,7 @@ var TrackStore = class {
45342
45447
  ...closedReason !== void 0 ? { closedReason } : {},
45343
45448
  observations,
45344
45449
  ...envelope !== null ? { envelope } : {}
45345
- };
45450
+ });
45346
45451
  }
45347
45452
  };
45348
45453
  //#endregion
@@ -47675,7 +47780,7 @@ var AudioMarkerProjector = class {
47675
47780
  cameraId: deviceId,
47676
47781
  sourceDeviceId: deviceId,
47677
47782
  source: "audio",
47678
- kind: "audio",
47783
+ kind: AUDIO_TRACK_CLASS,
47679
47784
  timestamp: timestampMs,
47680
47785
  ...audioLabels !== void 0 ? { audioLabels } : {}
47681
47786
  });
@@ -48883,6 +48988,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48883
48988
  * {@link isSpatialTrack}.
48884
48989
  */
48885
48990
  var DEFAULT_DEBOUNCE_MS = 1e4;
48991
+ /**
48992
+ * What the debounce treats as "the same event happening again".
48993
+ *
48994
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
48995
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
48996
+ * keying on `kind` alone would collapse this to one bucket per camera. The
48997
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
48998
+ * is a legitimate second episode — and a per-camera bucket would drop it.
48999
+ *
49000
+ * Only the FIRST label is used: a materialize carries the one class the episode
49001
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
49002
+ * different buckets, which is the opposite of what a debounce is for.
49003
+ */
49004
+ function debounceKey(input) {
49005
+ const heard = input.audioLabels?.[0]?.label;
49006
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
49007
+ }
48886
49008
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48887
49009
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48888
49010
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48925,7 +49047,7 @@ var SyntheticTrackMaterializer = class {
48925
49047
  snapshotTimeoutMs;
48926
49048
  makeId;
48927
49049
  now;
48928
- /** Last snapshot time per `${cameraId}:${kind}`. */
49050
+ /** Last snapshot time per {@link debounceKey}. */
48929
49051
  lastAt = /* @__PURE__ */ new Map();
48930
49052
  constructor(deps) {
48931
49053
  this.deps = deps;
@@ -48964,7 +49086,7 @@ var SyntheticTrackMaterializer = class {
48964
49086
  * still lands a track (with no media) — it never blocks the durable record.
48965
49087
  */
48966
49088
  async materialize(input) {
48967
- const key = `${input.cameraId}:${input.kind}`;
49089
+ const key = debounceKey(input);
48968
49090
  const last = this.lastAt.get(key);
48969
49091
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
48970
49092
  this.lastAt.set(key, this.now());
@@ -50531,6 +50653,126 @@ function evaluatePeriodicSnapshot(input) {
50531
50653
  movedFraction
50532
50654
  };
50533
50655
  }
50656
+ /** Where the completion marker lives — the SAME collection
50657
+ * `runLabelTierMigration` owns and classifies; referencing the constant
50658
+ * instead of repeating the literal keeps one declarer for the guards. */
50659
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = MIGRATION_MARKER_COLLECTION;
50660
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50661
+ /**
50662
+ * The decision for ONE stored row, exposed so the rule is testable without a
50663
+ * store. `null` = leave the row alone.
50664
+ *
50665
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50666
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50667
+ * read-side normalization and hide the very rows it is looking for.
50668
+ */
50669
+ function decideAudioClassRowMigration(data) {
50670
+ if (data["source"] !== "audio") return null;
50671
+ const className = data["className"];
50672
+ if (typeof className !== "string" || className.length === 0) return null;
50673
+ if (className === "audio") return null;
50674
+ const stored = data["audioLabels"];
50675
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50676
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50677
+ return {
50678
+ className: AUDIO_TRACK_CLASS,
50679
+ audioLabels: [{
50680
+ label: className,
50681
+ peakScore: 0,
50682
+ count: 1,
50683
+ firstAt: firstSeen,
50684
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50685
+ }]
50686
+ };
50687
+ }
50688
+ /**
50689
+ * Run the pass over the tracks collection, once.
50690
+ *
50691
+ * The marker short-circuits a completed migration; it is written only when the
50692
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50693
+ * of declaring itself done.
50694
+ */
50695
+ async function runAudioTrackClassMigration(deps, collection) {
50696
+ try {
50697
+ const marker = await deps.store.get.query({
50698
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50699
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50700
+ });
50701
+ if (marker !== null && marker !== void 0) return null;
50702
+ } catch {}
50703
+ let scanned = 0;
50704
+ let rewritten = 0;
50705
+ let labelsRecovered = 0;
50706
+ let skipped = 0;
50707
+ let offset = 0;
50708
+ let truncated = false;
50709
+ for (;;) {
50710
+ if (scanned >= 2e5) {
50711
+ truncated = true;
50712
+ break;
50713
+ }
50714
+ const rows = await deps.store.query.query({
50715
+ collection,
50716
+ filter: {
50717
+ limit: 500,
50718
+ offset
50719
+ }
50720
+ });
50721
+ if (rows.length === 0) break;
50722
+ for (const row of rows) {
50723
+ scanned++;
50724
+ const patch = decideAudioClassRowMigration(row.data);
50725
+ if (patch === null) {
50726
+ skipped++;
50727
+ continue;
50728
+ }
50729
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50730
+ try {
50731
+ await deps.store.update.mutate({
50732
+ collection,
50733
+ id: row.id,
50734
+ data: patch
50735
+ });
50736
+ rewritten++;
50737
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50738
+ } catch (err) {
50739
+ deps.logger.warn("audio track class migration row failed", {
50740
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50741
+ meta: {
50742
+ collection,
50743
+ id: row.id,
50744
+ error: err instanceof Error ? err.message : String(err)
50745
+ }
50746
+ });
50747
+ skipped++;
50748
+ }
50749
+ }
50750
+ if (rows.length < 500) break;
50751
+ offset += rows.length;
50752
+ }
50753
+ if (!truncated) try {
50754
+ await deps.store.set.mutate({
50755
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50756
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50757
+ value: {
50758
+ completedAt: deps.now(),
50759
+ rewritten
50760
+ }
50761
+ });
50762
+ } catch (err) {
50763
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50764
+ }
50765
+ const stats = {
50766
+ collection,
50767
+ scanned,
50768
+ rewritten,
50769
+ labelsRecovered,
50770
+ skipped,
50771
+ truncated
50772
+ };
50773
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50774
+ return stats;
50775
+ }
50534
50776
  //#endregion
50535
50777
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50536
50778
  /**
@@ -52949,7 +53191,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52949
53191
  AnalyticsLts.declare(api.settingsStore),
52950
53192
  SceneStore.declare(api.settingsStore),
52951
53193
  NotificationCenter.declare(api.settingsStore),
52952
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53194
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53195
+ declareMigrationMarkers(api.settingsStore)
52953
53196
  ]);
52954
53197
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
52955
53198
  try {
@@ -52967,6 +53210,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52967
53210
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
52968
53211
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
52969
53212
  });
53213
+ runAudioTrackClassMigration({
53214
+ store: api.settingsStore,
53215
+ logger: this.ctx.logger,
53216
+ now: () => Date.now()
53217
+ }, TRACKS_COLLECTION).catch((err) => {
53218
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53219
+ });
52970
53220
  }
52971
53221
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
52972
53222
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -56242,7 +56492,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
56242
56492
  cameraId: input.deviceId,
56243
56493
  sourceDeviceId: input.deviceId,
56244
56494
  source: "audio",
56245
- kind: ref.className,
56495
+ kind: AUDIO_TRACK_CLASS,
56246
56496
  timestamp: input.timestamp,
56247
56497
  audioLabels: [{
56248
56498
  label: ref.className,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-MP5xue-m.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D8fRo8aP.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.102",
3
+ "version": "1.2.104",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",