@camstack/addon-post-analysis 1.2.167 → 1.2.169

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 buildEventKindDescriptor, A as NcSnoozeSuppressedSchema, At as DeviceType, B as SCENE_DIVERGED, Bt as literal, C as NcRuleInputSchema, Ct as subKindsOf, D as NcScheduleSchema, Dt as zoneAnalyticsCapability, E as NcRuleTargetSchema, Et as videoclipsCapability, F as RECORDING_EXPORT_MAX_READ_BYTES, Ft as sleep$1, G as TimelapseRuleSchema, Gt as string, H as TIMELAPSE_DENSE_FLOOR_SEC, Ht as object, I as RetrainStatusSchema, It as _enum, J as addonWidgetsSourceCapability, K as TrackSourceSchema, Kt as unknown, Lt as array, M as NcTaxonomySchema, Mt as hydrateSchema, N as OpsLogEntrySchema, Nt as isDeviceScopedCap, O as NcSnoozeInputSchema, Ot as errMsg, Pt as nodePin, Q as audioModeOf, R as SCENE_DEFAULT_ANCHOR_THRESHOLD, Rt as boolean, S as NcConditionDescriptorSchema, St as sceneMonitorCapability, T as NcRuleSchema, Tt as vectorDimFromBase64, U as TimelapseRuleInputSchema, Ut as partialRecord, V as SceneMonitorSchema, Vt as number, W as TimelapseRulePatchSchema, Wt as record, X as assertTimelapseCadences, Y as alarmPanelCapability, Z as audioMetricsCapability, _ as NC_ALARM_SYSTEM_EVENT_KINDS, _t as pipelineAnalyticsCapability, at as encodeVectorBase64, bt as readTimelapseGeneratedAt, ct as failureContributionCapability, d as EVENT_PAD_MS, dt as isScheduleActive, et as cosineSimilarity$1, f as FULL_IMAGE_BBOX, ft as isSourceCap, g as MediaFileKindEnum, gt as pickClusterStepModels, h as MACRO_LABELS, i as DEFAULT_EVENT_COLOR, j as NcSystemEventKindSchema, jt as createEvent, k as NcSnoozeSchema, kt as BaseAddon, l as DeclaredDevices, m as LabelAttributionSchema, mt as notificationRulesCapability, n as CLUSTER_MODEL_SCOPED_STEPS, nt as defineCustomActions, ot as evaluateSensorEdge, p as FailureCounters, pt as kebabToCamel, q as VISIT_MERGE_GAP_MS, qt as EventCategory, r as COCO_TO_MACRO, rt as deriveRecordingMode, s as DETECTION_MACRO_CLASSES, st as faceGalleryCapability, t as BaseDevice, tt as customAction, u as EVENT_KIND_BY_CAP, ut as isDetectionMacroClass, v as NC_CONDITION_CATALOG, vt as plateGalleryCapability, w as NcRulePatchSchema, wt as systemEventFilterApplies, x as NC_TAXONOMY, y as NC_DEFAULT_SNOOZE_MINUTES, yt as readDeviceStateFrom, z as SCENE_DEFAULT_UNCOVERED_POLICY, zt as discriminatedUnion } from "../dist-DgOc1kH7.mjs";
1
+ import { $ as audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as boolean, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as isDeviceScopedCap, G as TimelapseRulePatchSchema, Gt as partialRecord, H as SceneMonitorSchema, Ht as literal, I as RECORDING_EXPORT_MAX_READ_BYTES, It as nodePin, J as VISIT_MERGE_GAP_MS, Jt as unknown, K as TimelapseRuleSchema, Kt as record, L as RetrainStatusSchema, Lt as sleep$1, M as NcSystemEventKindSchema, Mt as DeviceType, N as NcTaxonomySchema, Nt as createEvent, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as hydrateSchema, Q as audioMetricsCapability, Rt as _enum, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as number, V as SCENE_DIVERGED, Vt as discriminatedUnion, W as TimelapseRuleInputSchema, Wt as object, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Yt as EventCategory, Z as assertTimelapseCadences, _ as MediaFileKindEnum, _t as pickClusterStepModels, a as DEFAULT_EVENT_COLOR, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, c as DETECTION_MACRO_CLASSES, ct as faceGalleryCapability, d as EVENT_KIND_BY_CAP, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_PAD_MS, ft as isScheduleActive, g as MACRO_LABELS, h as LabelAttributionSchema, ht as notificationRulesCapability, i as COCO_TO_MACRO, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as CamProfileSchema, k as NcSnoozeInputSchema, kt as errMsg, lt as failureContributionCapability, m as FailureCounters, mt as kebabToCamel, n as BaseDevice, nt as customAction, ot as encodeVectorBase64, p as FULL_IMAGE_BBOX, pt as isSourceCap, q as TrackSourceSchema, qt as string, r as CLUSTER_MODEL_SCOPED_STEPS, rt as defineCustomActions, st as evaluateSensorEdge, t as AUDIO_MACRO_LABELS, tt as cosineSimilarity$1, u as DeclaredDevices, v as NC_ALARM_SYSTEM_EVENT_KINDS, vt as pipelineAnalyticsCapability, w as NcRuleInputSchema, wt as subKindsOf, xt as readTimelapseGeneratedAt, y as NC_CONDITION_CATALOG, yt as plateGalleryCapability, z as SCENE_DEFAULT_ANCHOR_THRESHOLD, zt as array } from "../dist-Cubb8Avj.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";
@@ -3132,7 +3132,8 @@ var LlmVisionJudge = class {
3132
3132
  prompt: call.prompt,
3133
3133
  images,
3134
3134
  jsonSchema: call.jsonSchema,
3135
- temperature: 0
3135
+ temperature: 0,
3136
+ timeoutMs: call.timeoutMs
3136
3137
  }).catch((err) => ({
3137
3138
  ok: false,
3138
3139
  message: String(err)
@@ -19683,8 +19684,37 @@ var TimelapseRenderError = class extends Error {
19683
19684
  this.reason = reason;
19684
19685
  }
19685
19686
  };
19686
- /** The profile with the most footage over the window; ties broken by name so
19687
- * the same window always renders from the same source. */
19687
+ /**
19688
+ * Profiles from cheapest to dearest, so the source is chosen by COST.
19689
+ *
19690
+ * `CamProfileSchema` is declared dearest-first; this is that order reversed.
19691
+ * A name the enum does not know sorts after all of them, alphabetically, so the
19692
+ * choice stays deterministic for a fleet with a bespoke profile.
19693
+ */
19694
+ var PROFILE_BY_COST = [...CamProfileSchema.options].reverse();
19695
+ function profileCost(profile) {
19696
+ const i = PROFILE_BY_COST.indexOf(profile);
19697
+ return i === -1 ? PROFILE_BY_COST.length : i;
19698
+ }
19699
+ /**
19700
+ * The CHEAPEST profile that has footage over the window.
19701
+ *
19702
+ * It used to be the profile with the MOST coverage, ties broken by name — and
19703
+ * that is a rule which picks `high` almost every time, because the high band is
19704
+ * the one that records continuously. On 2026-09-03 it did: a test render of a
19705
+ * ten-hour window chose `profile=high` at 97% coverage, which on this hub means
19706
+ * 4K read from a USB spinning disk. Decoding it made the machine stop answering
19707
+ * SSH and its own web UI for half an hour, and it had to be power-cycled.
19708
+ *
19709
+ * A timelapse is watched accelerated. Resolution buys nothing at 12 fps over a
19710
+ * night, and the cost difference between the bands is one to two orders of
19711
+ * magnitude in both bytes read and frames decoded. So there is no trade to
19712
+ * weigh: take the cheapest band that has anything, every time.
19713
+ *
19714
+ * Coverage no longer decides WHICH — only whether a band qualifies at all. A
19715
+ * band with less footage is still the right source; the gaps show as a shorter
19716
+ * video, not as a reason to read twenty times the bytes.
19717
+ */
19688
19718
  function pickProfile(ranges, window) {
19689
19719
  const byProfile = /* @__PURE__ */ new Map();
19690
19720
  for (const r of ranges) {
@@ -19692,15 +19722,15 @@ function pickProfile(ranges, window) {
19692
19722
  if (list === void 0) byProfile.set(r.profile, [r]);
19693
19723
  else list.push(r);
19694
19724
  }
19695
- let best = null;
19696
- for (const [profile, rows] of [...byProfile].sort((a, b) => a[0].localeCompare(b[0]))) {
19725
+ const ordered = [...byProfile].sort((a, b) => profileCost(a[0]) - profileCost(b[0]) || a[0].localeCompare(b[0]));
19726
+ for (const [profile, rows] of ordered) {
19697
19727
  const coverage = footageCoverage(rows, window.startMs, window.endMs);
19698
- if (best === null || coverage.coveredMs > best.coverage.coveredMs) best = {
19728
+ if (coverage.coveredMs > 0) return {
19699
19729
  profile,
19700
19730
  coverage
19701
19731
  };
19702
19732
  }
19703
- return best;
19733
+ return null;
19704
19734
  }
19705
19735
  /**
19706
19736
  * Is `record` the render this request is about to order?
@@ -27232,9 +27262,29 @@ async function renderTimelapseGif(ports, request) {
27232
27262
  }
27233
27263
  //#endregion
27234
27264
  //#region src/shared/frame/square-subject-crop.ts
27235
- /** Expansion factor applied to the bbox long side to frame the subject with a
27236
- * little breathing room (operator choice: ×1.2). */
27237
- var SQUARE_SUBJECT_CROP_EXPANSION = 1.2;
27265
+ /**
27266
+ * Expansion applied to the bbox long side to frame the subject with a little
27267
+ * breathing room.
27268
+ *
27269
+ * ×1.2 → ×1.05 (operator, 2026-09-03: "riduci e proviamo"). The complaint was
27270
+ * that a best shot carries too much surrounding scene, and the measurement says
27271
+ * where it goes. On 26 148 person samples from camera 617 the median bbox is
27272
+ * 1.44× taller than wide, so the square is driven by HEIGHT and half its width
27273
+ * is empty before the 16:9 extension adds any:
27274
+ *
27275
+ * subject inside the square 48% (waste 52%) ← this factor
27276
+ * square inside the 16:9 56% (waste 44%)
27277
+ * subject delivered 27%
27278
+ *
27279
+ * A prior analysis called the 16:9 extension the dominant waste. For a standing
27280
+ * person it is not: squaring costs more, because it pays for the height twice.
27281
+ * At ×1.05 the subject holds 63% of the square and ~35% of the canvas.
27282
+ *
27283
+ * The floor is 1.0, not lower: the square must CONTAIN the bbox, and detector
27284
+ * boxes hug the visible extent, so some margin is what keeps hair and feet in
27285
+ * shot when the box is a little tight.
27286
+ */
27287
+ var SQUARE_SUBJECT_CROP_EXPANSION = 1.05;
27238
27288
  /**
27239
27289
  * Compute a SQUARE crop region in PIXEL space around the subject bbox.
27240
27290
  *
@@ -33155,6 +33205,9 @@ function isDecisiveImprovement(current, candidate) {
33155
33205
  if (!isSquareContained(candidate) && isSquareContained(current)) return false;
33156
33206
  if (!isSemantic(candidate) && isSemantic(current)) return false;
33157
33207
  if (!candidate.edgeClear && current.edgeClear) return false;
33208
+ const currentSize = current.sizeScore;
33209
+ const candidateSize = candidate.sizeScore;
33210
+ if (currentSize !== void 0 && candidateSize !== void 0 && currentSize > 0 && candidateSize >= currentSize * 1.25) return true;
33158
33211
  return effectiveScore(candidate) >= effectiveScore(current) * DECISIVE_IMPROVEMENT_RATIO;
33159
33212
  }
33160
33213
  /**
@@ -36601,19 +36654,19 @@ var BEST_FACE_POLICY = {
36601
36654
  semanticAware: false
36602
36655
  };
36603
36656
  /**
36604
- * Best OBJECT (CLIP) embedding + its tight crop — confidence-only in-memory
36657
+ * Best OBJECT (CLIP) embedding + its tight crop — the in-memory
36605
36658
  * DECISION (which live frame the embedding row + crop derive from). The
36606
36659
  * durable, cross-restart "if better" gate stays DOWNSTREAM in
36607
36660
  * `ObjectEmbeddingStore.upsertIfBetter` (the documented seam: this selector
36608
- * resets on addon restart, the store row does not). Same values as
36609
- * {@link BEST_FACE_POLICY} today, but a SEPARATE named policy: the two paths
36610
- * may drift apart deliberately without one masquerading as the other.
36661
+ * resets on addon restart, the store row does not). A SEPARATE named policy
36662
+ * from the face path: the two may drift apart deliberately without one
36663
+ * masquerading as the other.
36611
36664
  */
36612
36665
  var BEST_OBJECT_EMBED_POLICY = {
36613
36666
  hysteresis: 0,
36614
36667
  minGapMs: 0,
36615
- edgeAware: false,
36616
- sizeAware: false,
36668
+ edgeAware: true,
36669
+ sizeAware: true,
36617
36670
  semanticAware: false
36618
36671
  };
36619
36672
  /**
@@ -39412,6 +39465,12 @@ var TrackStore = class {
39412
39465
  upsert(params) {
39413
39466
  const existing = this.active.get(params.trackId);
39414
39467
  if (existing) {
39468
+ if (params.matched === false) {
39469
+ existing.state = params.state;
39470
+ for (const z of params.zones) if (!existing.zonesVisited.includes(z)) existing.zonesVisited.push(z);
39471
+ if (!existing.classes.includes(params.className)) existing.classes.push(params.className);
39472
+ return existing;
39473
+ }
39415
39474
  const last = existing.positions[existing.positions.length - 1];
39416
39475
  const dist = last ? Math.sqrt((params.position.x - last.x) ** 2 + (params.position.y - last.y) ** 2) : 0;
39417
39476
  existing.lastSeen = params.timestamp;
@@ -43911,6 +43970,36 @@ var AnalyticsQueryFacade = class {
43911
43970
  //#region src/pipeline-analytics/audio-detection-settings.ts
43912
43971
  var AudioDetectionSettingsSchema = object({
43913
43972
  classificationMinScore: number().min(0).max(1).default(.6),
43973
+ /**
43974
+ * Macro classes allowed to produce an audio EVENT on this camera. Empty =
43975
+ * no policy, every class passes — so an upgrade changes nothing until an
43976
+ * operator states a policy.
43977
+ *
43978
+ * This belongs to the event generator, NOT to the classifier: the analyzer
43979
+ * keeps classifying everything it hears, `audioLabels` on the track keeps
43980
+ * carrying the full picture, and only the decision to MATERIALIZE an event
43981
+ * is narrowed. Raising a threshold inside the classifier would instead make
43982
+ * the sound invisible to every consumer at once.
43983
+ */
43984
+ audioAllowedClasses: array(string()).default([]),
43985
+ /**
43986
+ * Corroboration window on the EVENT, per class: this many occurrences of
43987
+ * the same sound must land inside `audioConfirmWindowSec` before it becomes
43988
+ * an event. `1` = no window.
43989
+ *
43990
+ * Default 2-in-10s, set by the operator on 2026-09-03 after measuring 26
43991
+ * audio tracks in 24 h: the single-frame classification is the dominant
43992
+ * false positive, and a sound that is real is heard again within ten
43993
+ * seconds. A camera that wants every occurrence sets `1`.
43994
+ *
43995
+ * The Notification Center already has this exact brake for audio RULES
43996
+ * (`confirmHits` / `confirmWindowSec`). This is the same idea one level
43997
+ * earlier, so a camera can stop MAKING the single-frame events instead of
43998
+ * only declining to notify about them.
43999
+ */
44000
+ audioConfirmHits: number().int().min(1).default(2),
44001
+ /** Width of the corroboration window. Ignored when `audioConfirmHits` is 1. */
44002
+ audioConfirmWindowSec: number().positive().default(10),
43914
44003
  levelDeviationDb: number().positive().default(10),
43915
44004
  levelWindowSec: number().positive().default(10),
43916
44005
  levelMaxWaitSec: number().positive().default(20),
@@ -43951,8 +44040,17 @@ var AudioDetectionSettingsSchema = object({
43951
44040
  audioEpisodeOpenHoldSec: number().nonnegative().default(1.5),
43952
44041
  /** Class must be absent this long before the episode closes. */
43953
44042
  audioEpisodeCloseGapSec: number().positive().default(12),
43954
- /** After close, the same class cannot reopen until this elapses. */
43955
- audioEpisodeCooldownSec: number().nonnegative().default(30)
44043
+ /** After close, the same class cannot reopen until this elapses.
44044
+ *
44045
+ * This is the minimum spacing between two SYNTHETIC EVENTS (the episode's
44046
+ * event + timeline track) of the same sound on one camera — NOT a
44047
+ * notification floor. Notification spacing is the rule's own
44048
+ * `throttle.cooldownSec`, which already defaults to 60 s and is keyed
44049
+ * per-device; the two are independent brakes on different objects.
44050
+ *
44051
+ * Raised 30 → 60 s on 2026-09-03 by the operator, alongside the 2-in-10s
44052
+ * corroboration window above. */
44053
+ audioEpisodeCooldownSec: number().nonnegative().default(60)
43956
44054
  });
43957
44055
  var AUDIO_DETECTION_DEFAULTS = AudioDetectionSettingsSchema.parse({});
43958
44056
  /**
@@ -44129,14 +44227,45 @@ function observeAudioEpisode(state, sample, knobs) {
44129
44227
  };
44130
44228
  }
44131
44229
  //#endregion
44230
+ //#region src/pipeline-analytics/audio-confirm-window.ts
44231
+ function emptyAudioConfirmState() {
44232
+ return { hitsByClass: /* @__PURE__ */ new Map() };
44233
+ }
44234
+ function observeAudioConfirm(state, input, cfg) {
44235
+ if (cfg.confirmHits <= 1) return {
44236
+ confirmed: true,
44237
+ nextState: state
44238
+ };
44239
+ const cutoff = input.timestampMs - cfg.confirmWindowSec * 1e3;
44240
+ const kept = [...(state.hitsByClass.get(input.className) ?? []).filter((at) => at > cutoff), input.timestampMs];
44241
+ const next = new Map(state.hitsByClass);
44242
+ if (kept.length < cfg.confirmHits) {
44243
+ next.set(input.className, kept);
44244
+ return {
44245
+ confirmed: false,
44246
+ nextState: { hitsByClass: next }
44247
+ };
44248
+ }
44249
+ next.delete(input.className);
44250
+ return {
44251
+ confirmed: true,
44252
+ nextState: { hitsByClass: next }
44253
+ };
44254
+ }
44255
+ //#endregion
44132
44256
  //#region src/pipeline-analytics/audio-event-router.ts
44133
44257
  /**
44134
44258
  * Decide whether an audio frame is a (meaningful) classification event or a
44135
44259
  * level event. `silence` is never a classification — it falls to the level
44136
44260
  * path so only a genuine loudness peak/heartbeat can surface it.
44261
+ *
44262
+ * A class the operator did not allow takes the SAME route as a class that
44263
+ * failed the score: the level path. Both mean "not a classification event on
44264
+ * this camera", and giving the allowlist its own harder outcome (a silent
44265
+ * drop) would be a second arithmetic for one question.
44137
44266
  */
44138
44267
  function classifyAudioFrame(top, cfg) {
44139
- if (top && top.className !== "silence" && top.score >= cfg.classificationMinScore) return {
44268
+ if ((cfg.allowedClasses.length === 0 || cfg.allowedClasses.includes(top?.className ?? "")) && top && top.className !== "silence" && top.score >= cfg.classificationMinScore) return {
44140
44269
  kind: "classification",
44141
44270
  className: top.className,
44142
44271
  score: top.score
@@ -59283,6 +59412,81 @@ function hasWrappedDetection(entries) {
59283
59412
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
59284
59413
  }
59285
59414
  //#endregion
59415
+ //#region src/pipeline-analytics/runtime/native-crop-dedupe.ts
59416
+ var NativeCropDedupe = class {
59417
+ config;
59418
+ entries = /* @__PURE__ */ new Map();
59419
+ fetched = 0;
59420
+ coalesced = 0;
59421
+ reused = 0;
59422
+ /**
59423
+ * What this actually saved, since the last read.
59424
+ *
59425
+ * Shipped WITH the optimisation, not after it: without a counter the only
59426
+ * evidence available is the process's RSS, which is allocator residue that
59427
+ * ratchets within a boot and cannot answer "did the dedupe fire". An
59428
+ * optimisation nobody can measure is a claim, not a fix.
59429
+ */
59430
+ drainCounts() {
59431
+ const counts = {
59432
+ fetched: this.fetched,
59433
+ coalesced: this.coalesced,
59434
+ reused: this.reused
59435
+ };
59436
+ this.fetched = 0;
59437
+ this.coalesced = 0;
59438
+ this.reused = 0;
59439
+ return counts;
59440
+ }
59441
+ constructor(config) {
59442
+ this.config = config;
59443
+ }
59444
+ /** Retained entries — for the bound's own test, and for metrics. */
59445
+ size() {
59446
+ return this.entries.size;
59447
+ }
59448
+ async run(handle, region, maxWidth, fetch) {
59449
+ const key = `${handle.shmId}#${handle.slot}#${handle.seq}|${region.x},${region.y},${region.w},${region.h}|${maxWidth ?? "full"}`;
59450
+ const existing = this.entries.get(key);
59451
+ if (existing !== void 0) if (existing.value !== void 0 && existing.at !== void 0) {
59452
+ if (this.config.now() - existing.at < this.config.ttlMs) {
59453
+ this.reused += 1;
59454
+ return existing.value;
59455
+ }
59456
+ this.entries.delete(key);
59457
+ } else {
59458
+ this.coalesced += 1;
59459
+ return existing.pending;
59460
+ }
59461
+ this.fetched += 1;
59462
+ const pending = fetch();
59463
+ const entry = { pending };
59464
+ this.entries.set(key, entry);
59465
+ try {
59466
+ const result = await pending;
59467
+ if (result === null) {
59468
+ this.entries.delete(key);
59469
+ return null;
59470
+ }
59471
+ entry.value = result;
59472
+ entry.at = this.config.now();
59473
+ this.evictOverflow();
59474
+ return result;
59475
+ } catch (err) {
59476
+ this.entries.delete(key);
59477
+ throw err;
59478
+ }
59479
+ }
59480
+ /** Oldest-first, and only ever over the ceiling. */
59481
+ evictOverflow() {
59482
+ while (this.entries.size > this.config.maxEntries) {
59483
+ const oldest = this.entries.keys().next();
59484
+ if (oldest.done === true) return;
59485
+ this.entries.delete(oldest.value);
59486
+ }
59487
+ }
59488
+ };
59489
+ //#endregion
59286
59490
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
59287
59491
  function createCaptureCrop(deps) {
59288
59492
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -59463,9 +59667,13 @@ function createNativeFrameTransport(deps) {
59463
59667
  const now = Date.now();
59464
59668
  if (now - lastCropMetricAt < NATIVE_CROP_METRIC_INTERVAL_MS) return;
59465
59669
  lastCropMetricAt = now;
59670
+ const dedupe = cropDedupe.drainCounts();
59466
59671
  cropMetricLogger.info("native-crop window", { meta: {
59467
59672
  nativeHits,
59468
- detectionFrameFallbacks: nativeFallbacks
59673
+ detectionFrameFallbacks: nativeFallbacks,
59674
+ wireFetches: dedupe.fetched,
59675
+ coalesced: dedupe.coalesced,
59676
+ reusedInWindow: dedupe.reused
59469
59677
  } });
59470
59678
  };
59471
59679
  const requestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
@@ -59479,6 +59687,16 @@ function createNativeFrameTransport(deps) {
59479
59687
  if (!native || native.width <= 0 || native.height <= 0) return null;
59480
59688
  return await normalize(native);
59481
59689
  };
59690
+ /**
59691
+ * Bounded and brief on purpose. Coalescing costs nothing; the 1.5 s window
59692
+ * holds at most 12 crops in THIS process to keep far more than that off
59693
+ * hub-main. It must never grow into the memory it was written to save.
59694
+ */
59695
+ const cropDedupe = new NativeCropDedupe({
59696
+ now: () => Date.now(),
59697
+ ttlMs: 1500,
59698
+ maxEntries: 12
59699
+ });
59482
59700
  const tryRequestNativeCrop = async (frameHandle, bbox, maxWidth, normalize) => {
59483
59701
  try {
59484
59702
  return await requestNativeCrop(frameHandle, bbox, maxWidth, normalize);
@@ -59487,7 +59705,7 @@ function createNativeFrameTransport(deps) {
59487
59705
  return null;
59488
59706
  }
59489
59707
  };
59490
- const fetchNativeCropJpeg = async (frameHandle, paddedNorm, maxWidth) => tryRequestNativeCrop(frameHandle, paddedNorm, maxWidth, cropReplyToJpeg);
59708
+ const fetchNativeCropJpeg = async (frameHandle, paddedNorm, maxWidth) => cropDedupe.run(frameHandle, paddedNorm, maxWidth, () => tryRequestNativeCrop(frameHandle, paddedNorm, maxWidth, cropReplyToJpeg));
59491
59709
  const tryNativeCrop = async (frameHandle, paddedNorm, maxWidth) => {
59492
59710
  return (await fetchNativeCropJpeg(frameHandle, paddedNorm, maxWidth))?.jpeg ?? null;
59493
59711
  };
@@ -61167,8 +61385,15 @@ var EventMediaDispatcher = class {
61167
61385
  * OWN box burned onto the NATIVE full frame downscaled to
61168
61386
  * {@link SNAPSHOT_MAX_WIDTH}. On a native miss it falls back to the ≤640
61169
61387
  * detection frame (`frameData`) downscaled to the same cap with a `logger.warn`
61170
- * — an honest LOWER-res frame, NEVER an upscale. Returns the JPEG, or `null`
61171
- * only if the sharp encode itself throws.
61388
+ * — an honest LOWER-res frame, NEVER an upscale.
61389
+ *
61390
+ * Returns BOTH renderings of the same surface: the boxed tile the timeline
61391
+ * wants, and its CLEAN twin (`drawBoxedFrame(…, [])` — encode only, the same
61392
+ * call `keyFrame` uses). One extra encode, no second fetch and no second
61393
+ * decode, because the notification ladder needs a densely-written whole-scene
61394
+ * frame WITHOUT the rectangle and there was none: 19 of 38 measured
61395
+ * `frame:'full'` deliveries carried the box. `null` only if a sharp encode
61396
+ * throws.
61172
61397
  */
61173
61398
  async boxedTimelineFrame(deviceId, frameHandle, frameData, fw, fh, ownerId, bbox, label) {
61174
61399
  let nf = null;
@@ -63002,6 +63227,36 @@ function buildGlobalSettingsSchema(cap) {
63002
63227
  step: .05,
63003
63228
  default: AUDIO_DETECTION_DEFAULTS.classificationMinScore
63004
63229
  },
63230
+ {
63231
+ type: "multiselect",
63232
+ key: "audioAllowedClasses",
63233
+ label: "Event classes",
63234
+ description: "Which sounds may become an event on this camera. Leave empty to allow every class. The classifier is untouched — it keeps hearing and labelling everything; this only narrows what is worth an event and a notification here.",
63235
+ options: AUDIO_MACRO_LABELS.filter((l) => l.id !== "silence").map((l) => ({
63236
+ value: l.id,
63237
+ label: l.name
63238
+ })),
63239
+ default: AUDIO_DETECTION_DEFAULTS.audioAllowedClasses
63240
+ },
63241
+ {
63242
+ type: "number",
63243
+ key: "audioConfirmHits",
63244
+ label: "Occurrences to confirm",
63245
+ description: "How many times the same sound must be heard inside the window below before it becomes an event. 1 disables the window — every occurrence is an event.",
63246
+ min: 1,
63247
+ step: 1,
63248
+ default: AUDIO_DETECTION_DEFAULTS.audioConfirmHits
63249
+ },
63250
+ {
63251
+ type: "number",
63252
+ key: "audioConfirmWindowSec",
63253
+ label: "Confirmation window",
63254
+ description: "Width of the corroboration window. Occurrences are counted per class, so two different sounds never confirm each other. Ignored when occurrences is 1.",
63255
+ min: 1,
63256
+ step: 1,
63257
+ default: AUDIO_DETECTION_DEFAULTS.audioConfirmWindowSec,
63258
+ unit: "s"
63259
+ },
63005
63260
  {
63006
63261
  type: "number",
63007
63262
  key: "levelDeviationDb",
@@ -65848,6 +66103,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65848
66103
  lastAudioInsertByDevice = /* @__PURE__ */ new Map();
65849
66104
  lastMotionInsertByDevice = /* @__PURE__ */ new Map();
65850
66105
  levelStateByDevice = /* @__PURE__ */ new Map();
66106
+ /** Per-device corroboration window for classified audio (see
66107
+ * `audio-confirm-window.ts`). Torn down wherever `levelStateByDevice` is —
66108
+ * a stale window on a removed device would confirm on its first frame back. */
66109
+ audioConfirmByDevice = /* @__PURE__ */ new Map();
65851
66110
  /**
65852
66111
  * Last ms a spatial track was alive on a device, any plane. Written on the
65853
66112
  * frame path (one `Map.set` per processed frame), read O(1) by the audio-
@@ -67362,7 +67621,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67362
67621
  mimeType: i.mimeType
67363
67622
  })),
67364
67623
  jsonSchema: request.jsonSchema,
67365
- temperature: request.temperature
67624
+ temperature: request.temperature,
67625
+ ...request.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
67366
67626
  });
67367
67627
  return result.ok ? {
67368
67628
  ok: true,
@@ -68268,6 +68528,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68268
68528
  this.overlaySynthesisWarnAt.delete(data.deviceId);
68269
68529
  this.forgetDeviceProcessors(data.deviceId);
68270
68530
  this.levelStateByDevice.delete(data.deviceId);
68531
+ this.audioConfirmByDevice.delete(data.deviceId);
68271
68532
  this.settingsCaches.clearDevice(data.deviceId);
68272
68533
  this.rawDeviceBlobMirror.invalidate(data.deviceId);
68273
68534
  }
@@ -68286,6 +68547,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68286
68547
  this.overlaySynthesisWarnAt.delete(deviceId);
68287
68548
  this.forgetDeviceProcessors(deviceId);
68288
68549
  this.levelStateByDevice.delete(deviceId);
68550
+ this.audioConfirmByDevice.delete(deviceId);
68289
68551
  this.lastTrackActivityMs.delete(deviceId);
68290
68552
  this.motionEventSnapshots?.forgetDevice(deviceId);
68291
68553
  this.audioEpisodeByDevice.delete(deviceId);
@@ -68502,7 +68764,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68502
68764
  mimeType: image.mimeType
68503
68765
  })),
68504
68766
  jsonSchema: i.jsonSchema,
68505
- temperature: i.temperature
68767
+ temperature: i.temperature,
68768
+ ...i.timeoutMs !== void 0 ? { timeoutMs: i.timeoutMs } : {}
68506
68769
  }),
68507
68770
  cancelVision: async (requestId) => {
68508
68771
  await api.llm.cancel.mutate({ requestId });
@@ -68797,6 +69060,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68797
69060
  this.nodeLiveness.clear();
68798
69061
  this.gatedNodeByDevice.clear();
68799
69062
  this.levelStateByDevice.clear();
69063
+ this.audioConfirmByDevice.clear();
68800
69064
  this.settingsCaches.clearAll();
68801
69065
  this.rawDeviceBlobMirror.invalidateAll();
68802
69066
  this.rawGlobalStoreMirror.invalidate();
@@ -68964,6 +69228,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
68964
69228
  trackId: t.trackId,
68965
69229
  deviceId,
68966
69230
  className: t.className,
69231
+ matched: t.matchedThisFrame !== false,
68967
69232
  ...t.labelPatch !== void 0 ? { labelPatch: t.labelPatch } : {},
68968
69233
  timestamp: result.timestamp,
68969
69234
  position: {
@@ -70811,12 +71076,15 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70811
71076
  this.notificationCenter?.observeAudio(deviceId, {
70812
71077
  timestampMs: timestamp,
70813
71078
  ...level?.dbfs !== void 0 ? { dbfs: level.dbfs } : {},
70814
- labels: (frame.detections ?? []).filter((d) => d.score >= settings.classificationMinScore).map((d) => d.macroClass)
71079
+ labels: (frame.detections ?? []).filter((d) => d.score >= settings.classificationMinScore).map((d) => d.macroClass).filter((macroClass) => settings.audioAllowedClasses.length === 0 || settings.audioAllowedClasses.includes(macroClass))
70815
71080
  });
70816
71081
  const route = classifyAudioFrame(topClassification ? {
70817
71082
  className: topClassification.macroClass,
70818
71083
  score: topClassification.score
70819
- } : void 0, { classificationMinScore: settings.classificationMinScore });
71084
+ } : void 0, {
71085
+ classificationMinScore: settings.classificationMinScore,
71086
+ allowedClasses: settings.audioAllowedClasses
71087
+ });
70820
71088
  if (route.kind === "classification") {
70821
71089
  if (!topClassification) {
70822
71090
  this.ctx.logger.warn("classifyAudioFrame returned classification but topClassification is null", {
@@ -70825,6 +71093,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70825
71093
  });
70826
71094
  return;
70827
71095
  }
71096
+ const confirm = observeAudioConfirm(this.audioConfirmByDevice.get(deviceId) ?? emptyAudioConfirmState(), {
71097
+ className: route.className,
71098
+ timestampMs: timestamp
71099
+ }, {
71100
+ confirmHits: settings.audioConfirmHits,
71101
+ confirmWindowSec: settings.audioConfirmWindowSec
71102
+ });
71103
+ this.audioConfirmByDevice.set(deviceId, confirm.nextState);
71104
+ if (!confirm.confirmed) {
71105
+ this.ctx.logger.debug("audio event held for corroboration", {
71106
+ tags: { deviceId },
71107
+ meta: {
71108
+ deviceId,
71109
+ className: route.className,
71110
+ confirmHits: settings.audioConfirmHits,
71111
+ confirmWindowSec: settings.audioConfirmWindowSec
71112
+ }
71113
+ });
71114
+ return;
71115
+ }
70828
71116
  const classified = {
70829
71117
  deviceId,
70830
71118
  timestamp,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CUasEI3s.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D74iweVp.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.167",
3
+ "version": "1.2.169",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",