@camstack/addon-post-analysis 1.2.90 → 1.2.91

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 faceGalleryCapability, At as record, B as VISIT_MERGE_GAP_MS, C as NcSnoozeSchema, Ct as array, D as OpsLogEntrySchema, Dt as number, E as NcTaxonomySchema, Et as literal, F as TIMELAPSE_DENSE_FLOOR_SEC, G as audioModeOf, H as alarmPanelCapability, I as TimelapseRuleInputSchema, J as customAction, K as buildEventKindDescriptor, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as unknown, N as SCENE_DIVERGED, Nt as EventCategory, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as object, P as SceneMonitorSchema, Q as encodeVectorBase64, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as _enum, T as NcSystemEventKindSchema, Tt as discriminatedUnion, U as assertTimelapseCadences, V as addonWidgetsSourceCapability, W as audioMetricsCapability, X as deriveRecordingMode, Y as defineCustomActions, _ as NcRuleInputSchema, _t as createEvent, at as pipelineAnalyticsCapability, b as NcRuleTargetSchema, bt as nodePin, c as EVENT_PAD_MS, ct as readTimelapseGeneratedAt, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as vectorDimFromBase64, f as NC_CONDITION_CATALOG, ft as videoclipsCapability, g as NcConditionDescriptorSchema, gt as DeviceType, h as NC_TAXONOMY, ht as BaseAddon, i as DETECTION_MACRO_CLASSES, it as notificationRulesCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as string, k as RetrainStatusSchema, kt as partialRecord, l as LabelAttributionSchema, lt as sceneMonitorCapability, mt as errMsg, n as DEFAULT_EVENT_COLOR, nt as isScheduleActive, o as DeclaredDevices, ot as plateGalleryCapability, p as NC_DEFAULT_SNOOZE_MINUTES, pt as zoneAnalyticsCapability, q as cosineSimilarity$1, rt as kebabToCamel, s as EVENT_KIND_BY_CAP, st as readDeviceStateFrom, t as BaseDevice, tt as isDetectionMacroClass, u as MACRO_LABELS, ut as subKindsOf, v as NcRulePatchSchema, vt as hydrateSchema, w as NcSnoozeSuppressedSchema, wt as boolean, x as NcScheduleSchema, xt as sleep, y as NcRuleSchema, yt as isDeviceScopedCap, z as TrackSourceSchema } from "../dist-Bac7ww1C.mjs";
1
+ import { $ as faceGalleryCapability, At as partialRecord, B as VISIT_MERGE_GAP_MS, C as NcSnoozeSchema, Ct as _enum, D as OpsLogEntrySchema, Dt as literal, E as NcTaxonomySchema, Et as discriminatedUnion, F as TIMELAPSE_DENSE_FLOOR_SEC, G as audioModeOf, H as alarmPanelCapability, I as TimelapseRuleInputSchema, J as customAction, K as buildEventKindDescriptor, L as TimelapseRulePatchSchema, M as SCENE_DEFAULT_UNCOVERED_POLICY, Mt as string, N as SCENE_DIVERGED, Nt as unknown, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as number, P as SceneMonitorSchema, Pt as EventCategory, Q as encodeVectorBase64, R as TimelapseRuleSchema, S as NcSnoozeInputSchema, St as sleep, T as NcSystemEventKindSchema, Tt as boolean, U as assertTimelapseCadences, V as addonWidgetsSourceCapability, W as audioMetricsCapability, X as deriveRecordingMode, Y as defineCustomActions, _ as NcRuleInputSchema, _t as DeviceType, at as pipelineAnalyticsCapability, b as NcRuleTargetSchema, bt as isDeviceScopedCap, c as EVENT_PAD_MS, ct as readTimelapseGeneratedAt, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as systemEventFilterApplies, f as NC_CONDITION_CATALOG, ft as vectorDimFromBase64, g as NcConditionDescriptorSchema, gt as BaseAddon, h as NC_TAXONOMY, ht as errMsg, i as DETECTION_MACRO_CLASSES, it as notificationRulesCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as record, k as RetrainStatusSchema, kt as object, l as LabelAttributionSchema, lt as sceneMonitorCapability, mt as zoneAnalyticsCapability, n as DEFAULT_EVENT_COLOR, nt as isScheduleActive, o as DeclaredDevices, ot as plateGalleryCapability, p as NC_DEFAULT_SNOOZE_MINUTES, pt as videoclipsCapability, q as cosineSimilarity$1, rt as kebabToCamel, s as EVENT_KIND_BY_CAP, st as readDeviceStateFrom, t as BaseDevice, tt as isDetectionMacroClass, u as MACRO_LABELS, ut as subKindsOf, v as NcRulePatchSchema, vt as createEvent, w as NcSnoozeSuppressedSchema, wt as array, x as NcScheduleSchema, xt as nodePin, y as NcRuleSchema, yt as hydrateSchema, z as TrackSourceSchema } from "../dist-CquggoRC.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";
@@ -7779,17 +7779,40 @@ function referencesAudioClass(classes) {
7779
7779
  if (classes === void 0) return false;
7780
7780
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
7781
7781
  }
