@camstack/addon-post-analysis 1.1.29 → 1.1.30

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 { S as string, _ as createEvent, b as number, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as EventCategory, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as hydrateSchema, x as object, y as boolean } from "../dist-CFjLqX2m.mjs";
1
+ import { S as EventCategory, _ as hydrateSchema, b as object, c as nodePin, d as videoclipsCapability, f as zoneAnalyticsCapability, g as createEvent, h as DeviceType, i as cosineSimilarity, l as pipelineAnalyticsCapability, m as BaseAddon, n as addonWidgetsSourceCapability, o as faceGalleryCapability, p as errMsg, r as audioMetricsCapability, t as EVENT_PAD_MS, u as plateGalleryCapability, v as boolean, x as string, y as number } from "../dist-yPsKFcJL.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -2496,6 +2496,14 @@ var StationaryObjectRegistry = class {
2496
2496
  count(deviceId) {
2497
2497
  return this.byDevice.get(deviceId)?.size ?? 0;
2498
2498
  }
2499
+ /** Device ids that currently hold at least one parked entry — drives the
2500
+ * occupancy baseline sampler (a detached camera with parked cars still
2501
+ * gets a flat history baseline). */
2502
+ deviceIds() {
2503
+ const ids = [];
2504
+ for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
2505
+ return ids;
2506
+ }
2499
2507
  /** Record that a frame was processed for a device — advances the OBSERVED
2500
2508
  * clock that drives entry expiry in {@link sweep}. */
2501
2509
  noteFrame(deviceId, timestamp) {
@@ -2715,6 +2723,26 @@ function rowToEntry(id, data) {
2715
2723
  };
2716
2724
  }
2717
2725
  //#endregion
2726
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
2727
+ /**
2728
+ * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
2729
+ * Empty when the entry has no frame dims (can't normalise) or no zone matches.
2730
+ */
2731
+ function computeStationaryEntryZones(entry, zones) {
2732
+ if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
2733
+ const centroidPx = bboxCentroid(entry.bbox);
2734
+ const point = {
2735
+ x: centroidPx.x / entry.frameWidth,
2736
+ y: centroidPx.y / entry.frameHeight
2737
+ };
2738
+ const matched = [];
2739
+ for (const zone of zones) {
2740
+ if (zone.polygon.length < 3) continue;
2741
+ if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
2742
+ }
2743
+ return matched;
2744
+ }
2745
+ //#endregion
2718
2746
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2719
2747
  /**
2720
2748
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -5403,6 +5431,17 @@ var RESOLUTION_MS = {
5403
5431
  * latest state is never lost.
5404
5432
  */
5405
5433
  var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
