@camstack/addon-post-analysis 1.2.100 → 1.2.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as 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-ecY_-nDx.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-CZm8slPB.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";
@@ -8297,23 +8297,23 @@ var en_default = {
8297
8297
  },
8298
8298
  "class.other": "{{name}}",
8299
8299
  "group.others.person": {
8300
- "one": "1 other person",
8300
+ "one": "{{count}} other person",
8301
8301
  "other": "{{count}} other people"
8302
8302
  },
8303
8303
  "group.others.vehicle": {
8304
- "one": "1 other vehicle",
8304
+ "one": "{{count}} other vehicle",
8305
8305
  "other": "{{count}} other vehicles"
8306
8306
  },
8307
8307
  "group.others.animal": {
8308
- "one": "1 other animal",
8308
+ "one": "{{count}} other animal",
8309
8309
  "other": "{{count}} other animals"
8310
8310
  },
8311
8311
  "group.others.package": {
8312
- "one": "1 other package",
8312
+ "one": "{{count}} other package",
8313
8313
  "other": "{{count}} other packages"
8314
8314
  },
8315
8315
  "group.others.other": {
8316
- "one": "1 other {{classNoun}}",
8316
+ "one": "{{count}} other {{classNoun}}",
8317
8317
  "other": "{{count}} other {{classNoun}}"
8318
8318
  },
8319
8319
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
@@ -8352,8 +8352,11 @@ var en_default = {
8352
8352
  "one": "{{count}} addon update available",
8353
8353
  "other": "{{count}} addon updates available"
8354
8354
  },
8355
+ "system.addon-update-available.title.onNode": {
8356
+ "one": "{{count}} addon update available on {{nodeId}}",
8357
+ "other": "{{count}} addon updates available on {{nodeId}}"
8358
+ },
8355
8359
  "system.addon-update-available.body": "{{list}}",