7782
- function matchesSystemEvent(condition, subject) {
7783
- if (!condition.kinds.includes(subject.kind)) return false;
7784
- if (condition.deviceIds !== void 0 && (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId))) return false;
7785
- if (condition.deviceTypes !== void 0 && (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType))) return false;
7786
- if (condition.nodeIds !== void 0) {
7787
- if (!subjectNodes(subject).some((id) => condition.nodeIds.includes(id))) return false;
7782
+ /**
7783
+ * Match one system subject against one rule's system-event condition.
7784
+ *
7785
+ * EVERY NARROWING FILTER IS SCOPED TO ITS KIND FAMILY, and the scoping is not
7786
+ * decided here: `systemEventFilterApplies` (in `@camstack/types`) is the one
7787
+ * statement of it, and both rule editors compute their filter visibility from
7788
+ * the same predicate. A filter outside a kind's family is IGNORED — never an
7789
+ * exclusion. It used to be an exclusion, and that made the operator's
7790
+ * eleven-kind rule with `deviceTypes: ['camera']` deliverable for exactly the
7791
+ * two kinds that carry a device type; the other nine failed closed and logged
7792
+ * nothing for four days.
7793
+ *
7794
+ * Inside its family a filter still narrows, and still fails CLOSED when the
7795
+ * subject cannot produce the identity it tests.
7796
+ *
7797
+ * Returns `undefined` on a match.
7798
+ */
7799
+ function systemEventExclusion(condition, subject) {
7800
+ if (!condition.kinds.includes(subject.kind)) return "kinds";
7801
+ const applies = (filter) => systemEventFilterApplies(subject.kind, filter);
7802
+ if (condition.deviceIds !== void 0 && applies("deviceIds")) {
7803
+ if (subject.deviceId === void 0 || !condition.deviceIds.includes(subject.deviceId)) return "deviceIds";
7788
7804
  }
7789
- if (condition.packageNames !== void 0) {
7790
- if (!subjectPackages(subject).some((name) => condition.packageNames.includes(name))) return false;
7805
+ if (condition.deviceTypes !== void 0 && applies("deviceTypes")) {
7806
+ if (subject.deviceType === void 0 || !condition.deviceTypes.includes(subject.deviceType)) return "deviceTypes";
7807
+ }
7808
+ const wantedNodes = condition.nodeIds;
7809
+ if (wantedNodes !== void 0 && applies("nodeIds")) {
7810
+ if (!subjectNodes(subject).some((id) => wantedNodes.includes(id))) return "nodeIds";
7811
+ }
7812
+ const wantedPackages = condition.packageNames;
7813
+ if (wantedPackages !== void 0 && applies("packageNames")) {
7814
+ if (!subjectPackages(subject).some((name) => wantedPackages.includes(name))) return "packageNames";
7791
7815
  }
7792
- return true;
7793
7816
  }
7794
7817
  function subjectNodes(subject) {
7795
7818
  const ids = [...subject.nodeIds ?? []];
@@ -7805,7 +7828,14 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7805
7828
  if (subject.kind === "system-event") {
7806
7829
  if (rule.delivery !== "system-event") return fail("delivery");
7807
7830
  const condition = rule.conditions.systemEvent;
7808
- if (condition === void 0 || subject.systemEvent === void 0 || !matchesSystemEvent(condition, subject.systemEvent)) return fail("systemEvent");
7831
+ const systemEvent = subject.systemEvent;
7832
+ if (condition === void 0 || systemEvent === void 0) return fail("systemEvent");
7833
+ const excluded = systemEventExclusion(condition, systemEvent);
7834
+ if (excluded !== void 0) return excluded === "kinds" ? fail("systemEvent") : {
7835
+ matched: false,
7836
+ failedCondition: "systemEvent",
7837
+ excludedBySystemEventFilter: excluded
7838
+ };
7809
7839
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
7810
7840
  if (rule.conditions.audio !== void 0) return fail("audio");
7811
7841
  } else if (subject.kind === "occupancy-event") {
@@ -20038,6 +20068,39 @@ var NotificationCenter = class NotificationCenter {
20038
20068
  if (identity.isCamera && identity.detectionActive === false) return "detection-off";
20039
20069
  }
20040
20070
  /**
20071
+ * "Your rule covers this kind, and your own filter dropped it."
20072
+ *
20073
+ * INFO, not debug, and unconditional. The alternative is what shipped: a
20074
+ * rule that selected eleven system-event kinds delivered two of them for
20075
+ * four days, and the nine it silently refused produced no line anywhere —
20076
+ * indistinguishable from an intake that had stopped emitting. The filter is
20077
+ * NAMED, and so is the value that failed it, because the fix is always an
20078
+ * edit to that one list.
20079
+ *
20080
+ * `tags.deviceId` carries the SUBJECT's numeric device id (the system
20081
+ * subject's own `deviceId` is the sentinel {@link NC_SYSTEM_DEVICE_ID}), so
20082
+ * "why is 617 louder than 2018?" is answerable by grouping. A node- or
20083
+ * package-scoped exclusion is about no device and carries no tag.
20084
+ */
20085
+ reportSystemFilterExclusion(rule, subject, filter) {
20086
+ const system = subject.systemEvent;
20087
+ if (system === void 0) return;
20088
+ this.logger.info("system event excluded by a rule filter", {
20089
+ ...system.deviceId !== void 0 ? { tags: { deviceId: system.deviceId } } : {},
20090
+ meta: {
20091
+ ruleId: rule.id,
20092
+ rule: rule.name,
20093
+ kind: system.kind,
20094
+ filter,
20095
+ eventId: subject.recordId,
20096
+ subject: system.subject,
20097
+ ...system.deviceType !== void 0 ? { deviceType: system.deviceType } : {},
20098
+ ...system.nodeId !== void 0 ? { nodeId: system.nodeId } : {},
20099
+ ...system.packageName !== void 0 ? { packageName: system.packageName } : {}
20100
+ }
20101
+ });
20102
+ }
20103
+ /**
20041
20104
  * SAY that the scene gate suppressed this rule.
20042
20105
  *
20043
20106
  * A branch that drops work silently logs it — the three-hour media blackout
@@ -20457,6 +20520,7 @@ var NotificationCenter = class NotificationCenter {
20457
20520
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
20458
20521
  if (!evaluation.matched) {
20459
20522
  if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
20523
+ if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
20460
20524
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
20461
20525
  outcomes.push({
20462
20526
  ruleId: rule.id,
@@ -22357,7 +22421,7 @@ var ncActions = defineCustomActions({
22357
22421
  "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22358
22422
  });
22359
22423
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22360
- function requireCaller(caller) {
22424
+ function requireCaller$1(caller) {
22361
22425
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
22362
22426
  return caller;
22363
22427
  }
@@ -22455,14 +22519,14 @@ function makeNcActionHandlers(deps) {
22455
22519
  };
22456
22520
  return {
22457
22521
  "nc.listRules": async (_input, caller) => {
22458
- const c = requireCaller(caller);
22522
+ const c = requireCaller$1(caller);
22459
22523
  return { rules: (c.isAdmin ? deps.ruleStore.list() : deps.ruleStore.listForOwner(c.userId)).map((r) => ({
22460
22524
  ...r,
22461
22525
  readOnly: !c.isAdmin && r.ownerUserId !== c.userId
22462
22526
  })) };
22463
22527
  },
22464
22528
  "nc.listTrackGroups": async (input, caller) => {
22465
- requireCaller(caller);
22529
+ requireCaller$1(caller);
22466
22530
  const list = deps.listTrackGroups;
22467
22531
  if (list === void 0) return { groups: [] };
22468
22532
  return { groups: [...await list(input)] };
@@ -22472,7 +22536,7 @@ function makeNcActionHandlers(deps) {
22472
22536
  taxonomy: NC_TAXONOMY
22473
22537
  }),
22474
22538
  "nc.createRule": async (input, caller) => {
22475
- const c = requireCaller(caller);
22539
+ const c = requireCaller$1(caller);
22476
22540
  const parsed = NcRuleInputSchema.parse(input.rule);
22477
22541
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22478
22542
  const rule = await deps.ruleStore.create({
@@ -22486,7 +22550,7 @@ function makeNcActionHandlers(deps) {
22486
22550
  return { rule };
22487
22551
  },
22488
22552
  "nc.updateRule": async (input, caller) => {
22489
- const c = requireCaller(caller);
22553
+ const c = requireCaller$1(caller);
22490
22554
  assertRuleEditable(input.ruleId, c);
22491
22555
  const patch = NcRulePatchSchema.parse(input.patch);
22492
22556
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
@@ -22499,7 +22563,7 @@ function makeNcActionHandlers(deps) {
22499
22563
  return { rule };
22500
22564
  },
22501
22565
  "nc.deleteRule": async (input, caller) => {
22502
- const c = requireCaller(caller);
22566
+ const c = requireCaller$1(caller);
22503
22567
  assertRuleEditable(input.ruleId, c);
22504
22568
  await deps.ruleStore.delete(input.ruleId);
22505
22569
  deps.logger.info("nc rule deleted", { meta: {
@@ -22509,11 +22573,11 @@ function makeNcActionHandlers(deps) {
22509
22573
  return { success: true };
22510
22574
  },
22511
22575
  "nc.listSnoozes": async (_input, caller) => {
22512
- const c = requireCaller(caller);
22576
+ const c = requireCaller$1(caller);
22513
22577
  return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
22514
22578
  },
22515
22579
  "nc.createSnooze": async (input, caller) => {
22516
- const c = requireCaller(caller);
22580
+ const c = requireCaller$1(caller);
22517
22581
  const parsed = NcSnoozeInputSchema.parse(input.snooze);
22518
22582
  if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
22519
22583
  const snooze = await deps.snooze.create(parsed, c);
@@ -22526,12 +22590,12 @@ function makeNcActionHandlers(deps) {
22526
22590
  return { snooze };
22527
22591
  },
22528
22592
  "nc.cancelSnooze": async (input, caller) => {
22529
- const c = requireCaller(caller);
22593
+ const c = requireCaller$1(caller);
22530
22594
  await deps.snooze.cancel(input.snoozeId, c);
22531
22595
  return { success: true };
22532
22596
  },
22533
22597
  "nc.injectTestEvent": async (input, caller) => {
22534
- const c = requireCaller(caller);
22598
+ const c = requireCaller$1(caller);
22535
22599
  if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
22536
22600
  const inject = deps.injectTestEvent;
22537
22601
  if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
@@ -22547,7 +22611,7 @@ function makeNcActionHandlers(deps) {
22547
22611
  return inject(parsed);
22548
22612
  },
22549
22613
  "nc.testRule": async (input, caller) => {
22550
- const c = requireCaller(caller);
22614
+ const c = requireCaller$1(caller);
22551
22615
  if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
22552
22616
  const inject = deps.injectTestEvent;
22553
22617
  if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
@@ -22579,7 +22643,7 @@ function makeNcActionHandlers(deps) {
22579
22643
  };
22580
22644
  },
22581
22645
  "nc.getRuleStatus": async (input, caller) => {
22582
- const c = requireCaller(caller);
22646
+ const c = requireCaller$1(caller);
22583
22647
  const status = deps.ruleStatus;
22584
22648
  if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
22585
22649
  const detection = deps.ruleStore.get(input.ruleId);
@@ -22595,7 +22659,7 @@ function makeNcActionHandlers(deps) {
22595
22659
  throw new Error(`rule not found: ${input.ruleId}`);
22596
22660
  },
22597
22661
  "nc.listTimelapseRules": async (_input, caller) => {
22598
- const c = requireCaller(caller);
22662
+ const c = requireCaller$1(caller);
22599
22663
  const tl = requireTimelapse();
22600
22664
  return { rules: (c.isAdmin ? tl.store.list() : tl.store.listForOwner(c.userId)).map((r) => ({
22601
22665
  ...r,
@@ -22603,7 +22667,7 @@ function makeNcActionHandlers(deps) {
22603
22667
  })) };
22604
22668
  },
22605
22669
  "nc.upsertTimelapseRule": async (input, caller) => {
22606
- const c = requireCaller(caller);
22670
+ const c = requireCaller$1(caller);
22607
22671
  const tl = requireTimelapse();
22608
22672
  if (input.ruleId === void 0) {
22609
22673
  if (input.rule === void 0) throw new Error("upsertTimelapseRule: rule or ruleId required");
@@ -22639,7 +22703,7 @@ function makeNcActionHandlers(deps) {
22639
22703
  return { rule };
22640
22704
  },
22641
22705
  "nc.deleteTimelapseRule": async (input, caller) => {
22642
- const c = requireCaller(caller);
22706
+ const c = requireCaller$1(caller);
22643
22707
  const tl = requireTimelapse();
22644
22708
  assertTimelapseEditable(input.ruleId, c, tl);
22645
22709
  await tl.store.delete(input.ruleId);
@@ -22650,7 +22714,7 @@ function makeNcActionHandlers(deps) {
22650
22714
  return { success: true };
22651
22715
  },
22652
22716
  "nc.testTimelapseRule": async (input, caller) => {
22653
- const c = requireCaller(caller);
22717
+ const c = requireCaller$1(caller);
22654
22718
  if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test timelapse");
22655
22719
  const tl = requireTimelapse();
22656
22720
  deps.logger.info("timelapse test requested", {
@@ -22677,14 +22741,14 @@ function makeNcActionHandlers(deps) {
22677
22741
  };
22678
22742
  },
22679
22743
  "nc.setRuleTargetEnabled": async (input, caller) => {
22680
- const c = requireCaller(caller);
22744
+ const c = requireCaller$1(caller);
22681
22745
  assertRuleVisible(input.ruleId, c);
22682
22746
  await assertTargetsOwned([input.targetId], c);
22683
22747
  await deps.ruleStore.setRuleTargetEnabled(input.ruleId, input.targetId, input.enabled);
22684
22748
  return { success: true };
22685
22749
  },
22686
22750
  "nc.listSummaryRules": async (_input, caller) => {
22687
- const c = requireCaller(caller);
22751
+ const c = requireCaller$1(caller);
22688
22752
  const sum = requireSummary();
22689
22753
  return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22690
22754
  ...r,
@@ -22692,7 +22756,7 @@ function makeNcActionHandlers(deps) {
22692
22756
  })) };
22693
22757
  },
22694
22758
  "nc.upsertSummaryRule": async (input, caller) => {
22695
- const c = requireCaller(caller);
22759
+ const c = requireCaller$1(caller);
22696
22760
  if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22697
22761
  const sum = requireSummary();
22698
22762
  if (input.ruleId === void 0) {
@@ -22725,7 +22789,7 @@ function makeNcActionHandlers(deps) {
22725
22789
  return { rule };
22726
22790
  },
22727
22791
  "nc.deleteSummaryRule": async (input, caller) => {
22728
- const c = requireCaller(caller);
22792
+ const c = requireCaller$1(caller);
22729
22793
  if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22730
22794
  await requireSummary().store.delete(input.ruleId);
22731
22795
  deps.logger.info("summary rule deleted", { meta: {
@@ -22735,7 +22799,7 @@ function makeNcActionHandlers(deps) {
22735
22799
  return { success: true };
22736
22800
  },
22737
22801
  "nc.testSummaryRule": async (input, caller) => {
22738
- const c = requireCaller(caller);
22802
+ const c = requireCaller$1(caller);
22739
22803
  if (!c.isAdmin) throw new Error("forbidden: admin required to produce a test summary");
22740
22804
  const sum = requireSummary();
22741
22805
  const startMs = input.windowStartMs;
@@ -22782,7 +22846,7 @@ function makeNcActionHandlers(deps) {
22782
22846
  "nc.setLanguage": async (input) => requireTexts().setLanguage(input.language),
22783
22847
  "nc.setTextOverrides": async (input) => requireTexts().setOverrides(input.language, input.overrides),
22784
22848
  "nc.listArtifacts": async (input, caller) => {
22785
- const c = requireCaller(caller);
22849
+ const c = requireCaller$1(caller);
22786
22850
  const page = await requireArtifacts().list(input, {
22787
22851
  userId: c.userId,
22788
22852
  isAdmin: c.isAdmin
@@ -22794,7 +22858,7 @@ function makeNcActionHandlers(deps) {
22794
22858
  };
22795
22859
  },
22796
22860
  "nc.getArtifact": async (input, caller) => {
22797
- const c = requireCaller(caller);
22861
+ const c = requireCaller$1(caller);
22798
22862
  return { artifact: await requireArtifacts().get(input.artifactId, {
22799
22863
  userId: c.userId,
22800
22864
  isAdmin: c.isAdmin
@@ -24443,11 +24507,11 @@ var WipeResultSchema = object({ deleted: number() });
24443
24507
  * and can never be called: the bridge insists on `{}` and the schema rejects
24444
24508
  * it. Verified on the live hub, both directions.
24445
24509
  */
24446
- var NoInputSchema = object({});
24510
+ var NoInputSchema$1 = object({});
24447
24511
  var embeddingActions = defineCustomActions({
24448
24512
  "embedding.rebuild": customAction(RebuildInputSchema, RebuildStartedSchema, { auth: "admin" }),
24449
- "embedding.rebuildStatus": customAction(NoInputSchema, RebuildStatusSchema),
24450
- "embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
24513
+ "embedding.rebuildStatus": customAction(NoInputSchema$1, RebuildStatusSchema),
24514
+ "embedding.wipe": customAction(NoInputSchema$1, WipeResultSchema, { auth: "admin" })
24451
24515
  });
24452
24516
  //#endregion
24453
24517
  //#region src/pipeline-analytics/map-with-concurrency.ts
@@ -29949,6 +30013,219 @@ function createVideoclipsProvider(deps) {
29949
30013
  }
29950
30014
  };
29951
30015
  }
30016
+ //#endregion
30017
+ //#region src/pipeline-analytics/viewer-settings-actions.ts
30018
+ /**
30019
+ * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
30020
+ *
30021
+ * Rides `addons.custom` (`viewerSettings.*`) so it ships with an addon deploy
30022
+ * and does not wait on a cap/codegen/server image. Ownership is the
30023
+ * authenticated caller (`caller: 'required'`); snapshots are visible to every
30024
+ * hub user, overwrite/delete only of one's own.
30025
+ */
30026
+ /**
30027
+ * @durable class=config owner=pipeline-analytics
30028
+ * write="a signed-in viewer user saves a named snapshot of their local stores"
30029
+ * retention="none — a row goes only when its owner deletes it"
30030
+ */
30031
+ var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "viewer-settings-snapshots";
30032
+ var COLUMNS = [
30033
+ {
30034
+ name: "id",
30035
+ type: "TEXT",
30036
+ primaryKey: true,
30037
+ notNull: true
30038
+ },
30039
+ {
30040
+ name: "name",
30041
+ type: "TEXT",
30042
+ notNull: true
30043
+ },
30044
+ {
30045
+ name: "ownerUserId",
30046
+ type: "TEXT",
30047
+ notNull: true
30048
+ },
30049
+ {
30050
+ name: "ownerUsername",
30051
+ type: "TEXT",
30052
+ notNull: true
30053
+ },
30054
+ {
30055
+ name: "createdAt",
30056
+ type: "INTEGER",
30057
+ notNull: true
30058
+ },
30059
+ {
30060
+ name: "updatedAt",
30061
+ type: "INTEGER",
30062
+ notNull: true
30063
+ },
30064
+ {
30065
+ name: "blob",
30066
+ type: "JSON",
30067
+ notNull: true
30068
+ }
30069
+ ];
30070
+ var NameSchema = string().trim().min(1).max(80);
30071
+ var BlobSchema = record(string(), unknown());
30072
+ var ViewerSettingsSnapshotSummarySchema = object({
30073
+ id: string(),
30074
+ name: string(),
30075
+ ownerUserId: string(),
30076
+ ownerUsername: string(),
30077
+ createdAt: number().int(),
30078
+ updatedAt: number().int(),
30079
+ mine: boolean()
30080
+ });
30081
+ var ViewerSettingsSnapshotSchema = ViewerSettingsSnapshotSummarySchema.extend({ blob: BlobSchema });
30082
+ var viewerSettingsActions = defineCustomActions({
30083
+ "viewerSettings.list": customAction(object({}), object({ snapshots: array(ViewerSettingsSnapshotSummarySchema) }), { caller: "required" }),
30084
+ "viewerSettings.save": customAction(object({
30085
+ name: NameSchema,
30086
+ blob: BlobSchema,
30087
+ ownerUsername: string().min(1).max(80)
30088
+ }), object({ snapshot: ViewerSettingsSnapshotSummarySchema }), {
30089
+ kind: "mutation",
30090
+ caller: "required"
30091
+ }),
30092
+ "viewerSettings.get": customAction(object({ id: string().min(1) }), object({ snapshot: ViewerSettingsSnapshotSchema.nullable() }), { caller: "required" }),
30093
+ "viewerSettings.delete": customAction(object({ id: string().min(1) }), object({ success: literal(true) }), {
30094
+ kind: "mutation",
30095
+ caller: "required"
30096
+ })
30097
+ });
30098
+ function requireCaller(caller) {
30099
+ if (!caller) throw new Error("viewerSettings: authenticated caller required");
30100
+ return caller;
30101
+ }
30102
+ function asRecord(data) {
30103
+ return typeof data === "object" && data !== null ? data : {};
30104
+ }
30105
+ function toSummary(id, data, callerUserId) {
30106
+ const parsed = ViewerSettingsSnapshotSummarySchema.omit({ mine: true }).safeParse({
30107
+ id,
30108
+ name: data.name,
30109
+ ownerUserId: data.ownerUserId,
30110
+ ownerUsername: data.ownerUsername,
30111
+ createdAt: data.createdAt,
30112
+ updatedAt: data.updatedAt
30113
+ });
30114
+ if (!parsed.success) return null;
30115
+ return {
30116
+ ...parsed.data,
30117
+ mine: parsed.data.ownerUserId === callerUserId
30118
+ };
30119
+ }
30120
+ var ViewerSettingsSnapshotStore = class {
30121
+ store;
30122
+ constructor(store) {
30123
+ this.store = store;
30124
+ }
30125
+ static async declare(store) {
30126
+ await store.declareCollection.mutate({
30127
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30128
+ columns: [...COLUMNS],
30129
+ indexes: [{
30130
+ name: "idx_viewer_settings_owner",
30131
+ columns: ["ownerUserId"]
30132
+ }]
30133
+ });
30134
+ }
30135
+ async list(caller) {
30136
+ const rows = await this.store.query.query({
30137
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30138
+ filter: {
30139
+ orderBy: {
30140
+ field: "updatedAt",
30141
+ direction: "desc"
30142
+ },
30143
+ limit: 200
30144
+ }
30145
+ });
30146
+ const out = [];
30147
+ for (const row of rows) {
30148
+ const summary = toSummary(row.id, asRecord(row.data), caller.userId);
30149
+ if (summary) out.push(summary);
30150
+ }
30151
+ return out;
30152
+ }
30153
+ async get(id, caller) {
30154
+ const row = (await this.store.query.query({
30155
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30156
+ filter: {
30157
+ where: { id },
30158
+ limit: 1
30159
+ }
30160
+ }))[0];
30161
+ if (!row) return null;
30162
+ const data = asRecord(row.data);
30163
+ const summary = toSummary(row.id, data, caller.userId);
30164
+ if (!summary) return null;
30165
+ const blob = BlobSchema.safeParse(data.blob);
30166
+ if (!blob.success) return null;
30167
+ return {
30168
+ ...summary,
30169
+ blob: blob.data
30170
+ };
30171
+ }
30172
+ async save(input, caller) {
30173
+ const now = Date.now();
30174
+ const prior = (await this.store.query.query({
30175
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30176
+ filter: {
30177
+ where: {
30178
+ ownerUserId: caller.userId,
30179
+ name: input.name
30180
+ },
30181
+ limit: 1
30182
+ }
30183
+ }))[0];
30184
+ const id = prior?.id ?? randomUUID();
30185
+ const createdAt = typeof asRecord(prior?.data).createdAt === "number" ? asRecord(prior?.data).createdAt : now;
30186
+ await this.store.set.mutate({
30187
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30188
+ key: id,
30189
+ value: {
30190
+ name: input.name,
30191
+ ownerUserId: caller.userId,
30192
+ ownerUsername: input.ownerUsername,
30193
+ createdAt,
30194
+ updatedAt: now,
30195
+ blob: input.blob
30196
+ }
30197
+ });
30198
+ return {
30199
+ id,
30200
+ name: input.name,
30201
+ ownerUserId: caller.userId,
30202
+ ownerUsername: input.ownerUsername,
30203
+ createdAt,
30204
+ updatedAt: now,
30205
+ mine: true
30206
+ };
30207
+ }
30208
+ async delete(id, caller) {
30209
+ const row = await this.get(id, caller);
30210
+ if (!row) throw new Error("viewerSettings: snapshot not found");
30211
+ if (row.ownerUserId !== caller.userId && !caller.isAdmin) throw new Error("viewerSettings: only the owner can delete this snapshot");
30212
+ await this.store.delete.mutate({
30213
+ collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
30214
+ key: id
30215
+ });
30216
+ }
30217
+ };
30218
+ function makeViewerSettingsActionHandlers(store) {
30219
+ return {
30220
+ "viewerSettings.list": async (_input, caller) => ({ snapshots: await store.list(requireCaller(caller)) }),
30221
+ "viewerSettings.save": async (input, caller) => ({ snapshot: await store.save(input, requireCaller(caller)) }),
30222
+ "viewerSettings.get": async (input, caller) => ({ snapshot: await store.get(input.id, requireCaller(caller)) }),
30223
+ "viewerSettings.delete": async (input, caller) => {
30224
+ await store.delete(input.id, requireCaller(caller));
30225
+ return { success: true };
30226
+ }
30227
+ };
30228
+ }
29952
30229
  /**
29953
30230
  * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
29954
30231
  *
@@ -30755,7 +31032,15 @@ var AudioDetectionSettingsSchema = object({
30755
31032
  /** Flood ceiling: markers per camera per rolling 24 h. This, not the
30756
31033
  * thresholds, is what bounds the worst case — a mis-tuned threshold can
30757
31034
  * only ever cost the operator this many markers a day. */
30758
- audioMarkerMaxPerDay: number().int().positive().default(12)
31035
+ audioMarkerMaxPerDay: number().int().positive().default(12),
31036
+ /** Materialize one event + one synthetic track per classified episode. */
31037
+ audioEpisodeEnabled: boolean().default(true),
31038
+ /** Class must be heard continuously this long before the episode opens. */
31039
+ audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
31040
+ /** Class must be absent this long before the episode closes. */
31041
+ audioEpisodeCloseGapSec: number().positive().default(12),
31042
+ /** After close, the same class cannot reopen until this elapses. */
31043
+ audioEpisodeCooldownSec: number().nonnegative().default(30)
30759
31044
  });