5434
+ /**
5435
+ * Cadence of the synthetic occupancy baseline. When a camera is detached (no
5436
+ * inference frames) but has persisted parked objects, the history ring would
5437
+ * otherwise stay empty and the chart would read "No occupancy history yet". A
5438
+ * device WITH parked entries gets one hydrated sample per this interval — a
5439
+ * flat baseline of the parked count — so the graph shows the parking lot's
5440
+ * standing occupancy instead of a gap. No sample is emitted for a device
5441
+ * without entries, and a real `recordFrame` in the same window suppresses the
5442
+ * baseline (it already appended a richer sample).
5443
+ */
5444
+ var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
5406
5445
  var ZoneAnalyticsProvider = class {
5407
5446
  ctx;
5408
5447
  snapshots = /* @__PURE__ */ new Map();
@@ -5419,8 +5458,17 @@ var ZoneAnalyticsProvider = class {
5419
5458
  /** Last logged frame-wide occupancy total per device — so the occupancy log
5420
5459
  * fires only when the count actually changes, not every inference frame. */
5421
5460
  lastOccupancyTotal = /* @__PURE__ */ new Map();
5461
+ /** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
5462
+ * device with parked objects but no live frames. `null` when disabled. */
5463
+ baselineTimer = null;
5422
5464
  constructor(ctx) {
5423
5465
  this.ctx = ctx;
5466
+ if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
5467
+ this.baselineTimer = setInterval(() => {
5468
+ this.appendBaselineSamples();
5469
+ }, BASELINE_SAMPLE_INTERVAL_MS);
5470
+ this.baselineTimer.unref?.();
5471
+ }
5424
5472
  this.sliceThrottle = new SliceThrottler({
5425
5473
  intervalMs: SLICE_WRITE_INTERVAL_MS$1,
5426
5474
  equalsIgnoringTs: snapshotEqualsIgnoringTs,
@@ -5442,9 +5490,10 @@ var ZoneAnalyticsProvider = class {
5442
5490
  /** Stop pending throttle timers — called from addon shutdown. */
5443
5491
  destroy() {
5444
5492
  this.sliceThrottle.destroy();
5493
+ if (this.baselineTimer) clearInterval(this.baselineTimer);
5445
5494
  }
5446
5495
  async getCurrentSnapshot({ deviceId }) {
5447
- return this.snapshots.get(deviceId) ?? null;
5496
+ return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
5448
5497
  }
5449
5498
  async getZoneHistory(input) {
5450
5499
  return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
@@ -5497,6 +5546,65 @@ var ZoneAnalyticsProvider = class {
5497
5546
  this.lastOccupancyTotal.delete(deviceId);
5498
5547
  this.sliceThrottle.forgetDevice(deviceId);
5499
5548
  }
5549
+ /**
5550
+ * Build an occupancy snapshot for a device purely from its parked-object
5551
+ * registry (no live frame). Returns `null` when hydration is unavailable or
5552
+ * the device has no parked objects — a device with neither frames nor entries
5553
+ * legitimately reports `null`. The snapshot's `ts` is the most recent
5554
+ * `lastConfirmedAt` across entries, falling back to the current tick.
5555
+ */
5556
+ async hydrateFromRegistry(deviceId) {
5557
+ const listStationary = this.ctx.listStationaryObjects;
5558
+ if (!listStationary) return null;
5559
+ const entries = listStationary(deviceId);
5560
+ if (entries.length === 0) return null;
5561
+ let zones = [];
5562
+ try {
5563
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5564
+ } catch (err) {
5565
+ this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
5566
+ tags: { deviceId },
5567
+ meta: { error: err instanceof Error ? err.message : String(err) }
5568
+ });
5569
+ }
5570
+ const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
5571
+ return buildStationarySnapshot({
5572
+ deviceId,
5573
+ entries,
5574
+ zones,
5575
+ timestamp: ts
5576
+ });
5577
+ }
5578
+ /**
5579
+ * Baseline sampler tick: for every device with parked objects, append a
5580
+ * hydrated sample to the history ring at the CURRENT time — but only when a
5581
+ * real frame hasn't already appended a sample within this interval (frames
5582
+ * flowing = richer samples, no synthetic baseline needed).
5583
+ */
5584
+ async appendBaselineSamples() {
5585
+ const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
5586
+ const now = Date.now();
5587
+ for (const deviceId of deviceIds) {
5588
+ const ring = this.history.get(deviceId);
5589
+ if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
5590
+ const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
5591
+ if (entries.length === 0) continue;
5592
+ let zones = [];
5593
+ try {
5594
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5595
+ } catch {}
5596
+ const snapshot = buildStationarySnapshot({
5597
+ deviceId,
5598
+ entries,
5599
+ zones,
5600
+ timestamp: now
5601
+ });
5602
+ if (snapshot) {
5603
+ this.appendHistory(deviceId, snapshot);
5604
+ this.sliceThrottle.push(deviceId, snapshot);
5605
+ }
5606
+ }
5607
+ }
5500
5608
  appendHistory(deviceId, snapshot) {
5501
5609
  const ring = this.history.get(deviceId) ?? [];
5502
5610
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -5587,6 +5695,39 @@ function computeSnapshot(input) {
5587
5695
  ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
5588
5696
  };
5589
5697
  }
5698
+ /** Most recent `lastConfirmedAt` across parked entries (0 when none). */
5699
+ function mostRecentStationaryConfirmedAt(entries) {
5700
+ let max = 0;
5701
+ for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
5702
+ return max;
5703
+ }
5704
+ /**
5705
+ * Build an occupancy snapshot from parked-object registry entries alone —
5706
+ * used when no live inference frame is available (fresh respawn, camera
5707
+ * detached). Each entry is folded into the frame aggregate AND attributed to
5708
+ * the zones its normalised bbox centroid falls inside (via
5709
+ * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
5710
+ * reports a count of 1. Returns `null` for an empty entry list. Reuses
5711
+ * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
5712
+ */
5713
+ function buildStationarySnapshot(input) {
5714
+ if (input.entries.length === 0) return null;
5715
+ const tracked = input.entries.map((e) => ({
5716
+ trackId: `stationary:${e.id}`,
5717
+ className: e.className,
5718
+ zones: computeStationaryEntryZones(e, input.zones)
5719
+ }));
5720
+ const first = input.entries[0];
5721
+ return computeSnapshot({
5722
+ deviceId: input.deviceId,
5723
+ timestamp: input.timestamp,
5724
+ frameWidth: first.frameWidth,
5725
+ frameHeight: first.frameHeight,
5726
+ tracked,
5727
+ zones: input.zones,
5728
+ stationaryObjects: input.entries
5729
+ });
5730
+ }
5590
5731
  //#endregion
5591
5732
  //#region src/pipeline-analytics/audio-metrics-provider.ts
5592
5733
  var AUDIO_METRICS_CAP_NAME = "audio-metrics";
@@ -7664,6 +7805,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
7664
7805
  /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
7665
7806
  var DEFAULT_ONCE_MAX_PER_TRACK = 3;
7666
7807
  /**
7808
+ * Consecutive frame-plane misses ("frame + crop both missed") after which a step
7809
+ * is ABANDONED for the track. The decode worker serves native crops from a RAM
7810
+ * lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
7811
+ * from an evicted handle and is a guaranteed miss forever. Retrying a
7812
+ * permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
7813
+ * consecutive misses (each ≥ one tick apart) confidently means the frame is gone
7814
+ * for good, while still tolerating a single transient decode-worker hiccup /
7815
+ * respawn on a genuinely live track (the counter resets on any resolved result).
7816
+ */
7817
+ var MAX_CONSECUTIVE_FRAME_MISSES = 3;
7818
+ /**
7667
7819
  * Pure per-(track, step) scheduling state machine for detail-subtree
7668
7820
  * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
7669
7821
  * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
@@ -7684,7 +7836,9 @@ var DetailScheduler = class {
7684
7836
  firedCount: 1,
7685
7837
  lastFiredAt: nowMs,
7686
7838
  sticky: false,
7687
- retryPending: false
7839
+ retryPending: false,
7840
+ consecutiveFrameMisses: 0,
7841
+ abandoned: false
7688
7842
  };
7689
7843
  steps.set(stepAnnounce.stepId, state);
7690
7844
  requests.push({
@@ -7717,7 +7871,7 @@ var DetailScheduler = class {
7717
7871
  tick(nowMs) {
7718
7872
  const requests = [];
7719
7873
  for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
7720
- if (state.sticky) continue;
7874
+ if (state.sticky || state.abandoned) continue;
7721
7875
  if (state.retryPending) {
7722
7876
  if (!this.intervalElapsed(state, nowMs)) continue;
7723
7877
  if (!this.underMaxPerTrack(state)) {
@@ -7755,7 +7909,8 @@ var DetailScheduler = class {
7755
7909
  if (!steps) return;
7756
7910
  const state = steps.get(stepId);
7757
7911
  if (!state) return;
7758
- if (state.sticky) return;
7912
+ if (state.sticky || state.abandoned) return;
7913
+ state.consecutiveFrameMisses = 0;
7759
7914
  const { stickyOnConfidence } = state.announce.cadence;
7760
7915
  if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
7761
7916
  state.sticky = true;
@@ -7770,11 +7925,37 @@ var DetailScheduler = class {
7770
7925
  if (this.underMaxPerTrack(state)) state.retryPending = true;
7771
7926
  }
7772
7927
  }
7928
+ /**
7929
+ * A dispatched request could not resolve a frame AT ALL — the frame handle
7930
+ * lease was evicted AND the crop fallback was unavailable (the "frame + crop
7931
+ * both missed" outcome). This is fundamentally different from `onResult(null)`:
7932
+ * there the frame plane WORKED and the model merely returned nothing (worth a
7933
+ * retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
7934
+ * handle every time, so it can never recover from this request. It is
7935
+ * retry-eligible only for a bounded number of CONSECUTIVE attempts; after
7936
+ * {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
7937
+ * track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
7938
+ * is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
7939
+ */
7940
+ onFrameMiss(trackId, stepId, _nowMs) {
7941
+ const steps = this.tracks.get(trackId);
7942
+ if (!steps) return;
7943
+ const state = steps.get(stepId);
7944
+ if (!state) return;
7945
+ if (state.sticky || state.abandoned) return;
7946
+ state.consecutiveFrameMisses += 1;
7947
+ if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
7948
+ state.abandoned = true;
7949
+ state.retryPending = false;
7950
+ return;
7951
+ }
7952
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
7953
+ }
7773
7954
  onTrackEnded(trackId) {
7774
7955
  this.tracks.delete(trackId);
7775
7956
  }
7776
7957
  canFire(state, nowMs) {
7777
- if (state.sticky) return false;
7958
+ if (state.sticky || state.abandoned) return false;
7778
7959
  if (!this.underMaxPerTrack(state)) return false;
7779
7960
  return this.intervalElapsed(state, nowMs);
7780
7961
  }
@@ -7955,8 +8136,12 @@ var TrackDetailDispatcher = class {
7955
8136
  }
7956
8137
  async dispatch(deviceId, dev, req, frame) {
7957
8138
  const details = await this.runOnce(deviceId, dev, req, frame);
8139
+ if (details === null) {
8140
+ dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
8141
+ return;
8142
+ }
7958
8143
  let topScore = null;
7959
- if (details !== null && details.length > 0) {
8144
+ if (details.length > 0) {
7960
8145
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7961
8146
  const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
7962
8147
  if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
@@ -9289,6 +9474,40 @@ function classifyAudioFrame(top, cfg) {
9289
9474
  //#endregion
9290
9475
  //#region src/pipeline-analytics/event-media-handler.ts
9291
9476
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
9477
+ /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
9478
+ * `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
9479
+ var THUMB_DEFAULT_SIZE = 160;
9480
+ var THUMB_MIN_SIZE$1 = 64;
9481
+ var THUMB_MAX_SIZE$1 = 320;
9482
+ /**
9483
+ * Parse the `?kind=…` query into a preferred stored media kind. Returns null
9484
+ * when unset. The value is a free-form kind token (e.g. `crop`); the resolver
9485
+ * validates it against the known kinds.
9486
+ */
9487
+ function parseEventMediaKind(query) {
9488
+ const kind = new URLSearchParams(query).get("kind");
9489
+ return kind !== null && kind.length > 0 ? kind : null;
9490
+ }
9491
+ /**
9492
+ * Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
9493
+ * when no small-square rendering was requested (the caller then serves the
9494
+ * stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
9495
+ * `size` / `w` / `h` (clamped to [64, 320], default 160).
9496
+ */
9497
+ function parseEventMediaVariant(query) {
9498
+ const params = new URLSearchParams(query);
9499
+ if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
9500
+ const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
9501
+ let size = THUMB_DEFAULT_SIZE;
9502
+ if (sizeRaw !== null) {
9503
+ const n = Number.parseInt(sizeRaw, 10);
9504
+ if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
9505
+ }
9506
+ return {
9507
+ kind: "thumb",
9508
+ size
9509
+ };
9510
+ }
9292
9511
  /**
9293
9512
  * Create a data-plane handler that serves event thumbnails as JPEG images.
9294
9513
  *
@@ -9302,14 +9521,20 @@ function createEventMediaHandler(deps) {
9302
9521
  res.writeHead(405, { allow: "GET, HEAD" }).end();
9303
9522
  return;
9304
9523
  }
9305
- const eventId = ((req.url ?? "/").split("?")[0] ?? "/").replace(/^\/+/, "");
9524
+ const url = req.url ?? "/";
9525
+ const qIdx = url.indexOf("?");
9526
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
9527
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
9528
+ const eventId = rawPath.replace(/^\/+/, "");
9306
9529
  if (!eventId || eventId.includes("/")) {
9307
9530
  res.writeHead(404).end();
9308
9531
  return;
9309
9532
  }
9533
+ const variant = parseEventMediaVariant(query);
9534
+ const preferKind = parseEventMediaKind(query);
9310
9535
  let media = null;
9311
9536
  try {
9312
- media = await deps.getMedia(eventId);
9537
+ media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
9313
9538
  } catch {
9314
9539
  const body = "Internal server error";
9315
9540
  res.writeHead(500, {
@@ -9342,6 +9567,27 @@ function createEventMediaHandler(deps) {
9342
9567
  else res.end(Buffer.from(media.bytes));
9343
9568
  };
9344
9569
  }
9570
+ /** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
9571
+ var THUMB_QUALITY = 70;
9572
+ /**
9573
+ * Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
9574
+ * stored 640×360 `crop`). Center-crop cover to a square then downscale to
9575
+ * `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
9576
+ * showing the object, not the full 16:9 crop. Output is a fraction of the source
9577
+ * (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
9578
+ * from tiny HTTP-cached tiles instead of full base64 payloads.
9579
+ *
9580
+ * `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
9581
+ * crops the overflow symmetrically — the center square of a square-safe crop
9582
+ * fully contains the detector bbox, so the object stays framed.
9583
+ */
9584
+ async function makeSquareThumb(bytes, size) {
9585
+ const edge = Math.max(64, Math.min(320, Math.round(size)));
9586
+ return sharp(Buffer.from(bytes)).resize(edge, edge, {
9587
+ fit: "cover",
9588
+ position: "centre"
9589
+ }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
9590
+ }
9345
9591
  //#endregion
9346
9592
  //#region src/pipeline-analytics/index.ts
9347
9593
  /**
@@ -9397,6 +9643,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
9397
9643
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
9398
9644
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
9399
9645
  /**
9646
+ * Stored media kinds that carry NO drawn bounding box, in fallback preference
9647
+ * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
9648
+ * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
9649
+ * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
9650
+ */
9651
+ var CLEAN_MEDIA_KINDS = [
9652
+ "crop",
9653
+ "fullFrame",
9654
+ "keyFrame"
9655
+ ];
9656
+ /**
9657
+ * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
9658
+ * `preferKind` if it is itself clean and present, else the first available
9659
+ * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
9660
+ * exists (caller 404s → the viewer shows an icon).
9661
+ */
9662
+ function pickCleanMedia(files, preferKind) {
9663
+ const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
9664
+ if (isClean(preferKind)) {
9665
+ const exact = files.find((f) => f.kind === preferKind);
9666
+ if (exact) return exact;
9667
+ }
9668
+ for (const kind of CLEAN_MEDIA_KINDS) {
9669
+ const found = files.find((f) => f.kind === kind);
9670
+ if (found) return found;
9671
+ }
9672
+ }
9673
+ /**
9400
9674
  * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
9401
9675
  * wire encoding produced by `runDetailSubtree`) back into a plain number[].
9402
9676
  */
@@ -9815,7 +10089,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9815
10089
  });
9816
10090
  this.zoneAnalytics = new ZoneAnalyticsProvider({
9817
10091
  logger: logger.child("ZoneAnalytics"),
9818
- fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
10092
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
10093
+ listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
10094
+ listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
10095
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
9819
10096
  });
9820
10097
  this.audioMetrics = new AudioMetricsProvider({
9821
10098
  logger: logger.child("AudioMetrics"),
@@ -9831,9 +10108,9 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
9831
10108
  }
9832
10109
  });
9833
10110
  try {
9834
- const handler = createEventMediaHandler({ getMedia: async (id) => {
10111
+ const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
9835
10112
  try {
9836
- return await this.readMediaByEventOrKey(id);
10113
+ return await this.readMediaByEventOrKey(id, variant, preferKind);
9837
10114
  } catch (err) {
9838
10115
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
9839
10116
  eventId: id,
@@ -10336,7 +10613,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10336
10613
  const stationaryAsTracked = stationaryViews.map((v) => ({
10337
10614
  trackId: `stationary:${v.id}`,
10338
10615
  className: v.className,
10339
- zones: []
10616
+ zones: computeStationaryEntryZones(v, liveZones)
10340
10617
  }));
10341
10618
  this.zoneAnalytics?.recordFrame({
10342
10619
  deviceId,
@@ -11772,6 +12049,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11772
12049
  return null;
11773
12050
  }
11774
12051
  }
12052
+ /**
12053
+ * Resolve a device's current 0–1 zone catalogue independent of the live
12054
+ * frame path — used by zone-analytics snapshot hydration + the occupancy
12055
+ * baseline sampler when the camera is detached (no frames). Warms the proxy
12056
+ * (cold read via `fetchDevice`) and, when the cached slice is empty, forces
12057
+ * one `refresh()` round-trip so a just-created proxy returns real zones.
12058
+ */
12059
+ async resolveDeviceZones(deviceId) {
12060
+ const proxy = await this.ensureProxy(deviceId);
12061
+ if (!proxy) return [];
12062
+ const cached = proxy.state.zones.value?.zones;
12063
+ if (cached && cached.length > 0) return cached;
12064
+ await proxy.state.zones.refresh().catch(() => void 0);
12065
+ return proxy.state.zones.value?.zones ?? [];
12066
+ }
11775
12067
  releaseProxy(deviceId) {
11776
12068
  const unsubs = this.proxyUnsubs.get(deviceId);
11777
12069
  if (unsubs) for (const u of unsubs) try {
@@ -12147,19 +12439,51 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12147
12439
  * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
12148
12440
  * key), so this resolves that crop; event ids stay on the event-crop path.
12149
12441
  */
12150
- async readMediaByEventOrKey(id) {
12151
- if (id.includes(":")) {
12152
- const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12153
- if (!file) return null;
12442
+ async readMediaByEventOrKey(id, variant, preferKind) {
12443
+ const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
12444
+ if (base === null || variant === void 0) return base;
12445
+ return this.applyThumbVariant(base, variant);
12446
+ }
12447
+ async readMediaByKey(id) {
12448
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12449
+ if (!file) return null;
12450
+ return {
12451
+ bytes: Buffer.from(file.base64, "base64"),
12452
+ key: file.key
12453
+ };
12454
+ }
12455
+ /**
12456
+ * Render a small center-cropped square from a resolved event media blob for
12457
+ * the reel / list surfaces. The returned `key` is variant-distinct so the
12458
+ * data-plane ETag never collides with the full-size blob's. On any encode
12459
+ * failure the full blob is served (a thumb must never 500 / blank a tile).
12460
+ */
12461
+ async applyThumbVariant(media, variant) {
12462
+ try {
12154
12463
  return {
12155
- bytes: Buffer.from(file.base64, "base64"),
12156
- key: file.key
12464
+ bytes: await makeSquareThumb(media.bytes, variant.size),
12465
+ key: `${media.key}|t${variant.size}`
12157
12466
  };
12467
+ } catch (err) {
12468
+ this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
12469
+ key: media.key,
12470
+ size: variant.size,
12471
+ error: errMsg(err)
12472
+ } });
12473
+ return media;
12158
12474
  }
12159
- return this.readEventThumbnail(id);
12160
12475
  }
12161
- async readEventThumbnail(id) {
12476
+ async readEventThumbnail(id, preferKind) {
12162
12477
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12478
+ if (preferKind !== void 0 && preferKind.length > 0) {
12479
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12480
+ const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
12481
+ if (!clean) return null;
12482
+ return {
12483
+ bytes: Buffer.from(clean.base64, "base64"),
12484
+ key: clean.key
12485
+ };
12486
+ }
12163
12487
  const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12164
12488
  if (chosenEvent) return {
12165
12489
  bytes: Buffer.from(chosenEvent.base64, "base64"),
@@ -12744,4 +13068,4 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
12744
13068
  }
12745
13069
  };
12746
13070
  //#endregion
12747
- export { PipelineAnalyticsAddon as default, stripGlobalOnlyFields, toAnalyticsDeviceSections };
13071
+ export { PipelineAnalyticsAddon as default, pickCleanMedia, stripGlobalOnlyFields, toAnalyticsDeviceSections };
@@ -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-D0V4LPAq.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DeoVBjEB.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.1.29",
3
+ "version": "1.1.30",
4
4
  "description": "CamStack Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",