8356
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} is available (current {{currentVersion}}) on node {{nodeId}}",
8357
8360
  "system.server-update-available.title": {
8358
8361
  "one": "Server update available on {{count}} node",
8359
8362
  "other": "Server update available on {{count}} nodes"
@@ -8368,6 +8371,7 @@ var en_default = {
8368
8371
  "system.addon-updated.body": "{{list}}",
8369
8372
  "system.server-updated.title": "Server updated",
8370
8373
  "system.server-updated.body": "{{packageName}} on {{nodeId}} is now {{toVersion}} (was {{fromVersion}})",
8374
+ "system.server-updated.body.toOnly": "{{packageName}} on {{nodeId}} is now {{toVersion}}",
8371
8375
  "system.server-updated.body.plain": "{{packageName}} on {{nodeId}} was updated",
8372
8376
  "system.export-completed.title": "Export ready",
8373
8377
  "system.export-completed.body": "The footage export is ready",
@@ -8511,23 +8515,23 @@ var it_default = {
8511
8515
  },
8512
8516
  "class.other": "{{name}}",
8513
8517
  "group.others.person": {
8514
- "one": "1 altra persona",
8518
+ "one": "{{count}} altra persona",
8515
8519
  "other": "{{count}} altre persone"
8516
8520
  },
8517
8521
  "group.others.vehicle": {
8518
- "one": "1 altro veicolo",
8522
+ "one": "{{count}} altro veicolo",
8519
8523
  "other": "{{count}} altri veicoli"
8520
8524
  },
8521
8525
  "group.others.animal": {
8522
- "one": "1 altro animale",
8526
+ "one": "{{count}} altro animale",
8523
8527
  "other": "{{count}} altri animali"
8524
8528
  },
8525
8529
  "group.others.package": {
8526
- "one": "1 altro pacco",
8530
+ "one": "{{count}} altro pacco",
8527
8531
  "other": "{{count}} altri pacchi"
8528
8532
  },
8529
8533
  "group.others.other": {
8530
- "one": "1 altro {{classNoun}}",
8534
+ "one": "{{count}} altro {{classNoun}}",
8531
8535
  "other": "{{count}} altri {{classNoun}}"
8532
8536
  },
8533
8537
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
@@ -8566,8 +8570,11 @@ var it_default = {
8566
8570
  "one": "{{count}} aggiornamento addon disponibile",
8567
8571
  "other": "{{count}} aggiornamenti addon disponibili"
8568
8572
  },
8573
+ "system.addon-update-available.title.onNode": {
8574
+ "one": "{{count}} aggiornamento addon disponibile su {{nodeId}}",
8575
+ "other": "{{count}} aggiornamenti addon disponibili su {{nodeId}}"
8576
+ },
8569
8577
  "system.addon-update-available.body": "{{list}}",
8570
- "system.addon-update-available.body.onNode": "{{packageName}} {{latestVersion}} è disponibile (attuale {{currentVersion}}) sul nodo {{nodeId}}",
8571
8578
  "system.server-update-available.title": {
8572
8579
  "one": "Aggiornamento server disponibile su {{count}} nodo",
8573
8580
  "other": "Aggiornamento server disponibile su {{count}} nodi"
@@ -8582,6 +8589,7 @@ var it_default = {
8582
8589
  "system.addon-updated.body": "{{list}}",
8583
8590
  "system.server-updated.title": "Server aggiornato",
8584
8591
  "system.server-updated.body": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}} (era {{fromVersion}})",
8592
+ "system.server-updated.body.toOnly": "{{packageName}} sul nodo {{nodeId}} è ora {{toVersion}}",
8585
8593
  "system.server-updated.body.plain": "{{packageName}} sul nodo {{nodeId}} è stato aggiornato",
8586
8594
  "system.export-completed.title": "Export pronto",
8587
8595
  "system.export-completed.body": "L'export del filmato è pronto",
@@ -10404,15 +10412,15 @@ function incomingFromUpdateAvailable(facts, data) {
10404
10412
  const nodeIds = unique(packages.map((pkg) => pkg.nodeId).filter((id) => id !== void 0));
10405
10413
  const packageNames = unique(packages.map((pkg) => pkg.packageName));
10406
10414
  const signature = packages.map((pkg) => `${pkg.nodeId ?? ""}:${pkg.packageName}@${pkg.latestVersion}`).toSorted().join(",");
10407
- const list = packages.map(formatAvailableLine).join("\n");
10408
- const nodes = nodeIds.join(", ");
10409
10415
  const head = packages[0];
10410
10416
  const oneNode = nodeIds.length === 1 ? nodeIds[0] : void 0;
10417
+ const list = packages.map((pkg) => formatAvailableLine(pkg, oneNode !== void 0)).join("\n");
10418
+ const nodes = nodeIds.join(", ");
10411
10419
  return systemSubject({
10412
10420
  facts,
10413
10421
  kind,
10414
10422
  subject: addon ? `packages:addon:${signature}` : `packages:server:${signature}`,
10415
- titleKey: `system.${kind}.title`,
10423
+ titleKey: addon && oneNode !== void 0 ? `system.${kind}.title.onNode` : `system.${kind}.title`,
10416
10424
  bodyKey: !addon && oneNode !== void 0 ? `system.${kind}.body.onNode` : !addon && nodeIds.length > 1 ? `system.${kind}.body.onNodes` : `system.${kind}.body`,
10417
10425
  textParams: {
10418
10426
  list,
@@ -10471,12 +10479,12 @@ function incomingFromAddonUpdates(facts, updates) {
10471
10479
  function incomingFromServerUpdated(facts, data) {
10472
10480
  const kind = stringField(data, "kind");
10473
10481
  const requestedBy = stringField(data, "requestedBy");
10474
- if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:"))) return null;
10475
10482
  const packageName = stringField(data, "packageName") ?? "@camstack/server";
10476
10483
  const fromVersion = stringField(data, "fromVersion");
10477
10484
  const toVersion = stringField(data, "toVersion") ?? versionFromRequestedBy(requestedBy);
10485
+ if (!(kind === "framework-update" || kind === "framework-bulk-update" || requestedBy !== void 0 && requestedBy.startsWith("server-update:") && toVersion !== void 0)) return null;
10478
10486
  const nodeId = stringField(data, "nodeId") ?? "hub";
10479
- const bodyKey = fromVersion !== void 0 && toVersion !== void 0 ? "system.server-updated.body" : "system.server-updated.body.plain";
10487
+ const bodyKey = toVersion === void 0 ? "system.server-updated.body.plain" : fromVersion === void 0 ? "system.server-updated.body.toOnly" : "system.server-updated.body";
10480
10488
  return systemSubject({
10481
10489
  facts,
10482
10490
  kind: "server-updated",
@@ -10557,9 +10565,15 @@ function updatePackages(data) {
10557
10565
  ...nodeId !== void 0 ? { nodeId } : {}
10558
10566
  }];
10559
10567
  }
10560
- function formatAvailableLine(pkg) {
10561
- const line = `${pkg.packageName} ${pkg.latestVersion} (current ${pkg.currentVersion})`;
10562
- return pkg.nodeId !== void 0 ? `${line} on ${pkg.nodeId}` : line;
10568
+ /**
10569
+ * One line of an availability list — the SAME shape the completed-update list
10570
+ * uses (`incomingFromAddonUpdates`), and deliberately word-free: the list is a
10571
+ * frozen param, so any English in it ("current") is delivered verbatim to an
10572
+ * Italian household. An arrow is not a language.
10573
+ */
10574
+ function formatAvailableLine(pkg, nodeNamedInTitle) {
10575
+ const line = `${pkg.packageName} ${pkg.currentVersion} → ${pkg.latestVersion}`;
10576
+ return pkg.nodeId !== void 0 && !nodeNamedInTitle ? `${line} (${pkg.nodeId})` : line;
10563
10577
  }
10564
10578
  function versionFromRequestedBy(requestedBy) {
10565
10579
  if (requestedBy === void 0) return void 0;
@@ -28789,19 +28803,29 @@ var BEST_FRAME_POLICY = {
28789
28803
  sizeAware: true
28790
28804
  };
28791
28805
  /**
28792
- * Best FACE (face-recognizer hold buffer) — confidence-only, no hysteresis, no
28793
- * rate limit. WHY: the face path must keep the TRUE highest-confidence face —
28794
- * accepting a new best only swaps an already-decoded crop buffer in memory
28795
- * (cheap; no capture is triggered), and a track may live well under the
28796
- * best-frame rate-limit window, so a gap/hysteresis would silently discard the
28797
- * real peak for nothing. This was the face path's de-facto behavior all along
28798
- * (it constructed the tracker with defaults); S9 declares it.
28806
+ * Best FACE (face-recognizer hold buffer) — SIZE-AWARE, no hysteresis, no rate
28807
+ * limit.
28808
+ *
28809
+ * No hysteresis / no gap, unchanged: accepting a new best only swaps an
28810
+ * already-decoded crop buffer in memory (cheap; no capture is triggered), and a
28811
+ * track may live well under the best-frame rate-limit window, so a gap would
28812
+ * silently discard the real peak for nothing.
28813
+ *
28814
+ * `sizeAware` since 2026-08-19. It used to be confidence-ONLY, and that was the
28815
+ * wrong ranking: SCRFD saturates its confidence on surveillance footage, so a
28816
+ * 12 px face and a 40 px face both answer ~0.9 and the track simply kept
28817
+ * whichever arrived first. The size that matters is the face's DETECTION-FRAME
28818
+ * short side, because that bounds the landmark precision the alignment warp
28819
+ * consumes — `face-size-score.ts` carries the measurement that set the
28820
+ * reference. Because `minGapMs` is 0 here, enabling this does NOT switch on the
28821
+ * decisive-improvement rate-limit bypass (there is no rate limit to bypass); it
28822
+ * only adds the size factor to the ranking.
28799
28823
  */
28800
28824
  var BEST_FACE_POLICY = {
28801
28825
  hysteresis: 0,
28802
28826
  minGapMs: 0,
28803
28827
  edgeAware: false,
28804
- sizeAware: false
28828
+ sizeAware: true
28805
28829
  };
28806
28830
  /**
28807
28831
  * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
@@ -30999,8 +31023,14 @@ function reportUnvouchedClips(deps, deviceId, clips) {
30999
31023
  * @durable class=config owner=pipeline-analytics
31000
31024
  * write="a signed-in viewer user saves a named snapshot of their local stores"
31001
31025
  * retention="none — a row goes only when its owner deletes it"
31026
+ *
31027
+ * Renamed from the bare `viewer-settings-snapshots` on 2026-08-19: an addon
31028
+ * collection carries its owner's prefix, and the bare name sat in the same flat
31029
+ * namespace as the kernel's `users` / `system-settings`. Rows already written
31030
+ * under the old name are adopted at boot by
31031
+ * `viewer-settings-legacy-adoption.ts`.
31002
31032
  */
31003
- var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
31033
+ var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "pipeline-analytics:viewer-settings-snapshots";
31004
31034
  var COLUMNS = [
31005
31035
  {
31006
31036
  name: "id",
@@ -31099,7 +31129,7 @@ var ViewerSettingsSnapshotStore = class {
31099
31129
  collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31100
31130
  columns: [...COLUMNS],
31101
31131
  indexes: [{
31102
- name: "idx_viewer_settings_owner",
31132
+ name: "idx_pa_viewer_settings_owner",
31103
31133
  columns: ["ownerUserId"]
31104
31134
  }]
31105
31135
  });
@@ -31198,6 +31228,183 @@ function makeViewerSettingsActionHandlers(store) {
31198
31228
  }
31199
31229
  };
31200
31230
  }
31231
+ //#endregion
31232
+ //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
31233
+ /**
31234
+ * @durable class=config owner=pipeline-analytics
31235
+ * write="NOTHING writes it any more — it is the pre-2026-08-19 bare name of
31236
+ * `pipeline-analytics:viewer-settings-snapshots`, declared here only so its
31237
+ * surviving rows can be READ once and moved"
31238
+ * retention="drained, not pruned: a row goes as soon as the successor is
31239
+ * confirmed to hold the same id. The table is empty and inert once every
31240
+ * hub has booted this addon; it is then dropped through the kernel's
31241
+ * `RETIRED_COLLECTIONS` and this module deleted."
31242
+ *
31243
+ * Bare, and therefore a namespacing violation — hence the single
31244
+ * `ALLOWED_BARE` entry in `scripts/check-collection-namespacing.ts`, which
31245
+ * names this file and the condition for removing both.
31246
+ */
31247
+ var LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
31248
+ /**
31249
+ * The ORIGINAL column list, byte-for-byte.
31250
+ *
31251
+ * Do not "tidy" this to reference the successor's constant: they are allowed to
31252
+ * diverge, and the day they do, a shared constant would silently rebuild the
31253
+ * legacy table and destroy the rows.
31254
+ */
31255
+ var LEGACY_COLUMNS = [
31256
+ {
31257
+ name: "id",
31258
+ type: "TEXT",
31259
+ primaryKey: true,
31260
+ notNull: true
31261
+ },
31262
+ {
31263
+ name: "name",
31264
+ type: "TEXT",
31265
+ notNull: true
31266
+ },
31267
+ {
31268
+ name: "ownerUserId",
31269
+ type: "TEXT",
31270
+ notNull: true
31271
+ },
31272
+ {
31273
+ name: "ownerUsername",
31274
+ type: "TEXT",
31275
+ notNull: true
31276
+ },
31277
+ {
31278
+ name: "createdAt",
31279
+ type: "INTEGER",
31280
+ notNull: true
31281
+ },
31282
+ {
31283
+ name: "updatedAt",
31284
+ type: "INTEGER",
31285
+ notNull: true
31286
+ },
31287
+ {
31288
+ name: "blob",
31289
+ type: "JSON",
31290
+ notNull: true
31291
+ }
31292
+ ];
31293
+ /** Bounded read: this table held 1 row on the reference hub. */
31294
+ var LEGACY_READ_LIMIT = 500;
31295
+ function errText(err) {
31296
+ return err instanceof Error ? err.message : String(err);
31297
+ }
31298
+ function asRows(raw) {
31299
+ if (!Array.isArray(raw)) return [];
31300
+ const out = [];
31301
+ for (const item of raw) {
31302
+ if (typeof item !== "object" || item === null) continue;
31303
+ const id = Reflect.get(item, "id");
31304
+ const data = Reflect.get(item, "data");
31305
+ if (typeof id !== "string" || typeof data !== "object" || data === null) continue;
31306
+ out.push({
31307
+ id,
31308
+ data
31309
+ });
31310
+ }
31311
+ return out;
31312
+ }
31313
+ /**
31314
+ * Move every legacy row into the successor and clear the legacy table.
31315
+ *
31316
+ * Returns the number of legacy rows RETIRED — the completion metric, not the
31317
+ * number written. A row copied but not yet verified counts as zero and is
31318
+ * retried on the next boot, which is the safe direction.
31319
+ */
31320
+ async function adoptLegacyViewerSettingsSnapshots(deps) {
31321
+ await deps.store.declareCollection.mutate({
31322
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31323
+ columns: LEGACY_COLUMNS
31324
+ });
31325
+ let legacy;
31326
+ try {
31327
+ legacy = asRows(await deps.store.query.query({
31328
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31329
+ filter: { limit: LEGACY_READ_LIMIT }
31330
+ }));
31331
+ } catch (err) {
31332
+ deps.logger.warn("viewer-settings legacy adoption skipped — legacy read failed", { meta: {
31333
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31334
+ error: errText(err)
31335
+ } });
31336
+ return 0;
31337
+ }
31338
+ if (legacy.length === 0) return 0;
31339
+ let existing;
31340
+ try {
31341
+ existing = new Set(asRows(await deps.store.query.query({
31342
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31343
+ filter: { limit: LEGACY_READ_LIMIT }
31344
+ })).map((r) => r.id));
31345
+ } catch (err) {
31346
+ deps.logger.warn("viewer-settings legacy adoption skipped — successor read failed", { meta: {
31347
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31348
+ error: errText(err)
31349
+ } });
31350
+ return 0;
31351
+ }
31352
+ let written = 0;
31353
+ for (const row of legacy) {
31354
+ if (existing.has(row.id)) continue;
31355
+ try {
31356
+ await deps.store.set.mutate({
31357
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31358
+ key: row.id,
31359
+ value: row.data
31360
+ });
31361
+ written += 1;
31362
+ } catch (err) {
31363
+ deps.logger.warn("viewer-settings legacy adoption — one row failed to write", { meta: {
31364
+ snapshotId: row.id,
31365
+ error: errText(err)
31366
+ } });
31367
+ }
31368
+ }
31369
+ let landed;
31370
+ try {
31371
+ landed = new Set(asRows(await deps.store.query.query({
31372
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31373
+ filter: { limit: LEGACY_READ_LIMIT }
31374
+ })).map((r) => r.id));
31375
+ } catch (err) {
31376
+ deps.logger.warn("viewer-settings legacy adoption — verification read failed, legacy rows kept", { meta: {
31377
+ written,
31378
+ error: errText(err)
31379
+ } });
31380
+ return 0;
31381
+ }
31382
+ let retired = 0;
31383
+ for (const row of legacy) {
31384
+ if (!landed.has(row.id)) continue;
31385
+ try {
31386
+ await deps.store.delete.mutate({
31387
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31388
+ key: row.id
31389
+ });
31390
+ retired += 1;
31391
+ } catch (err) {
31392
+ deps.logger.warn("viewer-settings legacy adoption — one legacy row failed to delete", { meta: {
31393
+ snapshotId: row.id,
31394
+ error: errText(err)
31395
+ } });
31396
+ }
31397
+ }
31398
+ deps.logger.warn("viewer-settings snapshots adopted from the legacy bare collection", { meta: {
31399
+ from: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31400
+ to: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
31401
+ found: legacy.length,
31402
+ written,
31403
+ retired,
31404
+ kept: legacy.length - retired
31405
+ } });
31406
+ return retired;
31407
+ }
31201
31408
  /**
31202
31409
  * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
31203
31410
  *
@@ -32005,7 +32212,11 @@ var AudioDetectionSettingsSchema = object({
32005
32212
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
32006
32213
  * only ever cost the operator this many markers a day. */
32007
32214
  audioMarkerMaxPerDay: number().int().positive().default(12),
32008
- /** Materialize one event + one synthetic track per classified episode. */
32215
+ /** Materialize one event + one synthetic track per classified episode.
32216
+ * OFF switches off the GROUPING only — classified audio still persists an
32217
+ * event (class change, then one per 5 s heartbeat) and still reaches the
32218
+ * Notification Center; it just opens no synthetic track. A knob that names
32219
+ * the grouping must never silence the path (D62). */
32009
32220
  audioEpisodeEnabled: boolean().default(true),
32010
32221
  /** Class must be heard continuously this long before the episode opens. */
32011
32222
  audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
@@ -34225,11 +34436,44 @@ async function resolveDefaultEventMedia(deps) {
34225
34436
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
34226
34437
  }
34227
34438
  //#endregion
34439
+ //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
34440
+ /**
34441
+ * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
34442
+ * name them in a log.
34443
+ *
34444
+ * Accepts unconditionally when the bound is 0 (disabled) or when the magnitude
34445
+ * is absent / non-finite — an older runner that does not report it must keep
34446
+ * working exactly as before, never be silently gated to nothing. Pure.
34447
+ */
34448
+ function embeddingMagnitudeVerdict(magnitude, bound) {
34449
+ if (!(bound > 0)) return null;
34450
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return null;
34451
+ if (magnitude <= bound) return null;
34452
+ return {
34453
+ magnitude,
34454
+ bound
34455
+ };
34456
+ }
34457
+ /**
34458
+ * Map a detection-frame face short side to the 0..1 `sizeScore` the
34459
+ * {@link ./best-detection-tracker.BestDetectionTracker} multiplies into its
34460
+ * effective score. Linear up to {@link FACE_SIZE_SCORE_REFERENCE_PX}, saturated
34461
+ * above it, 0 for a missing/degenerate measurement (so an unmeasurable face can
34462
+ * never outrank a measured one). Pure.
34463
+ */
34464
+ function faceSizeScore(detectionFrameShortSidePx) {
34465
+ if (!Number.isFinite(detectionFrameShortSidePx) || detectionFrameShortSidePx <= 0) return 0;
34466
+ if (detectionFrameShortSidePx >= 32) return 1;
34467
+ return detectionFrameShortSidePx / 32;
34468
+ }
34469
+ //#endregion
34228
34470
  //#region src/pipeline-analytics/face-recognizer.ts
34229
34471
  /** Attribution of an identity resolved by the in-process ArcFace matcher. */
34230
34472
  var FACE_RECOGNIZER_STEP_ID = "face-recognizer";
34231
34473
  /** At most one "dropping imageless track" log per this interval, per recognizer. */
34232
34474
  var FACE_IMAGELESS_LOG_THROTTLE_MS = 6e4;
34475
+ /** At most one 'embedding magnitude' sample line per device per this interval. */
34476
+ var FACE_MAGNITUDE_LOG_THROTTLE_MS = 6e4;
34233
34477
  /**
34234
34478
  * arcface model id stamped on a detail-plane face candidate when the gallery is
34235
34479
  * empty (collect-only). The detail-subtree result carries no `embeddingModelId`
@@ -34244,6 +34488,8 @@ var FaceRecognizer = class {
34244
34488
  names = /* @__PURE__ */ new Map();
34245
34489
  aggregates = /* @__PURE__ */ new Map();
34246
34490
  bestFace = /* @__PURE__ */ new Map();
34491
+ /** Per-device throttle clock for the magnitude sample line. */
34492
+ lastMagnitudeLogAt = /* @__PURE__ */ new Map();
34247
34493
  /**
34248
34494
  * Peak identity-MATCH cosine per track for the currently-assigned identity
34249
34495
  * (NOT the detection confidence held in {@link bestFace}). Reset on an
@@ -34293,6 +34539,21 @@ var FaceRecognizer = class {
34293
34539
  * input source moves from the per-frame plane to this per-track call.
34294
34540
  */
34295
34541
  async ingestFaceDetail(input) {
34542
+ const verdict = embeddingMagnitudeVerdict(input.embeddingMagnitude, input.settings.maxEmbeddingMagnitude);
34543
+ if (verdict !== null) {
34544
+ this.deps.logger.info("face: dropping detail — raw embedding magnitude over bound", {
34545
+ tags: { deviceId: input.deviceId },
34546
+ meta: {
34547
+ trackId: input.trackId,
34548
+ magnitude: Number(verdict.magnitude.toFixed(3)),
34549
+ bound: verdict.bound,
34550
+ score: input.score,
34551
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: input.nativeFaceShortSidePx } : {}
34552
+ }
34553
+ });
34554
+ return;
34555
+ }
34556
+ this.logEmbeddingMagnitude(input);
34296
34557
  const modelId = this.gallery[0]?.modelId ?? FALLBACK_FACE_MODEL_ID;
34297
34558
  const candidate = {
34298
34559
  trackId: input.trackId,
@@ -34319,6 +34580,30 @@ var FaceRecognizer = class {
34319
34580
  ...input.frameHandle !== void 0 ? { frameHandle: input.frameHandle } : {}
34320
34581
  });
34321
34582
  }
34583
+ /**
34584
+ * At most one magnitude line per device per
34585
+ * {@link FACE_MAGNITUDE_LOG_THROTTLE_MS}. This is the ONLY way the operator
34586
+ * can see where this cluster's usable and degenerate face populations sit, so
34587
+ * it must exist before `maxEmbeddingMagnitude` can honestly be turned on.
34588
+ */
34589
+ logEmbeddingMagnitude(input) {
34590
+ const magnitude = input.embeddingMagnitude;
34591
+ if (magnitude === void 0 || !Number.isFinite(magnitude)) return;
34592
+ const now = Date.now();
34593
+ if (now - (this.lastMagnitudeLogAt.get(input.deviceId) ?? 0) < FACE_MAGNITUDE_LOG_THROTTLE_MS) return;
34594
+ this.lastMagnitudeLogAt.set(input.deviceId, now);
34595
+ this.deps.logger.info("face: embedding magnitude", {
34596
+ tags: { deviceId: input.deviceId },
34597
+ meta: {
34598
+ trackId: input.trackId,
34599
+ magnitude: Number(magnitude.toFixed(3)),
34600
+ bound: input.settings.maxEmbeddingMagnitude,
34601
+ score: input.score,
34602
+ detectionFrameShortSidePx: input.faceBbox !== void 0 ? Math.round(Math.min(input.faceBbox.w, input.faceBbox.h)) : 0,
34603
+ ...input.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: Math.round(input.nativeFaceShortSidePx) } : {}
34604
+ }
34605
+ });
34606
+ }
34322
34607
  async processFrame(input) {
34323
34608
  const { settings } = input;
34324
34609
  const candidates = input.tracked.filter((t) => Array.isArray(t.embedding) && t.embedding.length > 0 && typeof t.embeddingModelId === "string" && t.confidence >= settings.minFaceConfidence && (t.nativeFaceShortSidePx ?? (t.faceBbox ? Math.min(t.faceBbox.w, t.faceBbox.h) : void 0) ?? Number.POSITIVE_INFINITY) >= settings.minFacePx);
@@ -34380,7 +34665,8 @@ var FaceRecognizer = class {
34380
34665
  }
34381
34666
  const recognizedIdentityId = state.assignedIdentityId ?? void 0;
34382
34667
  const held = this.bestFace.get(c.trackId);
34383
- const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp);
34668
+ const detectionFrameShortSidePx = c.faceBbox !== void 0 ? Math.min(c.faceBbox.w, c.faceBbox.h) : 0;
34669
+ const isNewBest = this.bestTracker.observe(c.trackId, c.confidence, input.timestamp, void 0, void 0, void 0, faceSizeScore(detectionFrameShortSidePx));
34384
34670
  const needsCrop = held !== void 0 && held.crop === void 0;
34385
34671
  if (isNewBest || needsCrop) {
34386
34672
  const cropBbox = c.faceBbox ?? c.bbox;
@@ -34640,14 +34926,38 @@ var FaceSettingsSchema = object({
34640
34926
  * stripped from the per-device schema).
34641
34927
  */
34642
34928
  enabled: boolean().default(true),
34643
- /** Cosine similarity (on L2-normalized arcface vectors) required to match.
34644
- * Raised 0.55→0.62 (2026-07-23 face-quality batch): 0.55 was implausibly
34645
- * loose against thin/low-quality galleries and produced ~25-40% visually
34646
- * wrong auto-assignments in the 12h audit. */
34647
- similarityThreshold: number().min(0).max(1).default(.62),
34648
- /** Reject ambiguous matches: require best secondBest margin. Raised
34649
- * 0.10→0.15 (2026-07-23 face-quality batch) to widen the confusion gap. */
34650
- margin: number().min(0).max(1).default(.15),
34929
+ /**
34930
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
34931
+ *
34932
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
34933
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
34934
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
34935
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
34936
+ * override until 2026-08-19; it is the schema default now, because a default
34937
+ * nobody runs is a decision nobody made.
34938
+ *
34939
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
34940
+ * quality, where most of what reaches ArcFace is not a correctly aligned
34941
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
34942
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
34943
+ * On correctly aligned crops the SAME models separate at a far lower
34944
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
34945
+ * 0.175, best accuracy 98.3% at threshold 0.28.
34946
+ *
34947
+ * Recalibration plan, in this order:
34948
+ * 1. land the landmark-precision gate (the alignment fix),
34949
+ * 2. re-enrol the gallery from crops produced AFTER it,
34950
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
34951
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
34952
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
34953
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
34954
+ * any threshold you could pick — the knob cannot reach it.
34955
+ */
34956
+ similarityThreshold: number().min(0).max(1).default(.55),
34957
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
34958
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
34959
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
34960
+ margin: number().min(0).max(1).default(.12),
34651
34961
  /** Minimum face-detection confidence for a face to be considered. */
34652
34962
  minFaceConfidence: number().min(0).max(1).default(.5),
34653
34963
  /**
@@ -34691,6 +35001,33 @@ var FaceSettingsSchema = object({
34691
35001
  * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
34692
35002
  * such a value falls back to the default). */
34693
35003
  confirmFrames: number().int().min(1).default(3),
35004
+ /**
35005
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35006
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35007
+ * the gate.
35008
+ *
35009
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35010
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35011
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35012
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35013
+ * similarity threshold can reach that — it sits above every threshold — so the
35014
+ * only defence is to refuse the embedding before it is stored or compared.
35015
+ *
35016
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35017
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35018
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35019
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35020
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35021
+ * 93.2% of degenerate ones.
35022
+ *
35023
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35024
+ * magnitude has never been observable here — `result-assembler` discarded it
35025
+ * one line after computing it. The field and its logging ship first; set a
35026
+ * real bound once a week of live `face: embedding magnitude` lines says where
35027
+ * this cluster's populations actually sit. Enabling a gate against a number
35028
+ * nobody has measured in production is how recognition silently stops.
35029
+ */
35030
+ maxEmbeddingMagnitude: number().min(0).default(0),
34694
35031
  /** Max buffered faces kept per device. */
34695
35032
  bufferMaxPerDevice: number().int().min(0).default(50)
34696
35033
  });
@@ -34709,6 +35046,7 @@ function resolveFaceSettings(raw) {
34709
35046
  recognitionMinFacePx: pick("recognitionMinFacePx"),
34710
35047
  suggestionMinCosine: pick("suggestionMinCosine"),
34711
35048
  minIdentitySamples: pick("minIdentitySamples"),
35049
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
34712
35050
  confirmFrames: pick("confirmFrames"),
34713
35051
  bufferMaxPerDevice: pick("bufferMaxPerDevice")
34714
35052
  };
@@ -39180,6 +39518,147 @@ async function applyTrackFlags(deps, input) {
39180
39518
  };
39181
39519
  }
39182
39520
  //#endregion
39521
+ //#region src/pipeline-analytics/pipeline/plate-vote.ts
39522
+ /**
39523
+ * Per-character VOTE over a track's plate reads.
39524
+ *
39525
+ * A track produces up to `plate-ocr`'s `cadence.maxPerTrack` reads of the SAME
39526
+ * physical plate, from different frames. The recognizer used to keep the single
39527
+ * read with the highest OCR score (`holdBest`, argmax) — and OCR score does not
39528
+ * correlate with correctness. In the 2026-08 audit the top-scoring read of a
39529
+ * track was routinely the wrong one while two lower-scoring reads agreed on the
39530
+ * right glyph: score ranks how confident the recognizer was about the pixels it
39531
+ * saw, not how much of the plate those pixels were.
39532
+ *
39533
+ * So the reads vote. Each read is normalized (upper, alnum only), aligned to a
39534
+ * reference read, and contributes its OCR score to whichever glyph it saw at
39535
+ * each reference position; the winner per position is the output. Two reads
39536
+ * that agree beat one confident read that disagrees, which is the whole point.
39537
+ *
39538
+ * ALIGNMENT — reads differ in length (4–9 glyphs live: a dropped leading
39539
+ * district letter, a hallucinated trailing glyph). A positional vote on raw
39540
+ * indices would let one missing character shift every later column and destroy
39541
+ * the agreement it was supposed to measure. Each read is therefore aligned to
39542
+ * the reference by Levenshtein traceback: a match or substitution votes at the
39543
+ * reference position it aligned to, an insertion votes nowhere, and a deletion
39544
+ * simply abstains at that position.
39545
+ *
39546
+ * REFERENCE — the length carrying the most SCORE MASS wins (a hallucinated
39547
+ * 9-glyph read does not get to set the output length just by being longest);
39548
+ * ties inside that length go to the highest-scoring read. With ≤5 reads this is
39549
+ * the honest cheap heuristic: there is no third source of truth to consult.
39550
+ */
39551
+ /**
39552
+ * Align `candidate` to `reference` by Levenshtein traceback. Returns one entry
39553
+ * per REFERENCE position: the candidate glyph that aligned there, or `null`
39554
+ * when the candidate had nothing at that position (a deletion). Candidate
39555
+ * insertions are dropped — they have no reference column to vote in.
39556
+ */
39557
+ function alignToReference(reference, candidate) {
39558
+ const n = reference.length;
39559
+ const m = candidate.length;
39560
+ const dist = [];
39561
+ for (let i = 0; i <= n; i++) {
39562
+ const row = new Array(m + 1).fill(0);
39563
+ row[0] = i;
39564
+ dist.push(row);
39565
+ }
39566
+ const first = dist[0];
39567
+ if (first === void 0) return [];
39568
+ for (let j = 0; j <= m; j++) first[j] = j;
39569
+ for (let i = 1; i <= n; i++) {
39570
+ const prev = dist[i - 1];
39571
+ const cur = dist[i];
39572
+ if (prev === void 0 || cur === void 0) continue;
39573
+ for (let j = 1; j <= m; j++) {
39574
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39575
+ cur[j] = Math.min((prev[j] ?? 0) + 1, (cur[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
39576
+ }
39577
+ }
39578
+ const aligned = new Array(n).fill(null);
39579
+ let i = n;
39580
+ let j = m;
39581
+ while (i > 0 && j > 0) {
39582
+ const cur = dist[i];
39583
+ const prev = dist[i - 1];
39584
+ if (cur === void 0 || prev === void 0) break;
39585
+ const cost = reference[i - 1] === candidate[j - 1] ? 0 : 1;
39586
+ if (cur[j] === (prev[j - 1] ?? 0) + cost) {
39587
+ aligned[i - 1] = candidate[j - 1] ?? null;
39588
+ i--;
39589
+ j--;
39590
+ } else if (cur[j] === (prev[j] ?? 0) + 1) i--;
39591
+ else j--;
39592
+ }
39593
+ return aligned;
39594
+ }
39595
+ /** The read whose LENGTH carries the most score mass; ties → highest score. */
39596
+ function pickReference(reads) {
39597
+ const massByLength = /* @__PURE__ */ new Map();
39598
+ for (const r of reads) massByLength.set(r.text.length, (massByLength.get(r.text.length) ?? 0) + r.score);
39599
+ let bestLength = 0;
39600
+ let bestMass = -1;
39601
+ for (const [length, mass] of massByLength) if (mass > bestMass || mass === bestMass && length < bestLength) {
39602
+ bestMass = mass;
39603
+ bestLength = length;
39604
+ }
39605
+ let reference = "";
39606
+ let referenceScore = -1;
39607
+ for (const r of reads) if (r.text.length === bestLength && r.score > referenceScore) {
39608
+ referenceScore = r.score;
39609
+ reference = r.text;
39610
+ }
39611
+ return reference;
39612
+ }
39613
+ /**
39614
+ * Confidence-weighted per-character vote over a track's plate reads. Returns
39615
+ * the normalized winning text (`''` when there is nothing to vote on).
39616
+ */
39617
+ function votePlateText(reads) {
39618
+ const normalized = [];
39619
+ for (const r of reads) {
39620
+ const text = normalizePlate(r.text);
39621
+ if (text.length > 0) normalized.push({
39622
+ text,
39623
+ score: r.score
39624
+ });
39625
+ }
39626
+ if (normalized.length === 0) return "";
39627
+ const only = normalized[0];
39628
+ if (normalized.length === 1 && only !== void 0) return only.text;
39629
+ const reference = pickReference(normalized);
39630
+ if (reference.length === 0) return "";
39631
+ const columns = [];
39632
+ for (let i = 0; i < reference.length; i++) columns.push(/* @__PURE__ */ new Map());
39633
+ for (const r of normalized) {
39634
+ const aligned = r.text === reference ? [...r.text] : alignToReference(reference, r.text);
39635
+ for (let i = 0; i < reference.length; i++) {
39636
+ const glyph = aligned[i];
39637
+ if (glyph === null || glyph === void 0) continue;
39638
+ const column = columns[i];
39639
+ if (column === void 0) continue;
39640
+ column.set(glyph, (column.get(glyph) ?? 0) + r.score);
39641
+ }
39642
+ }
39643
+ let out = "";
39644
+ for (let i = 0; i < reference.length; i++) {
39645
+ const column = columns[i];
39646
+ const referenceGlyph = reference[i] ?? "";
39647
+ if (column === void 0 || column.size === 0) {
39648
+ out += referenceGlyph;
39649
+ continue;
39650
+ }
39651
+ let bestGlyph = referenceGlyph;
39652
+ let bestMass = column.get(referenceGlyph) ?? -1;
39653
+ for (const [glyph, mass] of column) if (mass > bestMass) {
39654
+ bestMass = mass;
39655
+ bestGlyph = glyph;
39656
+ }
39657
+ out += bestGlyph;
39658
+ }
39659
+ return out;
39660
+ }
39661
+ //#endregion
39183
39662
  //#region src/pipeline-analytics/pipeline/plate-matcher.ts
39184
39663
  /**
39185
39664
  * plate-matcher — name a plate read against the vehicle gallery by text vicinity.
@@ -39228,11 +39707,11 @@ function matchPlateText(probe, gallery, opts) {
39228
39707
  //#endregion
39229
39708
  //#region src/pipeline-analytics/plate-recognizer.ts
39230
39709
  /**
39231
- * PlateRecognizer — buffers the best license-plate OCR read per vehicle track
39232
- * for the plate gallery. Unlike faces there is NO gallery matching: the OCR text
39233
- * IS the label and already rides on the event via the detection pipeline. This
39234
- * only collects per-track reads (+ a tight plate crop) so the gallery can group,
39235
- * review and search them. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39710
+ * PlateRecognizer — collects EVERY license-plate OCR read a vehicle track
39711
+ * produces and stores their per-character VOTE (`plate-vote.ts`) at close, plus
39712
+ * the max-score read's crop for the gallery tile. Unlike faces there is NO
39713
+ * gallery matching: the OCR text IS the label and already rides on the event via
39714
+ * the detection pipeline. Mirrors FaceRecognizer's hold→onTrackEnd lifecycle.
39236
39715
  */
39237
39716
  var PlateRecognizer = class {
39238
39717
  deps;
@@ -39295,7 +39774,7 @@ var PlateRecognizer = class {
39295
39774
  const minConfidence = input.minConfidence ?? 0;
39296
39775
  const candidates = input.tracked.filter((t) => typeof t.plateText === "string" && t.plateText.length > 0 && typeof t.plateScore === "number" && t.plateScore >= minConfidence && t.plateBbox !== void 0);
39297
39776
  if (candidates.length === 0) return;
39298
- for (const c of candidates) await this.holdBest({
39777
+ for (const c of candidates) await this.observeRead({
39299
39778
  deviceId: input.deviceId,
39300
39779
  trackId: c.trackId,
39301
39780
  text: c.plateText,
@@ -39318,15 +39797,50 @@ var PlateRecognizer = class {
39318
39797
  * gallery stays empty (0 plateCrop) — the observed live gap.
39319
39798
  */
39320
39799
  async observePlateRead(input) {
39321
- if (!isPlausiblePlateRead(input.text, input.score)) return;
39322
- if (input.score < (input.minConfidence ?? 0)) return;
39323
- await this.holdBest(input);
39800
+ const minConfidence = input.minConfidence ?? 0;
39801
+ const reason = !isPlausiblePlateRead(input.text, input.score) ? "implausible" : input.score < minConfidence ? "below-min-confidence" : null;
39802
+ if (reason !== null) {
39803
+ this.deps.logger.info("plate: read discarded", {
39804
+ tags: {
39805
+ deviceId: input.deviceId,
39806
+ trackId: input.trackId
39807
+ },
39808
+ meta: {
39809
+ text: input.text,
39810
+ score: input.score,
39811
+ minConfidence,
39812
+ reason
39813
+ }
39814
+ });
39815
+ return;
39816
+ }
39817
+ await this.observeRead(input);
39324
39818
  }
39325
- /** Hold the highest-scoring plate read per track, capturing a tight crop the
39326
- * first time a new best is seen (shared by the per-frame + detail-plane paths). */
39327
- async holdBest(input) {
39819
+ /**
39820
+ * Record one plate read for a track (shared by the per-frame + detail-plane
39821
+ * paths).
39822
+ *
39823
+ * EVERY read is kept for the close-time vote — a track's reads disagree, and
39824
+ * the disagreement is the signal. The crop + geometry still track the
39825
+ * MAX-SCORE read: the vote decides the text, but the picture the operator
39826
+ * reviews should be the sharpest frame the track produced.
39827
+ *
39828
+ * The read list is capped at {@link MAX_VOTING_READS} in ARRIVAL order, not
39829
+ * by score: evicting the lowest-scoring read would re-introduce the very
39830
+ * ranking this vote exists to stop trusting.
39831
+ */
39832
+ async observeRead(input) {
39328
39833
  const held = this.bestPlate.get(input.trackId);
39329
- if (held !== void 0 && input.score <= held.score) return;
39834
+ const vote = {
39835
+ text: input.text,
39836
+ score: input.score
39837
+ };
39838
+ if (held !== void 0 && input.score <= held.score) {
39839
+ if (held.reads.length < 5) held.reads.push(vote);
39840
+ return;
39841
+ }
39842
+ const reads = held?.reads ?? [];
39843
+ if (reads.length < 5) reads.push(vote);
39330
39844
  let crop;
39331
39845
  if (input.frameHandle !== void 0) try {
39332
39846
  crop = await this.deps.captureCrop(input.frameHandle, input.bbox, input.frameWidth, input.frameHeight, input.cropPadding, input.trackId, input.deviceId) ?? void 0;
@@ -39342,6 +39856,7 @@ var PlateRecognizer = class {
39342
39856
  this.bestPlate.set(input.trackId, {
39343
39857
  text: input.text,
39344
39858
  score: input.score,
39859
+ reads,
39345
39860
  bbox: input.bbox,
39346
39861
  timestamp: input.timestamp,
39347
39862
  frameWidth: input.frameWidth,
@@ -39418,14 +39933,16 @@ var PlateRecognizer = class {
39418
39933
  }
39419
39934
  });
39420
39935
  }
39421
- const match = this.matchVehicle(held.text, held.score);
39936
+ const voted = votePlateText(held.reads);
39937
+ const text = voted.length > 0 && isPlausiblePlateRead(voted, held.score) ? voted : normalizePlate(held.text);
39938
+ const match = this.matchVehicle(text, held.score);
39422
39939
  try {
39423
39940
  await this.deps.plateStore.insert({
39424
39941
  id: plateId,
39425
39942
  deviceId,
39426
39943
  trackId,
39427
39944
  timestamp: held.timestamp,
39428
- text: normalizePlate(held.text),
39945
+ text,
39429
39946
  score: held.score,
39430
39947
  ...mediaKey !== void 0 ? { mediaKey } : {},
39431
39948
  corrected: false,
@@ -39446,7 +39963,10 @@ var PlateRecognizer = class {
39446
39963
  },
39447
39964
  meta: {
39448
39965
  plateId,
39449
- text: normalizePlate(held.text),
39966
+ text,
39967
+ bestRead: normalizePlate(held.text),
39968
+ reads: held.reads.map((r) => `${normalizePlate(r.text)}@${r.score.toFixed(2)}`),
39969
+ votedFromReads: held.reads.length,
39450
39970
  score: held.score,
39451
39971
  hasCrop: mediaKey !== void 0,
39452
39972
  recognizedVehicleId: match?.vehicleId ?? null
@@ -39960,6 +40480,129 @@ function buildRecordedStillPorts(input) {
39960
40480
  })
39961
40481
  };
39962
40482
  }
40483
+ /** Where the completion marker lives. */
40484
+ var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
40485
+ var MIGRATION_MARKER_KEY = "label-tier-4g";
40486
+ /**
40487
+ * The decision for ONE row, exposed so the rule is testable without a store.
40488
+ *
40489
+ * `null` = leave the row alone.
40490
+ */
40491
+ function decideRowMigration(data, now) {
40492
+ const label = data["label"];
40493
+ if (typeof label !== "string" || label.length === 0) return null;
40494
+ if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
40495
+ if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
40496
+ if (isMacroClass(label)) return {
40497
+ label: null,
40498
+ labelScore: null,
40499
+ labelMeta: null
40500
+ };
40501
+ return {
40502
+ label: null,
40503
+ labelScore: null,
40504
+ labelMeta: null,
40505
+ subLabel: label,
40506
+ subLabelScore: null,
40507
+ subLabelMeta: {
40508
+ stepId: MIGRATION_STEP_ID,
40509
+ decidedAt: now
40510
+ }
40511
+ };
40512
+ }
40513
+ /** Migrate one collection, paged and bounded. */
40514
+ async function migrateCollectionLabelTiers(deps, collection) {
40515
+ let scanned = 0;
40516
+ let demoted = 0;
40517
+ let cleared = 0;
40518
+ let skipped = 0;
40519
+ let offset = 0;
40520
+ let truncated = false;
40521
+ for (;;) {
40522
+ if (scanned >= 2e5) {
40523
+ truncated = true;
40524
+ break;
40525
+ }
40526
+ const rows = await deps.store.query.query({
40527
+ collection,
40528
+ filter: {
40529
+ limit: 500,
40530
+ offset
40531
+ }
40532
+ });
40533
+ if (rows.length === 0) break;
40534
+ for (const row of rows) {
40535
+ scanned++;
40536
+ const patch = decideRowMigration(row.data, deps.now());
40537
+ if (patch === null) {
40538
+ skipped++;
40539
+ continue;
40540
+ }
40541
+ try {
40542
+ await deps.store.update.mutate({
40543
+ collection,
40544
+ id: row.id,
40545
+ data: patch
40546
+ });
40547
+ if (patch["subLabel"] === void 0) cleared++;
40548
+ else demoted++;
40549
+ } catch (err) {
40550
+ deps.logger.warn("label tier migration row failed", { meta: {
40551
+ collection,
40552
+ id: row.id,
40553
+ error: String(err)
40554
+ } });
40555
+ skipped++;
40556
+ }
40557
+ }
40558
+ if (rows.length < 500) break;
40559
+ offset += rows.length;
40560
+ }
40561
+ return {
40562
+ collection,
40563
+ scanned,
40564
+ demoted,
40565
+ cleared,
40566
+ skipped,
40567
+ truncated
40568
+ };
40569
+ }
40570
+ /**
40571
+ * Run the migration across the collections that carry the tiered columns, once.
40572
+ *
40573
+ * The marker short-circuits a completed migration; it is written only when
40574
+ * NEITHER collection was truncated, so an interrupted pass resumes on the next
40575
+ * boot instead of declaring itself done.
40576
+ */
40577
+ async function runLabelTierMigration(deps, collections) {
40578
+ try {
40579
+ const marker = await deps.store.get.query({
40580
+ collection: MIGRATION_MARKER_COLLECTION,
40581
+ key: MIGRATION_MARKER_KEY
40582
+ });
40583
+ if (marker !== null && marker !== void 0) return [];
40584
+ } catch {}
40585
+ const stats = [];
40586
+ for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
40587
+ if (stats.every((s) => !s.truncated)) try {
40588
+ await deps.store.set.mutate({
40589
+ collection: MIGRATION_MARKER_COLLECTION,
40590
+ key: MIGRATION_MARKER_KEY,
40591
+ value: { completedAt: deps.now() }
40592
+ });
40593
+ } catch (err) {
40594
+ deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
40595
+ }
40596
+ for (const s of stats) deps.logger.info("label tier migration", { meta: {
40597
+ collection: s.collection,
40598
+ scanned: s.scanned,
40599
+ demoted: s.demoted,
40600
+ cleared: s.cleared,
40601
+ skipped: s.skipped,
40602
+ truncated: s.truncated
40603
+ } });
40604
+ return stats;
40605
+ }
39963
40606
  //#endregion
39964
40607
  //#region src/durable/lts-aggregator.ts
39965
40608
  /** Wall-clock bucket width. Five minutes, matching HA's statistics tier. */
@@ -45151,6 +45794,55 @@ var ANALYTICS_COLLECTIONS = [
45151
45794
  why: "a five-minute statistics row summarises an INTERVAL, not an entity — there is no owner it can be orphaned from. Its bound is a ROW CAP per (subject, series, scope) enforced by the aggregator itself (105,120 rows ≈ one year), deliberately not an age sweep: D61 collapsed ten disagreeing age clocks into one, and an LTS series has no clock but its own."
45152
45795
  }
45153
45796
  },
45797
+ {
45798
+ collection: RETRAIN_ANNOTATIONS_COLLECTION,
45799
+ classification: {
45800
+ kind: "never-orphaned",
45801
+ why: "the ground truth an operator DREW. It declares `trackId` and must be classified by hand for that reason: the id is provenance, not ownership. The dataset addresses COPIES (D81), which is precisely what makes a 'trained' track evictable again — so a track ageing out leaves the annotations standing. Rows go only through `deleteForFrame`, when the operator deselects the frame. Deleting one because its track expired would destroy labelling work no sweep can recreate."
45802
+ }
45803
+ },
45804
+ {
45805
+ collection: RETRAIN_FRAMES_COLLECTION,
45806
+ classification: {
45807
+ kind: "never-orphaned",
45808
+ why: "the frame COPIES the dataset owns outright, under a `retrain` prefix no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
45809
+ }
45810
+ },
45811
+ {
45812
+ collection: SCENE_COLLECTION,
45813
+ classification: {
45814
+ kind: "never-orphaned",
45815
+ why: "operator intent — one row per (deviceId, monitorId), created and destroyed by scene CRUD or by a device-removal sweep. Nothing retention does can orphan it, and no age clock applies: a scene the operator configured and never revisits is not stale, it is configured."
45816
+ }
45817
+ },
45818
+ {
45819
+ collection: SCENE_LATCH_COLLECTION,
45820
+ classification: {
45821
+ kind: "never-orphaned",
45822
+ why: "it IS owned — by the scene set — but the owner-cleanup is self-contained: `pruneExcept(the scene set)` runs on every scene reload, so a latch whose scene is gone is collected there and never by retention. Deliberately NOT `owner-indirection`: that kind puts a collection into `auditableCollections()`, and the orphan audit resolves exactly one indirection, that of `pipeline-analytics:media`."
45823
+ }
45824
+ },
45825
+ {
45826
+ collection: MIGRATION_MARKER_COLLECTION,
45827
+ classification: {
45828
+ kind: "never-orphaned",
45829
+ why: "a one-row completion marker for the label-tier 4g pass. It summarises a one-time event, so there is no entity it can be orphaned from — and its PRESENCE is what stops the migration running again, so deleting it is not cleanup, it is a re-run over every track, face and object-event row."
45830
+ }
45831
+ },
45832
+ {
45833
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
45834
+ classification: {
45835
+ kind: "never-orphaned",
45836
+ why: "a hub user's named snapshot of their own viewer layout. It is not analytics data — it happens to live in this addon because the actions ride `addons.custom`. Owned by the user who saved it and deleted only by them; no track, device or event can orphan it, and no age applies."
45837
+ }
45838
+ },
45839
+ {
45840
+ collection: LEGACY_VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
45841
+ classification: {
45842
+ kind: "never-orphaned",
45843
+ why: "the drained predecessor of the row above, declared only so its rows can be read once and moved (`viewer-settings-legacy-adoption.ts`). Retention must never touch it: its rows go one way only, to the successor, gated on a re-read that confirms they landed. Delete this entry together with that module."
45844
+ }
45845
+ },
45154
45846
  {
45155
45847
  collection: STATIONARY_COLLECTION,
45156
45848
  classification: {
@@ -49627,7 +50319,7 @@ function buildGlobalSettingsSchema() {
49627
50319
  type: "boolean",
49628
50320
  key: "audioEpisodeEnabled",
49629
50321
  label: "Create audio episodes",
49630
- description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
50322
+ description: "Off switches off the GROUPING only: classified audio still persists an event and still notifies, but no timeline track is opened and a long sound is no longer collapsed into one episode (one event per class change, then one every 5 s while it lasts).",
49631
50323
  default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
49632
50324
  },
49633
50325
  {
@@ -49753,6 +50445,15 @@ function buildGlobalSettingsSchema() {
49753
50445
  step: 1,
49754
50446
  default: FACE_DEFAULTS.confirmFrames
49755
50447
  },
50448
+ {
50449
+ type: "number",
50450
+ key: "maxEmbeddingMagnitude",
50451
+ label: "Max embedding magnitude (0 = off)",
50452
+ description: "Refuse a face whose RAW ArcFace feature magnitude exceeds this, before it is buffered, matched or enrolled. A degenerate crop does not score LOW against the gallery — it scores HIGH against everything — so no similarity threshold can catch it; the raw magnitude can. On this model a usable face sits around 4.4 whatever its size, grey level or blur, while an information-free crop jumps to 10–16. Leave at 0 until the \"face: embedding magnitude\" log lines show where YOUR cameras sit; a bound guessed from someone else's data silently stops recognising everybody.",
50453
+ min: 0,
50454
+ step: .5,
50455
+ default: FACE_DEFAULTS.maxEmbeddingMagnitude
50456
+ },
49756
50457
  {
49757
50458
  type: "number",
49758
50459
  key: "bufferMaxPerDevice",
@@ -49830,129 +50531,6 @@ function evaluatePeriodicSnapshot(input) {
49830
50531
  movedFraction
49831
50532
  };
49832
50533
  }
49833
- /** Where the completion marker lives. */
49834
- var MIGRATION_MARKER_COLLECTION = "pipeline-analytics:migrations";
49835
- var MIGRATION_MARKER_KEY = "label-tier-4g";
49836
- /**
49837
- * The decision for ONE row, exposed so the rule is testable without a store.
49838
- *
49839
- * `null` = leave the row alone.
49840
- */
49841
- function decideRowMigration(data, now) {
49842
- const label = data["label"];
49843
- if (typeof label !== "string" || label.length === 0) return null;
49844
- if (typeof data["subLabel"] === "string" && data["subLabel"].length > 0) return null;
49845
- if (data["labelMeta"] !== null && data["labelMeta"] !== void 0) return null;
49846
- if (isMacroClass(label)) return {
49847
- label: null,
49848
- labelScore: null,
49849
- labelMeta: null
49850
- };
49851
- return {
49852
- label: null,
49853
- labelScore: null,
49854
- labelMeta: null,
49855
- subLabel: label,
49856
- subLabelScore: null,
49857
- subLabelMeta: {
49858
- stepId: MIGRATION_STEP_ID,
49859
- decidedAt: now
49860
- }
49861
- };
49862
- }
49863
- /** Migrate one collection, paged and bounded. */
49864
- async function migrateCollectionLabelTiers(deps, collection) {
49865
- let scanned = 0;
49866
- let demoted = 0;
49867
- let cleared = 0;
49868
- let skipped = 0;
49869
- let offset = 0;
49870
- let truncated = false;
49871
- for (;;) {
49872
- if (scanned >= 2e5) {
49873
- truncated = true;
49874
- break;
49875
- }
49876
- const rows = await deps.store.query.query({
49877
- collection,
49878
- filter: {
49879
- limit: 500,
49880
- offset
49881
- }
49882
- });
49883
- if (rows.length === 0) break;
49884
- for (const row of rows) {
49885
- scanned++;
49886
- const patch = decideRowMigration(row.data, deps.now());
49887
- if (patch === null) {
49888
- skipped++;
49889
- continue;
49890
- }
49891
- try {
49892
- await deps.store.update.mutate({
49893
- collection,
49894
- id: row.id,
49895
- data: patch
49896
- });
49897
- if (patch["subLabel"] === void 0) cleared++;
49898
- else demoted++;
49899
- } catch (err) {
49900
- deps.logger.warn("label tier migration row failed", { meta: {
49901
- collection,
49902
- id: row.id,
49903
- error: String(err)
49904
- } });
49905
- skipped++;
49906
- }
49907
- }
49908
- if (rows.length < 500) break;
49909
- offset += rows.length;
49910
- }
49911
- return {
49912
- collection,
49913
- scanned,
49914
- demoted,
49915
- cleared,
49916
- skipped,
49917
- truncated
49918
- };
49919
- }
49920
- /**
49921
- * Run the migration across the collections that carry the tiered columns, once.
49922
- *
49923
- * The marker short-circuits a completed migration; it is written only when
49924
- * NEITHER collection was truncated, so an interrupted pass resumes on the next
49925
- * boot instead of declaring itself done.
49926
- */
49927
- async function runLabelTierMigration(deps, collections) {
49928
- try {
49929
- const marker = await deps.store.get.query({
49930
- collection: MIGRATION_MARKER_COLLECTION,
49931
- key: MIGRATION_MARKER_KEY
49932
- });
49933
- if (marker !== null && marker !== void 0) return [];
49934
- } catch {}
49935
- const stats = [];
49936
- for (const collection of collections) stats.push(await migrateCollectionLabelTiers(deps, collection));
49937
- if (stats.every((s) => !s.truncated)) try {
49938
- await deps.store.set.mutate({
49939
- collection: MIGRATION_MARKER_COLLECTION,
49940
- key: MIGRATION_MARKER_KEY,
49941
- value: { completedAt: deps.now() }
49942
- });
49943
- } catch (err) {
49944
- deps.logger.warn("label tier migration marker failed", { meta: { error: String(err) } });
49945
- }
49946
- for (const s of stats) deps.logger.info("label tier migration", { meta: {
49947
- collection: s.collection,
49948
- scanned: s.scanned,
49949
- demoted: s.demoted,
49950
- cleared: s.cleared,
49951
- skipped: s.skipped,
49952
- truncated: s.truncated
49953
- } });
49954
- return stats;
49955
- }
49956
50534
  //#endregion
49957
50535
  //#region src/pipeline-analytics/store/object-embedding-store.ts
49958
50536
  /**
@@ -51256,6 +51834,16 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
51256
51834
  var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
51257
51835
  /** Persist lastSeen on an open synthetic audio track at most this often. */
51258
51836
  var AUDIO_EPISODE_PERSIST_MS = 2e3;
51837
+ /**
51838
+ * Coalescing window for classified audio inserts when episodes are OFF.
51839
+ *
51840
+ * With `audioEpisodeEnabled: false` there is no hysteresis to collapse a sound
51841
+ * into one row, but the classifier still fires at ~30 Hz — so the pre-episode
51842
+ * rule applies again: while the SAME class persists, one row per heartbeat;
51843
+ * a class change inserts immediately. The knob turns off the grouping, never
51844
+ * the persistence (D62).
51845
+ */
51846
+ var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
51259
51847
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
51260
51848
  /**
51261
51849
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -52364,6 +52952,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52364
52952
  ViewerSettingsSnapshotStore.declare(api.settingsStore)
52365
52953
  ]);
52366
52954
  this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
52955
+ try {
52956
+ await adoptLegacyViewerSettingsSnapshots({
52957
+ store: api.settingsStore,
52958
+ logger: this.ctx.logger.child("viewer-settings")
52959
+ });
52960
+ } catch (err) {
52961
+ this.ctx.logger.warn("viewer-settings legacy adoption threw", { meta: { error: errMsg(err) } });
52962
+ }
52367
52963
  runLabelTierMigration({
52368
52964
  store: api.settingsStore,
52369
52965
  logger: this.ctx.logger,
@@ -55103,6 +55699,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55103
55699
  frameHeight: frame.frameHeight,
55104
55700
  score: detail.score,
55105
55701
  embedding: decodeEmbeddingBase64(detail.embedding),
55702
+ ...detail.embeddingMagnitude !== void 0 ? { embeddingMagnitude: detail.embeddingMagnitude } : {},
55106
55703
  parentBbox: { ...frame.bbox },
55107
55704
  ...detail.bbox !== void 0 ? { faceBbox: { ...detail.bbox } } : {},
55108
55705
  ...detail.nativeFaceShortSidePx !== void 0 ? { nativeFaceShortSidePx: detail.nativeFaceShortSidePx } : {},
@@ -55570,7 +56167,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55570
56167
  return;
55571
56168
  }
55572
56169
  }
55573
- async openAudioEpisode(input, ref) {
56170
+ /**
56171
+ * The ONE writer for a classified audio observation: the durable row, the
56172
+ * Notification Center hook fired in the same persist moment (D-2), the
56173
+ * track-level `audioLabels` stamp, and the telemetry event.
56174
+ *
56175
+ * Shared by both routes on purpose. `audioEpisodeEnabled` selects WHEN this
56176
+ * runs (once per episode, or once per heartbeat), never WHETHER — a knob
56177
+ * that names the grouping must not be able to silence the classified path
56178
+ * (D62). This body was inlined in `openAudioEpisode` and the knob-off camera
56179
+ * therefore persisted nothing at all.
56180
+ */
56181
+ async persistClassifiedAudio(input) {
55574
56182
  const ev = {
55575
56183
  id: randomUUID(),
55576
56184
  deviceId: input.deviceId,
@@ -55579,18 +56187,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55579
56187
  rms: input.rms,
55580
56188
  dbfs: input.dbfs,
55581
56189
  classification: {
55582
- className: ref.className,
56190
+ className: input.className,
55583
56191
  ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
55584
56192
  score: input.score
55585
56193
  }
55586
56194
  };
55587
56195
  this.lastAudioInsertByDevice.set(input.deviceId, {
55588
- className: ref.className,
56196
+ className: input.className,
55589
56197
  atMs: input.timestamp
55590
56198
  });
55591
56199
  await this.eventStore.insertAudio(ev);
55592
56200
  this.notificationCenter?.onAudioEventPersisted(ev);
55593
- this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
56201
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, input.className, input.score, input.timestamp);
55594
56202
  this.ctx.eventBus.emit({
55595
56203
  id: `pa-${ev.id}`,
55596
56204
  timestamp: new Date(ev.timestamp),
@@ -55607,6 +56215,25 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55607
56215
  timestamp: ev.timestamp
55608
56216
  }
55609
56217
  });
56218
+ }
56219
+ /**
56220
+ * Classified audio with episodes OFF: persist on class change or heartbeat,
56221
+ * and open NO synthetic track. This is the whole of what the knob switches —
56222
+ * the timeline track and the episode coalescing — and it is what the form
56223
+ * promises.
56224
+ */
56225
+ async persistClassifiedAudioUngrouped(input) {
56226
+ const last = this.lastAudioInsertByDevice.get(input.deviceId);
56227
+ const sameClass = last?.className === input.className;
56228
+ const heartbeatDue = last === void 0 || input.timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
56229
+ if (sameClass && !heartbeatDue) return;
56230
+ await this.persistClassifiedAudio(input);
56231
+ }
56232
+ async openAudioEpisode(input, ref) {
56233
+ await this.persistClassifiedAudio({
56234
+ ...input,
56235
+ className: ref.className
56236
+ });
55610
56237
  if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
55611
56238
  const materializer = this.syntheticTracks;
55612
56239
  if (materializer === null) return;
@@ -55718,19 +56345,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55718
56345
  });
55719
56346
  return;
55720
56347
  }
55721
- await this.driveAudioEpisode({
56348
+ const classified = {
55722
56349
  deviceId,
55723
56350
  timestamp,
55724
56351
  className: route.className,
55725
56352
  score: topClassification.score,
55726
- originalClass: topClassification.debug?.originalClass,
56353
+ ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
55727
56354
  rms: level?.rms ?? 0,
55728
- dbfs: level?.dbfs ?? 0,
56355
+ dbfs: level?.dbfs ?? 0
56356
+ };
56357
+ if (!settings.audioEpisodeEnabled) {
56358
+ await this.persistClassifiedAudioUngrouped(classified);
56359
+ return;
56360
+ }
56361
+ await this.driveAudioEpisode({
56362
+ ...classified,
55729
56363
  knobs: this.audioEpisodeKnobs(settings)
55730
56364
  });
55731
56365
  return;
55732
56366
  }
55733
- await this.driveAudioEpisode({
56367
+ if (settings.audioEpisodeEnabled) await this.driveAudioEpisode({
55734
56368
  deviceId,
55735
56369
  timestamp,
55736
56370
  className: null,