30760
31045
  var AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
30761
31046
  /**
@@ -30779,6 +31064,159 @@ function resolveAudioDetectionSettings(raw) {
30779
31064
  return resolved.success ? resolved.data : AUDIO_DETECTION_DEFAULTS;
30780
31065
  }
30781
31066
  //#endregion
31067
+ //#region src/pipeline-analytics/audio-episode-hysteresis.ts
31068
+ var EMPTY = {
31069
+ pendingClass: null,
31070
+ pendingSinceMs: 0,
31071
+ openClass: null,
31072
+ episodeId: null,
31073
+ lastHeardMs: 0,
31074
+ lastClosedClass: null,
31075
+ lastClosedMs: 0
31076
+ };
31077
+ function emptyAudioEpisodeState() {
31078
+ return EMPTY;
31079
+ }
31080
+ function episodeIdFor(className, timestampMs) {
31081
+ return `${className}:${timestampMs}`;
31082
+ }
31083
+ function inCooldown(state, className, timestampMs, knobs) {
31084
+ return state.lastClosedClass === className && timestampMs - state.lastClosedMs < knobs.cooldownMs;
31085
+ }
31086
+ function closedState(className, atMs) {
31087
+ return {
31088
+ ...EMPTY,
31089
+ lastClosedClass: className,
31090
+ lastClosedMs: atMs
31091
+ };
31092
+ }
31093
+ function observeAudioEpisode(state, sample, knobs) {
31094
+ if (!knobs.enabled) return {
31095
+ nextState: EMPTY,
31096
+ action: { kind: "none" }
31097
+ };
31098
+ const { timestampMs, className } = sample;
31099
+ const openClass = state.openClass;
31100
+ const openId = state.episodeId;
31101
+ if (openClass !== null && openId !== null) {
31102
+ if (className === openClass) return {
31103
+ nextState: {
31104
+ ...state,
31105
+ pendingClass: null,
31106
+ pendingSinceMs: 0,
31107
+ lastHeardMs: timestampMs
31108
+ },
31109
+ action: {
31110
+ kind: "hold",
31111
+ className: openClass,
31112
+ episodeId: openId
31113
+ }
31114
+ };
31115
+ if (className === null) {
31116
+ if (timestampMs - state.lastHeardMs < knobs.closeGapMs) return {
31117
+ nextState: state,
31118
+ action: { kind: "none" }
31119
+ };
31120
+ return {
31121
+ nextState: closedState(openClass, timestampMs),
31122
+ action: {
31123
+ kind: "close",
31124
+ className: openClass,
31125
+ episodeId: openId
31126
+ }
31127
+ };
31128
+ }
31129
+ if (inCooldown(state, className, timestampMs, knobs)) return {
31130
+ nextState: state,
31131
+ action: { kind: "none" }
31132
+ };
31133
+ const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
31134
+ if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
31135
+ const openedId = episodeIdFor(className, timestampMs);
31136
+ return {
31137
+ nextState: {
31138
+ pendingClass: null,
31139
+ pendingSinceMs: 0,
31140
+ openClass: className,
31141
+ episodeId: openedId,
31142
+ lastHeardMs: timestampMs,
31143
+ lastClosedClass: openClass,
31144
+ lastClosedMs: timestampMs
31145
+ },
31146
+ action: {
31147
+ kind: "replace",
31148
+ closed: {
31149
+ className: openClass,
31150
+ episodeId: openId
31151
+ },
31152
+ opened: {
31153
+ className,
31154
+ episodeId: openedId
31155
+ }
31156
+ }
31157
+ };
31158
+ }
31159
+ return {
31160
+ nextState: {
31161
+ ...state,
31162
+ pendingClass: className,
31163
+ pendingSinceMs: pendingSince
31164
+ },
31165
+ action: { kind: "none" }
31166
+ };
31167
+ }
31168
+ if (className === null) {
31169
+ if (state.pendingClass === null) return {
31170
+ nextState: state,
31171
+ action: { kind: "none" }
31172
+ };
31173
+ return {
31174
+ nextState: {
31175
+ ...state,
31176
+ pendingClass: null,
31177
+ pendingSinceMs: 0
31178
+ },
31179
+ action: { kind: "none" }
31180
+ };
31181
+ }
31182
+ if (inCooldown(state, className, timestampMs, knobs)) return {
31183
+ nextState: state,
31184
+ action: { kind: "none" }
31185
+ };
31186
+ const pendingSince = state.pendingClass === className ? state.pendingSinceMs : timestampMs;
31187
+ if (knobs.openHoldMs === 0 || state.pendingClass === className && timestampMs - pendingSince >= knobs.openHoldMs) {
31188
+ const episodeId = episodeIdFor(className, timestampMs);
31189
+ return {
31190
+ nextState: {
31191
+ pendingClass: null,
31192
+ pendingSinceMs: 0,
31193
+ openClass: className,
31194
+ episodeId,
31195
+ lastHeardMs: timestampMs,
31196
+ lastClosedClass: state.lastClosedClass,
31197
+ lastClosedMs: state.lastClosedMs
31198
+ },
31199
+ action: {
31200
+ kind: "open",
31201
+ className,
31202
+ episodeId
31203
+ }
31204
+ };
31205
+ }
31206
+ if (state.pendingClass === className) return {
31207
+ nextState: state,
31208
+ action: { kind: "none" }
31209
+ };
31210
+ return {
31211
+ nextState: {
31212
+ ...state,
31213
+ pendingClass: className,
31214
+ pendingSinceMs: timestampMs
31215
+ },
31216
+ action: { kind: "none" }
31217
+ };
31218
+ }
31219
+ //#endregion
30782
31220
  //#region src/pipeline-analytics/audio-event-router.ts
30783
31221
  /**
30784
31222
  * Decide whether an audio frame is a (meaningful) classification event or a
@@ -48343,6 +48781,51 @@ function buildGlobalSettingsSchema() {
48343
48781
  }
48344
48782
  ]
48345
48783
  },
48784
+ {
48785
+ id: "audio-episodes",
48786
+ title: "Audio episodes",
48787
+ description: "One timeline track and one event per classified sound, held open with hysteresis so a long cry is not four tracks. No class filter: speech, crying, a bark — the knobs below are what stop repeats. Leave on for a day or two and judge the spam, then tighten per camera.",
48788
+ columns: 2,
48789
+ fields: [
48790
+ {
48791
+ type: "boolean",
48792
+ key: "audioEpisodeEnabled",
48793
+ label: "Create audio episodes",
48794
+ description: "When off, classified audio still notifies from other paths but does not open a synthetic track or coalesce classification events.",
48795
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeEnabled
48796
+ },
48797
+ {
48798
+ type: "number",
48799
+ key: "audioEpisodeOpenHoldSec",
48800
+ label: "Open hold",
48801
+ description: "The class must be heard continuously this long before an episode opens. Filters 200 ms blips.",
48802
+ min: 0,
48803
+ step: .5,
48804
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeOpenHoldSec,
48805
+ unit: "s"
48806
+ },
48807
+ {
48808
+ type: "number",
48809
+ key: "audioEpisodeCloseGapSec",
48810
+ label: "Close gap",
48811
+ description: "The class must be absent this long before the episode closes. A pause inside a cry does not start a second track.",
48812
+ min: 1,
48813
+ step: 1,
48814
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCloseGapSec,
48815
+ unit: "s"
48816
+ },
48817
+ {
48818
+ type: "number",
48819
+ key: "audioEpisodeCooldownSec",
48820
+ label: "Cooldown",
48821
+ description: "After an episode closes, the same class cannot reopen until this elapses.",
48822
+ min: 0,
48823
+ step: 5,
48824
+ default: AUDIO_DETECTION_DEFAULTS.audioEpisodeCooldownSec,
48825
+ unit: "s"
48826
+ }
48827
+ ]
48828
+ },
48346
48829
  {
48347
48830
  id: "face-recognition",
48348
48831
  title: "Face recognition",
@@ -49828,7 +50311,8 @@ var customActions = {
49828
50311
  ...ncActions,
49829
50312
  ...embeddingActions,
49830
50313
  ...debugActions,
49831
- ...orphanAuditActions
50314
+ ...orphanAuditActions,
50315
+ ...viewerSettingsActions
49832
50316
  };
49833
50317
  /**
49834
50318
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -49930,7 +50414,12 @@ var LTS_FLUSH_INTERVAL_MS = 6e4;
49930
50414
  var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
49931
50415
  /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
49932
50416
  var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
49933
- var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
50417
+ /** How recently a spatial track must have been alive to count as "visual
50418
+ * happening" — stamp audio labels on that track instead of opening a
50419
+ * synthetic audio episode. */
50420
+ var AUDIO_EPISODE_VISUAL_QUIET_MS = 2e3;
50421
+ /** Persist lastSeen on an open synthetic audio track at most this often. */
50422
+ var AUDIO_EPISODE_PERSIST_MS = 2e3;
49934
50423
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
49935
50424
  /**
49936
50425
  * Stored media kinds that carry NO drawn bounding box, in fallback preference
@@ -50402,6 +50891,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50402
50891
  * fallible read on the decision path.
50403
50892
  */
