@camstack/addon-post-analysis 1.2.102 → 1.2.103

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.
@@ -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",
@@ -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;
@@ -40544,6 +40561,24 @@ function buildRecordedStillPorts(input) {
40544
40561
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40545
40562
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40546
40563
  /**
40564
+ * The owner declares its marker collection — like every other store in this
40565
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40566
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40567
+ * never existed, so the engine refused every marker write, the catch below
40568
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40569
+ * boot from the day the migration shipped.
40570
+ */
40571
+ async function declareMigrationMarkers(store) {
40572
+ await store.declareCollection.mutate({
40573
+ collection: MIGRATION_MARKER_COLLECTION,
40574
+ columns: [{
40575
+ name: "data",
40576
+ type: "TEXT",
40577
+ notNull: true
40578
+ }]
40579
+ });
40580
+ }
40581
+ /**
40547
40582
  * The decision for ONE row, exposed so the rule is testable without a store.
40548
40583
  *
40549
40584
  * `null` = leave the row alone.
@@ -43724,6 +43759,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43724
43759
  return mark.priorRollbacks < 3;
43725
43760
  }
43726
43761
  //#endregion
43762
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43763
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43764
+ var AUDIO_TRACK_CLASS = "audio";
43765
+ /**
43766
+ * True when this row predates the contract: audio-sourced, but carrying a
43767
+ * naked audio macro as its class.
43768
+ *
43769
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43770
+ * vocabulary after a row was written would then read as modern forever. The
43771
+ * question is the simple one: is this an audio row that does not say `audio`?
43772
+ */
43773
+ function isLegacyAudioTrackClass(track) {
43774
+ return track.source === "audio" && track.className !== "audio";
43775
+ }
43776
+ /**
43777
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43778
+ * there is nothing to do, so the hot path (every non-audio track, and every
43779
+ * audio track written after this shipped) allocates nothing.
43780
+ *
43781
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43782
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43783
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43784
+ * can never be the step that DESTROYS the only copy of what was heard.
43785
+ */
43786
+ function normalizeAudioTrackClass(track) {
43787
+ if (!isLegacyAudioTrackClass(track)) return track;
43788
+ const heard = {
43789
+ label: track.className,
43790
+ peakScore: 0,
43791
+ count: 1,
43792
+ firstAt: track.firstSeen,
43793
+ lastAt: track.lastSeen
43794
+ };
43795
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43796
+ return {
43797
+ ...track,
43798
+ className: AUDIO_TRACK_CLASS,
43799
+ audioLabels
43800
+ };
43801
+ }
43802
+ //#endregion
43727
43803
  //#region src/pipeline-analytics/store/recent-cursor.ts
43728
43804
  function encodeRecentCursor(cursor) {
43729
43805
  return Buffer.from(JSON.stringify({
@@ -45371,7 +45447,7 @@ var TrackStore = class {
45371
45447
  maxX: envMaxX,
45372
45448
  maxY: envMaxY
45373
45449
  } : null;
45374
- return {
45450
+ return normalizeAudioTrackClass({
45375
45451
  trackId: id,
45376
45452
  deviceId: Number(data["deviceId"]),
45377
45453
  className: String(data["className"]),
@@ -45402,7 +45478,7 @@ var TrackStore = class {
45402
45478
  ...closedReason !== void 0 ? { closedReason } : {},
45403
45479
  observations,
45404
45480
  ...envelope !== null ? { envelope } : {}
45405
- };
45481
+ });
45406
45482
  }
45407
45483
  };
45408
45484
  //#endregion
@@ -47735,7 +47811,7 @@ var AudioMarkerProjector = class {
47735
47811
  cameraId: deviceId,
47736
47812
  sourceDeviceId: deviceId,
47737
47813
  source: "audio",
47738
- kind: "audio",
47814
+ kind: AUDIO_TRACK_CLASS,
47739
47815
  timestamp: timestampMs,
47740
47816
  ...audioLabels !== void 0 ? { audioLabels } : {}
47741
47817
  });
@@ -48943,6 +49019,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48943
49019
  * {@link isSpatialTrack}.
48944
49020
  */
48945
49021
  var DEFAULT_DEBOUNCE_MS = 1e4;
49022
+ /**
49023
+ * What the debounce treats as "the same event happening again".
49024
+ *
49025
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
49026
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
49027
+ * keying on `kind` alone would collapse this to one bucket per camera. The
49028
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
49029
+ * is a legitimate second episode — and a per-camera bucket would drop it.
49030
+ *
49031
+ * Only the FIRST label is used: a materialize carries the one class the episode
49032
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
49033
+ * different buckets, which is the opposite of what a debounce is for.
49034
+ */
49035
+ function debounceKey(input) {
49036
+ const heard = input.audioLabels?.[0]?.label;
49037
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
49038
+ }
48946
49039
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48947
49040
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48948
49041
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48985,7 +49078,7 @@ var SyntheticTrackMaterializer = class {
48985
49078
  snapshotTimeoutMs;
48986
49079
  makeId;
48987
49080
  now;
48988
- /** Last snapshot time per `${cameraId}:${kind}`. */
49081
+ /** Last snapshot time per {@link debounceKey}. */
48989
49082
  lastAt = /* @__PURE__ */ new Map();
48990
49083
  constructor(deps) {
48991
49084
  this.deps = deps;
@@ -49024,7 +49117,7 @@ var SyntheticTrackMaterializer = class {
49024
49117
  * still lands a track (with no media) — it never blocks the durable record.
49025
49118
  */
49026
49119
  async materialize(input) {
49027
- const key = `${input.cameraId}:${input.kind}`;
49120
+ const key = debounceKey(input);
49028
49121
  const last = this.lastAt.get(key);
49029
49122
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
49030
49123
  this.lastAt.set(key, this.now());
@@ -50591,6 +50684,125 @@ function evaluatePeriodicSnapshot(input) {
50591
50684
  movedFraction
50592
50685
  };
50593
50686
  }
50687
+ /** Where the completion marker lives — the collection `runLabelTierMigration`
50688
+ * already established for exactly this. */
50689
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
50690
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50691
+ /**
50692
+ * The decision for ONE stored row, exposed so the rule is testable without a
50693
+ * store. `null` = leave the row alone.
50694
+ *
50695
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50696
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50697
+ * read-side normalization and hide the very rows it is looking for.
50698
+ */
50699
+ function decideAudioClassRowMigration(data) {
50700
+ if (data["source"] !== "audio") return null;
50701
+ const className = data["className"];
50702
+ if (typeof className !== "string" || className.length === 0) return null;
50703
+ if (className === "audio") return null;
50704
+ const stored = data["audioLabels"];
50705
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50706
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50707
+ return {
50708
+ className: AUDIO_TRACK_CLASS,
50709
+ audioLabels: [{
50710
+ label: className,
50711
+ peakScore: 0,
50712
+ count: 1,
50713
+ firstAt: firstSeen,
50714
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50715
+ }]
50716
+ };
50717
+ }
50718
+ /**
50719
+ * Run the pass over the tracks collection, once.
50720
+ *
50721
+ * The marker short-circuits a completed migration; it is written only when the
50722
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50723
+ * of declaring itself done.
50724
+ */
50725
+ async function runAudioTrackClassMigration(deps, collection) {
50726
+ try {
50727
+ const marker = await deps.store.get.query({
50728
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50729
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50730
+ });
50731
+ if (marker !== null && marker !== void 0) return null;
50732
+ } catch {}
50733
+ let scanned = 0;
50734
+ let rewritten = 0;
50735
+ let labelsRecovered = 0;
50736
+ let skipped = 0;
50737
+ let offset = 0;
50738
+ let truncated = false;
50739
+ for (;;) {
50740
+ if (scanned >= 2e5) {
50741
+ truncated = true;
50742
+ break;
50743
+ }
50744
+ const rows = await deps.store.query.query({
50745
+ collection,
50746
+ filter: {
50747
+ limit: 500,
50748
+ offset
50749
+ }
50750
+ });
50751
+ if (rows.length === 0) break;
50752
+ for (const row of rows) {
50753
+ scanned++;
50754
+ const patch = decideAudioClassRowMigration(row.data);
50755
+ if (patch === null) {
50756
+ skipped++;
50757
+ continue;
50758
+ }
50759
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50760
+ try {
50761
+ await deps.store.update.mutate({
50762
+ collection,
50763
+ id: row.id,
50764
+ data: patch
50765
+ });
50766
+ rewritten++;
50767
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50768
+ } catch (err) {
50769
+ deps.logger.warn("audio track class migration row failed", {
50770
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50771
+ meta: {
50772
+ collection,
50773
+ id: row.id,
50774
+ error: err instanceof Error ? err.message : String(err)
50775
+ }
50776
+ });
50777
+ skipped++;
50778
+ }
50779
+ }
50780
+ if (rows.length < 500) break;
50781
+ offset += rows.length;
50782
+ }
50783
+ if (!truncated) try {
50784
+ await deps.store.set.mutate({
50785
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50786
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50787
+ value: {
50788
+ completedAt: deps.now(),
50789
+ rewritten
50790
+ }
50791
+ });
50792
+ } catch (err) {
50793
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50794
+ }
50795
+ const stats = {
50796
+ collection,
50797
+ scanned,
50798
+ rewritten,
50799
+ labelsRecovered,
50800
+ skipped,
50801
+ truncated
50802
+ };
50803
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50804
+ return stats;
50805
+ }
50594
50806
  //#endregion
50595
50807
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50596
50808
  /**
@@ -53009,7 +53221,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53009
53221
  AnalyticsLts.declare(api.settingsStore),
53010
53222
  SceneStore.declare(api.settingsStore),
53011
53223
  NotificationCenter.declare(api.settingsStore),
53012
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53224
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53225
+ declareMigrationMarkers(api.settingsStore)
53013
53226
  ]);
53014
53227
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
53015
53228
  try {
@@ -53027,6 +53240,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53027
53240
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
53028
53241
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53029
53242
  });
53243
+ runAudioTrackClassMigration({
53244
+ store: api.settingsStore,
53245
+ logger: this.ctx.logger,
53246
+ now: () => Date.now()
53247
+ }, TRACKS_COLLECTION).catch((err) => {
53248
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53249
+ });
53030
53250
  }
53031
53251
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
53032
53252
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -56302,7 +56522,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
56302
56522
  cameraId: input.deviceId,
56303
56523
  sourceDeviceId: input.deviceId,
56304
56524
  source: "audio",
56305
- kind: ref.className,
56525
+ kind: AUDIO_TRACK_CLASS,
56306
56526
  timestamp: input.timestamp,
56307
56527
  audioLabels: [{
56308
56528
  label: ref.className,
@@ -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",
@@ -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;
@@ -40484,6 +40501,24 @@ function buildRecordedStillPorts(input) {
40484
40501
  var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40485
40502
  var MIGRATION_MARKER_KEY = "label-tier-4g";
40486
40503
  /**
40504
+ * The owner declares its marker collection — like every other store in this
40505
+ * addon. KV shape (a single `data` column: `get`/`set` route the value as a
40506
+ * JSON blob; the engine adds the implicit `id` primary key). This declaration
40507
+ * never existed, so the engine refused every marker write, the catch below
40508
+ * warned, and every boot re-scanned the full analytics store — ~36k rows per
40509
+ * boot from the day the migration shipped.
40510
+ */
40511
+ async function declareMigrationMarkers(store) {
40512
+ await store.declareCollection.mutate({
40513
+ collection: MIGRATION_MARKER_COLLECTION,
40514
+ columns: [{
40515
+ name: "data",
40516
+ type: "TEXT",
40517
+ notNull: true
40518
+ }]
40519
+ });
40520
+ }
40521
+ /**
40487
40522
  * The decision for ONE row, exposed so the rule is testable without a store.
40488
40523
  *
40489
40524
  * `null` = leave the row alone.
@@ -43664,6 +43699,47 @@ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
43664
43699
  return mark.priorRollbacks < 3;
43665
43700
  }
43666
43701
  //#endregion
43702
+ //#region src/pipeline-analytics/store/audio-track-class.ts
43703
+ /** The taxonomy macro every audio-sourced track carries as its class. */
43704
+ var AUDIO_TRACK_CLASS = "audio";
43705
+ /**
43706
+ * True when this row predates the contract: audio-sourced, but carrying a
43707
+ * naked audio macro as its class.
43708
+ *
43709
+ * Deliberately NOT "className is in AUDIO_MACRO_LABELS" — an id added to the
43710
+ * vocabulary after a row was written would then read as modern forever. The
43711
+ * question is the simple one: is this an audio row that does not say `audio`?
43712
+ */
43713
+ function isLegacyAudioTrackClass(track) {
43714
+ return track.source === "audio" && track.className !== "audio";
43715
+ }
43716
+ /**
43717
+ * Bring a hydrated row up to the contract. Returns the SAME reference when
43718
+ * there is nothing to do, so the hot path (every non-audio track, and every
43719
+ * audio track written after this shipped) allocates nothing.
43720
+ *
43721
+ * The naked class is preserved as an `audioLabels` entry when — and only when
43722
+ * — the row carries none. On the 576 legacy rows measured on the live hub that
43723
+ * branch is unreachable (all of them have labels); it exists so the rewrite
43724
+ * can never be the step that DESTROYS the only copy of what was heard.
43725
+ */
43726
+ function normalizeAudioTrackClass(track) {
43727
+ if (!isLegacyAudioTrackClass(track)) return track;
43728
+ const heard = {
43729
+ label: track.className,
43730
+ peakScore: 0,
43731
+ count: 1,
43732
+ firstAt: track.firstSeen,
43733
+ lastAt: track.lastSeen
43734
+ };
43735
+ const audioLabels = track.audioLabels !== void 0 && track.audioLabels.length > 0 ? track.audioLabels : [heard];
43736
+ return {
43737
+ ...track,
43738
+ className: AUDIO_TRACK_CLASS,
43739
+ audioLabels
43740
+ };
43741
+ }
43742
+ //#endregion
43667
43743
  //#region src/pipeline-analytics/store/recent-cursor.ts
43668
43744
  function encodeRecentCursor(cursor) {
43669
43745
  return Buffer.from(JSON.stringify({
@@ -45311,7 +45387,7 @@ var TrackStore = class {
45311
45387
  maxX: envMaxX,
45312
45388
  maxY: envMaxY
45313
45389
  } : null;
45314
- return {
45390
+ return normalizeAudioTrackClass({
45315
45391
  trackId: id,
45316
45392
  deviceId: Number(data["deviceId"]),
45317
45393
  className: String(data["className"]),
@@ -45342,7 +45418,7 @@ var TrackStore = class {
45342
45418
  ...closedReason !== void 0 ? { closedReason } : {},
45343
45419
  observations,
45344
45420
  ...envelope !== null ? { envelope } : {}
45345
- };
45421
+ });
45346
45422
  }
45347
45423
  };
45348
45424
  //#endregion
@@ -47675,7 +47751,7 @@ var AudioMarkerProjector = class {
47675
47751
  cameraId: deviceId,
47676
47752
  sourceDeviceId: deviceId,
47677
47753
  source: "audio",
47678
- kind: "audio",
47754
+ kind: AUDIO_TRACK_CLASS,
47679
47755
  timestamp: timestampMs,
47680
47756
  ...audioLabels !== void 0 ? { audioLabels } : {}
47681
47757
  });
@@ -48883,6 +48959,23 @@ async function projectSensorMarkers(deps, data, timestamp) {
48883
48959
  * {@link isSpatialTrack}.
48884
48960
  */
48885
48961
  var DEFAULT_DEBOUNCE_MS = 1e4;
48962
+ /**
48963
+ * What the debounce treats as "the same event happening again".
48964
+ *
48965
+ * `(camera, kind)` — plus, for an audio row, the class HEARD. Since D189 every
48966
+ * audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
48967
+ * keying on `kind` alone would collapse this to one bucket per camera. The
48968
+ * episode hysteresis cools down PER CLASS, so a bark three seconds after speech
48969
+ * is a legitimate second episode — and a per-camera bucket would drop it.
48970
+ *
48971
+ * Only the FIRST label is used: a materialize carries the one class the episode
48972
+ * opened on. Joining all of them would make `[speech]` and `[speech, dog]`
48973
+ * different buckets, which is the opposite of what a debounce is for.
48974
+ */
48975
+ function debounceKey(input) {
48976
+ const heard = input.audioLabels?.[0]?.label;
48977
+ return heard === void 0 ? `${input.cameraId}:${input.kind}` : `${input.cameraId}:${input.kind}:${heard}`;
48978
+ }
48886
48979
  /** A hanging snapshot cap must not stall the sensor state-change path. */
48887
48980
  var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
48888
48981
  /** Distinguishes a snapshot-cap timeout from a fetch failure for reporting. */
@@ -48925,7 +49018,7 @@ var SyntheticTrackMaterializer = class {
48925
49018
  snapshotTimeoutMs;
48926
49019
  makeId;
48927
49020
  now;
48928
- /** Last snapshot time per `${cameraId}:${kind}`. */
49021
+ /** Last snapshot time per {@link debounceKey}. */
48929
49022
  lastAt = /* @__PURE__ */ new Map();
48930
49023
  constructor(deps) {
48931
49024
  this.deps = deps;
@@ -48964,7 +49057,7 @@ var SyntheticTrackMaterializer = class {
48964
49057
  * still lands a track (with no media) — it never blocks the durable record.
48965
49058
  */
48966
49059
  async materialize(input) {
48967
- const key = `${input.cameraId}:${input.kind}`;
49060
+ const key = debounceKey(input);
48968
49061
  const last = this.lastAt.get(key);
48969
49062
  if (last !== void 0 && this.now() - last < this.debounceMs) return null;
48970
49063
  this.lastAt.set(key, this.now());
@@ -50531,6 +50624,125 @@ function evaluatePeriodicSnapshot(input) {
50531
50624
  movedFraction
50532
50625
  };
50533
50626
  }
50627
+ /** Where the completion marker lives — the collection `runLabelTierMigration`
50628
+ * already established for exactly this. */
50629
+ var AUDIO_CLASS_MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
50630
+ var AUDIO_CLASS_MIGRATION_MARKER_KEY = "audio-track-class-d189";
50631
+ /**
50632
+ * The decision for ONE stored row, exposed so the rule is testable without a
50633
+ * store. `null` = leave the row alone.
50634
+ *
50635
+ * Reads the RAW row rather than a hydrated `Track` on purpose: this is the
50636
+ * column the pass rewrites, and hydrating through `rowToTrack` would apply the
50637
+ * read-side normalization and hide the very rows it is looking for.
50638
+ */
50639
+ function decideAudioClassRowMigration(data) {
50640
+ if (data["source"] !== "audio") return null;
50641
+ const className = data["className"];
50642
+ if (typeof className !== "string" || className.length === 0) return null;
50643
+ if (className === "audio") return null;
50644
+ const stored = data["audioLabels"];
50645
+ if (Array.isArray(stored) && stored.length > 0) return { className: AUDIO_TRACK_CLASS };
50646
+ const firstSeen = typeof data["firstSeen"] === "number" ? data["firstSeen"] : 0;
50647
+ return {
50648
+ className: AUDIO_TRACK_CLASS,
50649
+ audioLabels: [{
50650
+ label: className,
50651
+ peakScore: 0,
50652
+ count: 1,
50653
+ firstAt: firstSeen,
50654
+ lastAt: typeof data["lastSeen"] === "number" ? data["lastSeen"] : firstSeen
50655
+ }]
50656
+ };
50657
+ }
50658
+ /**
50659
+ * Run the pass over the tracks collection, once.
50660
+ *
50661
+ * The marker short-circuits a completed migration; it is written only when the
50662
+ * pass did NOT truncate, so an interrupted run resumes on the next boot instead
50663
+ * of declaring itself done.
50664
+ */
50665
+ async function runAudioTrackClassMigration(deps, collection) {
50666
+ try {
50667
+ const marker = await deps.store.get.query({
50668
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50669
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY
50670
+ });
50671
+ if (marker !== null && marker !== void 0) return null;
50672
+ } catch {}
50673
+ let scanned = 0;
50674
+ let rewritten = 0;
50675
+ let labelsRecovered = 0;
50676
+ let skipped = 0;
50677
+ let offset = 0;
50678
+ let truncated = false;
50679
+ for (;;) {
50680
+ if (scanned >= 2e5) {
50681
+ truncated = true;
50682
+ break;
50683
+ }
50684
+ const rows = await deps.store.query.query({
50685
+ collection,
50686
+ filter: {
50687
+ limit: 500,
50688
+ offset
50689
+ }
50690
+ });
50691
+ if (rows.length === 0) break;
50692
+ for (const row of rows) {
50693
+ scanned++;
50694
+ const patch = decideAudioClassRowMigration(row.data);
50695
+ if (patch === null) {
50696
+ skipped++;
50697
+ continue;
50698
+ }
50699
+ const deviceId = typeof row.data["deviceId"] === "number" ? row.data["deviceId"] : void 0;
50700
+ try {
50701
+ await deps.store.update.mutate({
50702
+ collection,
50703
+ id: row.id,
50704
+ data: patch
50705
+ });
50706
+ rewritten++;
50707
+ if (patch["audioLabels"] !== void 0) labelsRecovered++;
50708
+ } catch (err) {
50709
+ deps.logger.warn("audio track class migration row failed", {
50710
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
50711
+ meta: {
50712
+ collection,
50713
+ id: row.id,
50714
+ error: err instanceof Error ? err.message : String(err)
50715
+ }
50716
+ });
50717
+ skipped++;
50718
+ }
50719
+ }
50720
+ if (rows.length < 500) break;
50721
+ offset += rows.length;
50722
+ }
50723
+ if (!truncated) try {
50724
+ await deps.store.set.mutate({
50725
+ collection: AUDIO_CLASS_MIGRATION_MARKER_COLLECTION,
50726
+ key: AUDIO_CLASS_MIGRATION_MARKER_KEY,
50727
+ value: {
50728
+ completedAt: deps.now(),
50729
+ rewritten
50730
+ }
50731
+ });
50732
+ } catch (err) {
50733
+ deps.logger.warn("audio track class migration marker failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
50734
+ }
50735
+ const stats = {
50736
+ collection,
50737
+ scanned,
50738
+ rewritten,
50739
+ labelsRecovered,
50740
+ skipped,
50741
+ truncated
50742
+ };
50743
+ deps.logger.info("audio track class migration", { meta: { ...stats } });
50744
+ return stats;
50745
+ }
50534
50746
  //#endregion
50535
50747
  //#region src/pipeline-analytics/store/object-embedding-store.ts
50536
50748
  /**
@@ -52949,7 +53161,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52949
53161
  AnalyticsLts.declare(api.settingsStore),
52950
53162
  SceneStore.declare(api.settingsStore),
52951
53163
  NotificationCenter.declare(api.settingsStore),
52952
- ViewerSettingsSnapshotStore.declare(api.settingsStore)
53164
+ ViewerSettingsSnapshotStore.declare(api.settingsStore),
53165
+ declareMigrationMarkers(api.settingsStore)
52953
53166
  ]);
52954
53167
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
52955
53168
  try {
@@ -52967,6 +53180,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52967
53180
  }, [TRACKS_COLLECTION, OBJECT_EVENTS_COLLECTION]).catch((err) => {
52968
53181
  this.ctx.logger.warn("label tier migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
52969
53182
  });
53183
+ runAudioTrackClassMigration({
53184
+ store: api.settingsStore,
53185
+ logger: this.ctx.logger,
53186
+ now: () => Date.now()
53187
+ }, TRACKS_COLLECTION).catch((err) => {
53188
+ this.ctx.logger.warn("audio track class migration failed — will retry next boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
53189
+ });
52970
53190
  }
52971
53191
  /** Event-media storage. By default it uses `ctx.kernel.storage`, rooted at
52972
53192
  * the node data dir (appData) → heavy thumbnail/track-frame churn hammers
@@ -56242,7 +56462,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
56242
56462
  cameraId: input.deviceId,
56243
56463
  sourceDeviceId: input.deviceId,
56244
56464
  source: "audio",
56245
- kind: ref.className,
56465
+ kind: AUDIO_TRACK_CLASS,
56246
56466
  timestamp: input.timestamp,
56247
56467
  audioLabels: [{
56248
56468
  label: ref.className,
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.103",
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",