50404
50893
  lastTrackActivityMs = /* @__PURE__ */ new Map();
50894
+ /** Per-device classified-audio episode hysteresis. */
50895
+ audioEpisodeByDevice = /* @__PURE__ */ new Map();
50896
+ /** Open synthetic tracks keyed by hysteresis episodeId. */
50897
+ audioEpisodeTracks = /* @__PURE__ */ new Map();
50898
+ audioEpisodeLastPersistMs = /* @__PURE__ */ new Map();
50405
50899
  settingsCaches = new DeviceSettingsCacheHub();
50406
50900
  /** Avoids a store read every ~30 Hz audio frame. */
50407
50901
  audioSettingsCache = this.settingsCaches.registerDeviceCache("audio-detection", SETTINGS_CACHE_TTL_MS);
@@ -50685,6 +51179,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50685
51179
  * NAME — its scope list is derived from the classification registry, not
50686
51180
  * from a set of store instances. */
50687
51181
  settingsStore = null;
51182
+ viewerSettingsSnapshots = null;
50688
51183
  /** Per-scope resume cursors, so a run interrupted by a restart costs one
50689
51184
  * page rather than a full rescan. In RAM: losing them costs a rescan, and
50690
51185
  * persisting them would be a durable record of a purely advisory position. */
@@ -50822,12 +51317,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50822
51317
  ...ncActions,
50823
51318
  ...embeddingActions,
50824
51319
  ...debugActions,
50825
- ...orphanAuditActions
51320
+ ...orphanAuditActions,
51321
+ ...viewerSettingsActions
50826
51322
  },
50827
51323
  actionHandlers: {
50828
51324
  ...ncHandlers,
50829
51325
  ...this.buildEmbeddingActionHandlers(),
50830
- ...this.buildDebugActionHandlers()
51326
+ ...this.buildDebugActionHandlers(),
51327
+ ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
50831
51328
  }
50832
51329
  } : {}
50833
51330
  };
@@ -51002,8 +51499,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
51002
51499
  OpsLogStore.declare(api.settingsStore),
51003
51500
  AnalyticsLts.declare(api.settingsStore),
51004
51501
  SceneStore.declare(api.settingsStore),
51005
- NotificationCenter.declare(api.settingsStore)
51502
+ NotificationCenter.declare(api.settingsStore),
51503
+ ViewerSettingsSnapshotStore.declare(api.settingsStore)
51006
51504
  ]);
51505
+ this.viewerSettingsSnapshots = new ViewerSettingsSnapshotStore(api.settingsStore);
51007
51506
  runLabelTierMigration({
51008
51507
  store: api.settingsStore,
51009
51508
  logger: this.ctx.logger,
@@ -52263,6 +52762,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52263
52762
  this.forgetDeviceProcessors(deviceId);
52264
52763
  this.levelStateByDevice.delete(deviceId);
52265
52764
  this.lastTrackActivityMs.delete(deviceId);
52765
+ this.audioEpisodeByDevice.delete(deviceId);
52766
+ for (const [episodeId, track] of this.audioEpisodeTracks) if (track.deviceId === deviceId) {
52767
+ this.audioEpisodeTracks.delete(episodeId);
52768
+ this.audioEpisodeLastPersistMs.delete(episodeId);
52769
+ }
52266
52770
  this.audioMarkers?.forgetDevice(deviceId);
52267
52771
  this.settingsCaches.clearDevice(deviceId);
52268
52772
  this.bindingCache?.invalidate(deviceId);
@@ -54159,6 +54663,146 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54159
54663
  maxPerDay: settings.audioMarkerMaxPerDay
54160
54664
  };
54161
54665
  }
54666
+ audioEpisodeKnobs(settings) {
54667
+ return {
54668
+ enabled: settings.audioEpisodeEnabled,
54669
+ openHoldMs: settings.audioEpisodeOpenHoldSec * 1e3,
54670
+ closeGapMs: settings.audioEpisodeCloseGapSec * 1e3,
54671
+ cooldownMs: settings.audioEpisodeCooldownSec * 1e3
54672
+ };
54673
+ }
54674
+ hasLiveVisualTrack(deviceId, atMs) {
54675
+ const at = this.lastTrackActivityMs.get(deviceId);
54676
+ return at !== void 0 && atMs - at < AUDIO_EPISODE_VISUAL_QUIET_MS;
54677
+ }
54678
+ async driveAudioEpisode(input) {
54679
+ const { nextState, action } = observeAudioEpisode(this.audioEpisodeByDevice.get(input.deviceId) ?? emptyAudioEpisodeState(), {
54680
+ timestampMs: input.timestamp,
54681
+ className: input.className
54682
+ }, input.knobs);
54683
+ this.audioEpisodeByDevice.set(input.deviceId, nextState);
54684
+ switch (action.kind) {
54685
+ case "none": return;
54686
+ case "open":
54687
+ await this.openAudioEpisode(input, action);
54688
+ return;
54689
+ case "hold":
54690
+ await this.holdAudioEpisode(input, action);
54691
+ return;
54692
+ case "close":
54693
+ await this.persistAudioEpisodeLastSeen(action, input.timestamp, true);
54694
+ return;
54695
+ case "replace":
54696
+ await this.persistAudioEpisodeLastSeen(action.closed, input.timestamp, true);
54697
+ await this.openAudioEpisode(input, action.opened);
54698
+ return;
54699
+ }
54700
+ }
54701
+ async openAudioEpisode(input, ref) {
54702
+ const ev = {
54703
+ id: randomUUID(),
54704
+ deviceId: input.deviceId,
54705
+ timestamp: input.timestamp,
54706
+ kind: "audio",
54707
+ rms: input.rms,
54708
+ dbfs: input.dbfs,
54709
+ classification: {
54710
+ className: ref.className,
54711
+ ...input.originalClass !== void 0 ? { originalClass: input.originalClass } : {},
54712
+ score: input.score
54713
+ }
54714
+ };
54715
+ this.lastAudioInsertByDevice.set(input.deviceId, {
54716
+ className: ref.className,
54717
+ atMs: input.timestamp
54718
+ });
54719
+ await this.eventStore.insertAudio(ev);
54720
+ this.notificationCenter?.onAudioEventPersisted(ev);
54721
+ this.trackStore?.addAudioLabelEpisode(input.deviceId, ref.className, input.score, input.timestamp);
54722
+ this.ctx.eventBus.emit({
54723
+ id: `pa-${ev.id}`,
54724
+ timestamp: new Date(ev.timestamp),
54725
+ source: {
54726
+ type: "addon",
54727
+ id: "pipeline-analytics",
54728
+ addonId: "pipeline-analytics"
54729
+ },
54730
+ category: EventCategory.PipelineAnalyticsDetectionEvent,
54731
+ data: {
54732
+ deviceId: input.deviceId,
54733
+ kind: "audio",
54734
+ eventId: ev.id,
54735
+ timestamp: ev.timestamp
54736
+ }
54737
+ });
54738
+ if (this.hasLiveVisualTrack(input.deviceId, input.timestamp)) return;
54739
+ const materializer = this.syntheticTracks;
54740
+ if (materializer === null) return;
54741
+ try {
54742
+ const track = await materializer.materialize({
54743
+ cameraId: input.deviceId,
54744
+ sourceDeviceId: input.deviceId,
54745
+ source: "audio",
54746
+ kind: ref.className,
54747
+ timestamp: input.timestamp,
54748
+ audioLabels: [{
54749
+ label: ref.className,
54750
+ peakScore: input.score,
54751
+ count: 1,
54752
+ firstAt: input.timestamp,
54753
+ lastAt: input.timestamp
54754
+ }]
54755
+ });
54756
+ if (track) {
54757
+ this.audioEpisodeTracks.set(ref.episodeId, track);
54758
+ this.audioEpisodeLastPersistMs.set(ref.episodeId, input.timestamp);
54759
+ }
54760
+ } catch (err) {
54761
+ this.ctx.logger.warn("audio-episode synthetic track failed", {
54762
+ tags: { deviceId: input.deviceId },
54763
+ meta: {
54764
+ className: ref.className,
54765
+ error: errMsg(err)
54766
+ }
54767
+ });
54768
+ }
54769
+ }
54770
+ async holdAudioEpisode(input, ref) {
54771
+ await this.persistAudioEpisodeLastSeen(ref, input.timestamp, false);
54772
+ }
54773
+ async persistAudioEpisodeLastSeen(ref, timestamp, force) {
54774
+ const track = this.audioEpisodeTracks.get(ref.episodeId);
54775
+ if (!track) {
54776
+ if (force) {
54777
+ this.audioEpisodeTracks.delete(ref.episodeId);
54778
+ this.audioEpisodeLastPersistMs.delete(ref.episodeId);
54779
+ }
54780
+ return;
54781
+ }
54782
+ const last = this.audioEpisodeLastPersistMs.get(ref.episodeId) ?? 0;
54783
+ if (!force && timestamp - last < AUDIO_EPISODE_PERSIST_MS) return;
54784
+ const updated = {
54785
+ ...track,
54786
+ lastSeen: timestamp
54787
+ };
54788
+ this.audioEpisodeTracks.set(ref.episodeId, updated);
54789
+ this.audioEpisodeLastPersistMs.set(ref.episodeId, timestamp);
54790
+ try {
54791
+ await this.trackStore?.persistSyntheticTrack(updated);
54792
+ } catch (err) {
54793
+ this.ctx.logger.warn("audio-episode lastSeen persist failed", {
54794
+ tags: { deviceId: track.deviceId },
54795
+ meta: {
54796
+ episodeId: ref.episodeId,
54797
+ error: errMsg(err)
54798
+ }
54799
+ });
54800
+ }
54801
+ if (force) {
54802
+ this.audioEpisodeTracks.delete(ref.episodeId);
54803
+ this.audioEpisodeLastPersistMs.delete(ref.episodeId);
54804
+ }
54805
+ }
54162
54806
  async handleAudioResult(data) {
54163
54807
  if (this.shuttingDown) return;
54164
54808
  const { deviceId, frame } = data;
@@ -54202,48 +54846,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54202
54846
  });
54203
54847
  return;
54204
54848
  }
54205
- const last = this.lastAudioInsertByDevice.get(deviceId);
54206
- const sameClass = last?.className === route.className;
54207
- const heartbeatDue = !last || timestamp - last.atMs >= AUDIO_EVENT_HEARTBEAT_MS;
54208
- if (sameClass && !heartbeatDue) return;
54209
- const ev = {
54210
- id: randomUUID(),
54849
+ await this.driveAudioEpisode({
54211
54850
  deviceId,
54212
54851
  timestamp,
54213
- kind: "audio",
54852
+ className: route.className,
54853
+ score: topClassification.score,
54854
+ originalClass: topClassification.debug?.originalClass,
54214
54855
  rms: level?.rms ?? 0,
54215
54856
  dbfs: level?.dbfs ?? 0,
54216
- classification: {
54217
- className: topClassification.macroClass,
54218
- ...topClassification.debug?.originalClass !== void 0 ? { originalClass: topClassification.debug.originalClass } : {},
54219
- score: topClassification.score
54220
- }
54221
- };
54222
- this.lastAudioInsertByDevice.set(deviceId, {
54223
- className: route.className,
54224
- atMs: timestamp
54225
- });
54226
- await this.eventStore.insertAudio(ev);
54227
- this.notificationCenter?.onAudioEventPersisted(ev);
54228
- this.trackStore?.addAudioLabelEpisode(deviceId, route.className, topClassification.score, timestamp);
54229
- this.ctx.eventBus.emit({
54230
- id: `pa-${ev.id}`,
54231
- timestamp: new Date(ev.timestamp),
54232
- source: {
54233
- type: "addon",
54234
- id: "pipeline-analytics",
54235
- addonId: "pipeline-analytics"
54236
- },
54237
- category: EventCategory.PipelineAnalyticsDetectionEvent,
54238
- data: {
54239
- deviceId,
54240
- kind: "audio",
54241
- eventId: ev.id,
54242
- timestamp: ev.timestamp
54243
- }
54857
+ knobs: this.audioEpisodeKnobs(settings)
54244
54858
  });
54245
54859
  return;
54246
54860
  }
54861
+ await this.driveAudioEpisode({
54862
+ deviceId,
54863
+ timestamp,
54864
+ className: null,
54865
+ score: 0,
54866
+ rms: level?.rms ?? 0,
54867
+ dbfs: level?.dbfs ?? 0,
54868
+ knobs: this.audioEpisodeKnobs(settings)
54869
+ });
54247
54870
  const res = observeLevel(this.levelStateByDevice.get(deviceId) ?? emptyLevelState(), {
54248
54871
  timestampMs: timestamp,
54249
54872
  dbfs: level?.dbfs ?? -Infinity