@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-AFLbpmAs.js");
5
+ const require_dist = require("../dist-U51kCBdm.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { a as embeddingEncoderCapability, m as BaseAddon, s as hfModelUrl } from "../dist-CFjLqX2m.mjs";
1
+ import { a as embeddingEncoderCapability, m as BaseAddon, s as hfModelUrl } from "../dist-yPsKFcJL.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path$1 from "node:path";
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-AFLbpmAs.js");
1
+ const require_dist = require("./dist-U51kCBdm.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs, 1);
4
4
  let node_path = require("node:path");
@@ -3,7 +3,7 @@ import "./dist-CYZr2fwk.mjs";
3
3
  var e = {
4
4
  "@camstack/sdk": {
5
5
  name: "@camstack/sdk",
6
- version: "1.1.24",
6
+ version: "1.1.25",
7
7
  scope: ["default"],
8
8
  loaded: !1,
9
9
  from: "addon_pipeline_analytics_widgets",
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.1.45",
21
+ version: "1.1.46",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -33,7 +33,7 @@ var e = {
33
33
  },
34
34
  "@camstack/ui-library": {
35
35
  name: "@camstack/ui-library",
36
- version: "1.1.36",
36
+ version: "1.1.37",
37
37
  scope: ["default"],
38
38
  loaded: !1,
39
39
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.1.45",
39
+ version: "1.1.46",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -45,7 +45,7 @@ async function r() {
45
45
  }
46
46
  },
47
47
  "@camstack/sdk": {
48
- version: "1.1.24",
48
+ version: "1.1.25",
49
49
  scope: "default",
50
50
  shareConfig: {
51
51
  singleton: !0,
@@ -81,7 +81,7 @@ async function r() {
81
81
  }
82
82
  },
83
83
  "@camstack/ui-library": {
84
- version: "1.1.36",
84
+ version: "1.1.37",
85
85
  scope: "default",
86
86
  shareConfig: {
87
87
  singleton: !0,
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-AFLbpmAs.js");
5
+ const require_dist = require("../dist-U51kCBdm.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -2501,6 +2501,14 @@ var StationaryObjectRegistry = class {
2501
2501
  count(deviceId) {
2502
2502
  return this.byDevice.get(deviceId)?.size ?? 0;
2503
2503
  }
2504
+ /** Device ids that currently hold at least one parked entry — drives the
2505
+ * occupancy baseline sampler (a detached camera with parked cars still
2506
+ * gets a flat history baseline). */
2507
+ deviceIds() {
2508
+ const ids = [];
2509
+ for (const [deviceId, m] of this.byDevice) if (m.size > 0) ids.push(deviceId);
2510
+ return ids;
2511
+ }
2504
2512
  /** Record that a frame was processed for a device — advances the OBSERVED
2505
2513
  * clock that drives entry expiry in {@link sweep}. */
2506
2514
  noteFrame(deviceId, timestamp) {
@@ -2720,6 +2728,26 @@ function rowToEntry(id, data) {
2720
2728
  };
2721
2729
  }
2722
2730
  //#endregion
2731
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
2732
+ /**
2733
+ * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
2734
+ * Empty when the entry has no frame dims (can't normalise) or no zone matches.
2735
+ */
2736
+ function computeStationaryEntryZones(entry, zones) {
2737
+ if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
2738
+ const centroidPx = bboxCentroid(entry.bbox);
2739
+ const point = {
2740
+ x: centroidPx.x / entry.frameWidth,
2741
+ y: centroidPx.y / entry.frameHeight
2742
+ };
2743
+ const matched = [];
2744
+ for (const zone of zones) {
2745
+ if (zone.polygon.length < 3) continue;
2746
+ if (pointInPolygon(point, zone.polygon)) matched.push(zone.id);
2747
+ }
2748
+ return matched;
2749
+ }
2750
+ //#endregion
2723
2751
  //#region src/pipeline-analytics/pipeline/track-appearance.ts
2724
2752
  /**
2725
2753
  * Pure: no side effects. `continuing` = still active from last frame;
@@ -5408,6 +5436,17 @@ var RESOLUTION_MS = {
5408
5436
  * latest state is never lost.
5409
5437
  */
5410
5438
  var SLICE_WRITE_INTERVAL_MS$1 = 1e3;
5439
+ /**
5440
+ * Cadence of the synthetic occupancy baseline. When a camera is detached (no
5441
+ * inference frames) but has persisted parked objects, the history ring would
5442
+ * otherwise stay empty and the chart would read "No occupancy history yet". A
5443
+ * device WITH parked entries gets one hydrated sample per this interval — a
5444
+ * flat baseline of the parked count — so the graph shows the parking lot's
5445
+ * standing occupancy instead of a gap. No sample is emitted for a device
5446
+ * without entries, and a real `recordFrame` in the same window suppresses the
5447
+ * baseline (it already appended a richer sample).
5448
+ */
5449
+ var BASELINE_SAMPLE_INTERVAL_MS = 6e4;
5411
5450
  var ZoneAnalyticsProvider = class {
5412
5451
  ctx;
5413
5452
  snapshots = /* @__PURE__ */ new Map();
@@ -5424,8 +5463,17 @@ var ZoneAnalyticsProvider = class {
5424
5463
  /** Last logged frame-wide occupancy total per device — so the occupancy log
5425
5464
  * fires only when the count actually changes, not every inference frame. */
5426
5465
  lastOccupancyTotal = /* @__PURE__ */ new Map();
5466
+ /** Low-cadence baseline sampler — appends a hydrated occupancy sample for any
5467
+ * device with parked objects but no live frames. `null` when disabled. */
5468
+ baselineTimer = null;
5427
5469
  constructor(ctx) {
5428
5470
  this.ctx = ctx;
5471
+ if (ctx.listStationaryDeviceIds && ctx.listStationaryObjects) {
5472
+ this.baselineTimer = setInterval(() => {
5473
+ this.appendBaselineSamples();
5474
+ }, BASELINE_SAMPLE_INTERVAL_MS);
5475
+ this.baselineTimer.unref?.();
5476
+ }
5429
5477
  this.sliceThrottle = new SliceThrottler({
5430
5478
  intervalMs: SLICE_WRITE_INTERVAL_MS$1,
5431
5479
  equalsIgnoringTs: snapshotEqualsIgnoringTs,
@@ -5447,9 +5495,10 @@ var ZoneAnalyticsProvider = class {
5447
5495
  /** Stop pending throttle timers — called from addon shutdown. */
5448
5496
  destroy() {
5449
5497
  this.sliceThrottle.destroy();
5498
+ if (this.baselineTimer) clearInterval(this.baselineTimer);
5450
5499
  }
5451
5500
  async getCurrentSnapshot({ deviceId }) {
5452
- return this.snapshots.get(deviceId) ?? null;
5501
+ return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
5453
5502
  }
5454
5503
  async getZoneHistory(input) {
5455
5504
  return this.bucketize(input.deviceId, input.from, input.to, input.resolution, (snap) => {
@@ -5502,6 +5551,65 @@ var ZoneAnalyticsProvider = class {
5502
5551
  this.lastOccupancyTotal.delete(deviceId);
5503
5552
  this.sliceThrottle.forgetDevice(deviceId);
5504
5553
  }
5554
+ /**
5555
+ * Build an occupancy snapshot for a device purely from its parked-object
5556
+ * registry (no live frame). Returns `null` when hydration is unavailable or
5557
+ * the device has no parked objects — a device with neither frames nor entries
5558
+ * legitimately reports `null`. The snapshot's `ts` is the most recent
5559
+ * `lastConfirmedAt` across entries, falling back to the current tick.
5560
+ */
5561
+ async hydrateFromRegistry(deviceId) {
5562
+ const listStationary = this.ctx.listStationaryObjects;
5563
+ if (!listStationary) return null;
5564
+ const entries = listStationary(deviceId);
5565
+ if (entries.length === 0) return null;
5566
+ let zones = [];
5567
+ try {
5568
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5569
+ } catch (err) {
5570
+ this.ctx.logger.debug("zone-analytics hydrate zone resolve failed", {
5571
+ tags: { deviceId },
5572
+ meta: { error: err instanceof Error ? err.message : String(err) }
5573
+ });
5574
+ }
5575
+ const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
5576
+ return buildStationarySnapshot({
5577
+ deviceId,
5578
+ entries,
5579
+ zones,
5580
+ timestamp: ts
5581
+ });
5582
+ }
5583
+ /**
5584
+ * Baseline sampler tick: for every device with parked objects, append a
5585
+ * hydrated sample to the history ring at the CURRENT time — but only when a
5586
+ * real frame hasn't already appended a sample within this interval (frames
5587
+ * flowing = richer samples, no synthetic baseline needed).
5588
+ */
5589
+ async appendBaselineSamples() {
5590
+ const deviceIds = this.ctx.listStationaryDeviceIds?.() ?? [];
5591
+ const now = Date.now();
5592
+ for (const deviceId of deviceIds) {
5593
+ const ring = this.history.get(deviceId);
5594
+ if (now - (ring && ring.length > 0 ? ring[ring.length - 1].ts : 0) < BASELINE_SAMPLE_INTERVAL_MS) continue;
5595
+ const entries = this.ctx.listStationaryObjects?.(deviceId) ?? [];
5596
+ if (entries.length === 0) continue;
5597
+ let zones = [];
5598
+ try {
5599
+ zones = await this.ctx.resolveZones?.(deviceId) ?? [];
5600
+ } catch {}
5601
+ const snapshot = buildStationarySnapshot({
5602
+ deviceId,
5603
+ entries,
5604
+ zones,
5605
+ timestamp: now
5606
+ });
5607
+ if (snapshot) {
5608
+ this.appendHistory(deviceId, snapshot);
5609
+ this.sliceThrottle.push(deviceId, snapshot);
5610
+ }
5611
+ }
5612
+ }
5505
5613
  appendHistory(deviceId, snapshot) {
5506
5614
  const ring = this.history.get(deviceId) ?? [];
5507
5615
  const cutoff = snapshot.ts - HISTORY_WINDOW_MS;
@@ -5592,6 +5700,39 @@ function computeSnapshot(input) {
5592
5700
  ...input.stationaryObjects !== void 0 && input.stationaryObjects.length > 0 ? { stationaryObjects: input.stationaryObjects } : {}
5593
5701
  };
5594
5702
  }
5703
+ /** Most recent `lastConfirmedAt` across parked entries (0 when none). */
5704
+ function mostRecentStationaryConfirmedAt(entries) {
5705
+ let max = 0;
5706
+ for (const e of entries) if (e.lastConfirmedAt > max) max = e.lastConfirmedAt;
5707
+ return max;
5708
+ }
5709
+ /**
5710
+ * Build an occupancy snapshot from parked-object registry entries alone —
5711
+ * used when no live inference frame is available (fresh respawn, camera
5712
+ * detached). Each entry is folded into the frame aggregate AND attributed to
5713
+ * the zones its normalised bbox centroid falls inside (via
5714
+ * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
5715
+ * reports a count of 1. Returns `null` for an empty entry list. Reuses
5716
+ * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
5717
+ */
5718
+ function buildStationarySnapshot(input) {
5719
+ if (input.entries.length === 0) return null;
5720
+ const tracked = input.entries.map((e) => ({
5721
+ trackId: `stationary:${e.id}`,
5722
+ className: e.className,
5723
+ zones: computeStationaryEntryZones(e, input.zones)
5724
+ }));
5725
+ const first = input.entries[0];
5726
+ return computeSnapshot({
5727
+ deviceId: input.deviceId,
5728
+ timestamp: input.timestamp,
5729
+ frameWidth: first.frameWidth,
5730
+ frameHeight: first.frameHeight,
5731
+ tracked,
5732
+ zones: input.zones,
5733
+ stationaryObjects: input.entries
5734
+ });
5735
+ }
5595
5736
  //#endregion
5596
5737
  //#region src/pipeline-analytics/audio-metrics-provider.ts
5597
5738
  var AUDIO_METRICS_CAP_NAME = "audio-metrics";
@@ -7669,6 +7810,17 @@ var DEFAULT_MIN_INTERVAL_MS = 1e3;
7669
7810
  /** Default fire cap for a `once` step when its announce omits `maxPerTrack`. */
7670
7811
  var DEFAULT_ONCE_MAX_PER_TRACK = 3;
7671
7812
  /**
7813
+ * Consecutive frame-plane misses ("frame + crop both missed") after which a step
7814
+ * is ABANDONED for the track. The decode worker serves native crops from a RAM
7815
+ * lease store with a ~500ms TTL, so a retry that arrives seconds later re-cuts
7816
+ * from an evicted handle and is a guaranteed miss forever. Retrying a
7817
+ * permanently-gone frame just burns cross-process RPC + CPU + log lines. Three
7818
+ * consecutive misses (each ≥ one tick apart) confidently means the frame is gone
7819
+ * for good, while still tolerating a single transient decode-worker hiccup /
7820
+ * respawn on a genuinely live track (the counter resets on any resolved result).
7821
+ */
7822
+ var MAX_CONSECUTIVE_FRAME_MISSES = 3;
7823
+ /**
7672
7824
  * Pure per-(track, step) scheduling state machine for detail-subtree
7673
7825
  * dispatch. Given a camera's announced child steps (`DetailStepAnnounce[]`,
7674
7826
  * read off `PipelineInferenceResultPayload.detailSteps`), decides WHEN each
@@ -7689,7 +7841,9 @@ var DetailScheduler = class {
7689
7841
  firedCount: 1,
7690
7842
  lastFiredAt: nowMs,
7691
7843
  sticky: false,
7692
- retryPending: false
7844
+ retryPending: false,
7845
+ consecutiveFrameMisses: 0,
7846
+ abandoned: false
7693
7847
  };
7694
7848
  steps.set(stepAnnounce.stepId, state);
7695
7849
  requests.push({
@@ -7722,7 +7876,7 @@ var DetailScheduler = class {
7722
7876
  tick(nowMs) {
7723
7877
  const requests = [];
7724
7878
  for (const [trackId, steps] of this.tracks) for (const state of steps.values()) {
7725
- if (state.sticky) continue;
7879
+ if (state.sticky || state.abandoned) continue;
7726
7880
  if (state.retryPending) {
7727
7881
  if (!this.intervalElapsed(state, nowMs)) continue;
7728
7882
  if (!this.underMaxPerTrack(state)) {
@@ -7760,7 +7914,8 @@ var DetailScheduler = class {
7760
7914
  if (!steps) return;
7761
7915
  const state = steps.get(stepId);
7762
7916
  if (!state) return;
7763
- if (state.sticky) return;
7917
+ if (state.sticky || state.abandoned) return;
7918
+ state.consecutiveFrameMisses = 0;
7764
7919
  const { stickyOnConfidence } = state.announce.cadence;
7765
7920
  if (confidence !== null && stickyOnConfidence !== void 0 && confidence >= stickyOnConfidence) {
7766
7921
  state.sticky = true;
@@ -7775,11 +7930,37 @@ var DetailScheduler = class {
7775
7930
  if (this.underMaxPerTrack(state)) state.retryPending = true;
7776
7931
  }
7777
7932
  }
7933
+ /**
7934
+ * A dispatched request could not resolve a frame AT ALL — the frame handle
7935
+ * lease was evicted AND the crop fallback was unavailable (the "frame + crop
7936
+ * both missed" outcome). This is fundamentally different from `onResult(null)`:
7937
+ * there the frame plane WORKED and the model merely returned nothing (worth a
7938
+ * retry on a fresh frame). A frame-plane miss re-cuts from the SAME evicted
7939
+ * handle every time, so it can never recover from this request. It is
7940
+ * retry-eligible only for a bounded number of CONSECUTIVE attempts; after
7941
+ * {@link MAX_CONSECUTIVE_FRAME_MISSES} in a row the step is abandoned for the
7942
+ * track — this is the give-up that breaks the permanent-retry loop. `_nowMs`
7943
+ * is accepted for signature symmetry (backoff is anchored to `lastFiredAt`).
7944
+ */
7945
+ onFrameMiss(trackId, stepId, _nowMs) {
7946
+ const steps = this.tracks.get(trackId);
7947
+ if (!steps) return;
7948
+ const state = steps.get(stepId);
7949
+ if (!state) return;
7950
+ if (state.sticky || state.abandoned) return;
7951
+ state.consecutiveFrameMisses += 1;
7952
+ if (state.consecutiveFrameMisses >= MAX_CONSECUTIVE_FRAME_MISSES) {
7953
+ state.abandoned = true;
7954
+ state.retryPending = false;
7955
+ return;
7956
+ }
7957
+ if (this.underMaxPerTrack(state)) state.retryPending = true;
7958
+ }
7778
7959
  onTrackEnded(trackId) {
7779
7960
  this.tracks.delete(trackId);
7780
7961
  }
7781
7962
  canFire(state, nowMs) {
7782
- if (state.sticky) return false;
7963
+ if (state.sticky || state.abandoned) return false;
7783
7964
  if (!this.underMaxPerTrack(state)) return false;
7784
7965
  return this.intervalElapsed(state, nowMs);
7785
7966
  }
@@ -7960,8 +8141,12 @@ var TrackDetailDispatcher = class {
7960
8141
  }
7961
8142
  async dispatch(deviceId, dev, req, frame) {
7962
8143
  const details = await this.runOnce(deviceId, dev, req, frame);
8144
+ if (details === null) {
8145
+ dev.scheduler.onFrameMiss(req.trackId, req.stepId, Date.now());
8146
+ return;
8147
+ }
7963
8148
  let topScore = null;
7964
- if (details !== null && details.length > 0) {
8149
+ if (details.length > 0) {
7965
8150
  topScore = details.reduce((max, d) => d.score > max ? d.score : max, Number.NEGATIVE_INFINITY);
7966
8151
  const steps = composeDetailSteps(req, (id) => this.deps.hasTrackLabel?.(id) ?? false);
7967
8152
  if (isEnrichmentChain(steps) && !detailsCarryEnrichment(details)) this.warnEnrichmentMissThrottled(deviceId, dev, req, steps);
@@ -9294,6 +9479,40 @@ function classifyAudioFrame(top, cfg) {
9294
9479
  //#endregion
9295
9480
  //#region src/pipeline-analytics/event-media-handler.ts
9296
9481
  var CACHE_CONTROL = "public, max-age=31536000, immutable";
9482
+ /** Default / clamp bounds for the `thumb` variant edge (px). Mirrors
9483
+ * `shared/frame/square-thumb.ts`; kept here so query parsing stays pure. */
9484
+ var THUMB_DEFAULT_SIZE = 160;
9485
+ var THUMB_MIN_SIZE$1 = 64;
9486
+ var THUMB_MAX_SIZE$1 = 320;
9487
+ /**
9488
+ * Parse the `?kind=…` query into a preferred stored media kind. Returns null
9489
+ * when unset. The value is a free-form kind token (e.g. `crop`); the resolver
9490
+ * validates it against the known kinds.
9491
+ */
9492
+ function parseEventMediaKind(query) {
9493
+ const kind = new URLSearchParams(query).get("kind");
9494
+ return kind !== null && kind.length > 0 ? kind : null;
9495
+ }
9496
+ /**
9497
+ * Parse the `?variant=…` query into an {@link EventMediaVariant}. Returns null
9498
+ * when no small-square rendering was requested (the caller then serves the
9499
+ * stored blob). Accepts `variant=thumb` or `square=1`; the edge comes from
9500
+ * `size` / `w` / `h` (clamped to [64, 320], default 160).
9501
+ */
9502
+ function parseEventMediaVariant(query) {
9503
+ const params = new URLSearchParams(query);
9504
+ if (!(params.get("variant") === "thumb" || params.get("square") === "1")) return null;
9505
+ const sizeRaw = params.get("size") ?? params.get("w") ?? params.get("h");
9506
+ let size = THUMB_DEFAULT_SIZE;
9507
+ if (sizeRaw !== null) {
9508
+ const n = Number.parseInt(sizeRaw, 10);
9509
+ if (Number.isFinite(n)) size = Math.max(THUMB_MIN_SIZE$1, Math.min(THUMB_MAX_SIZE$1, n));
9510
+ }
9511
+ return {
9512
+ kind: "thumb",
9513
+ size
9514
+ };
9515
+ }
9297
9516
  /**
9298
9517
  * Create a data-plane handler that serves event thumbnails as JPEG images.
9299
9518
  *
@@ -9307,14 +9526,20 @@ function createEventMediaHandler(deps) {
9307
9526
  res.writeHead(405, { allow: "GET, HEAD" }).end();
9308
9527
  return;
9309
9528
  }
9310
- const eventId = ((req.url ?? "/").split("?")[0] ?? "/").replace(/^\/+/, "");
9529
+ const url = req.url ?? "/";
9530
+ const qIdx = url.indexOf("?");
9531
+ const rawPath = qIdx === -1 ? url : url.slice(0, qIdx);
9532
+ const query = qIdx === -1 ? "" : url.slice(qIdx + 1);
9533
+ const eventId = rawPath.replace(/^\/+/, "");
9311
9534
  if (!eventId || eventId.includes("/")) {
9312
9535
  res.writeHead(404).end();
9313
9536
  return;
9314
9537
  }
9538
+ const variant = parseEventMediaVariant(query);
9539
+ const preferKind = parseEventMediaKind(query);
9315
9540
  let media = null;
9316
9541
  try {
9317
- media = await deps.getMedia(eventId);
9542
+ media = await deps.getMedia(eventId, variant ?? void 0, preferKind ?? void 0);
9318
9543
  } catch {
9319
9544
  const body = "Internal server error";
9320
9545
  res.writeHead(500, {
@@ -9347,6 +9572,27 @@ function createEventMediaHandler(deps) {
9347
9572
  else res.end(Buffer.from(media.bytes));
9348
9573
  };
9349
9574
  }
9575
+ /** JPEG quality for the small square thumbnail (visibly fine at ≤192px, tiny). */
9576
+ var THUMB_QUALITY = 70;
9577
+ /**
9578
+ * Produce a SMALL SQUARE JPEG from an already-encoded image (typically the
9579
+ * stored 640×360 `crop`). Center-crop cover to a square then downscale to
9580
+ * `size`×`size` at JPEG q70 — the reel/list surfaces want a compact square tile
9581
+ * showing the object, not the full 16:9 crop. Output is a fraction of the source
9582
+ * (~3–8 KB at 144–192 px vs ~65 KB for the crop), so a fleet-wide reel renders
9583
+ * from tiny HTTP-cached tiles instead of full base64 payloads.
9584
+ *
9585
+ * `fit: 'cover'` + `position: 'centre'` scales the shorter side to `size` and
9586
+ * crops the overflow symmetrically — the center square of a square-safe crop
9587
+ * fully contains the detector bbox, so the object stays framed.
9588
+ */
9589
+ async function makeSquareThumb(bytes, size) {
9590
+ const edge = Math.max(64, Math.min(320, Math.round(size)));
9591
+ return (0, sharp.default)(Buffer.from(bytes)).resize(edge, edge, {
9592
+ fit: "cover",
9593
+ position: "centre"
9594
+ }).jpeg({ quality: THUMB_QUALITY }).toBuffer();
9595
+ }
9350
9596
  //#endregion
9351
9597
  //#region src/pipeline-analytics/index.ts
9352
9598
  /**
@@ -9402,6 +9648,34 @@ var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
9402
9648
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
9403
9649
  var MOTION_EVENT_HEARTBEAT_MS = 5e3;
9404
9650
  /**
9651
+ * Stored media kinds that carry NO drawn bounding box, in fallback preference
9652
+ * order. The reel forces `?kind=crop`; when a track has no crop the endpoint may
9653
+ * degrade only to one of these CLEAN frames — never `fullFrameBoxed` /
9654
+ * `thumbnail` / `lastFrame` / `firstFrame` / `snapshot` (all server-boxed).
9655
+ */
9656
+ var CLEAN_MEDIA_KINDS = [
9657
+ "crop",
9658
+ "fullFrame",
9659
+ "keyFrame"
9660
+ ];
9661
+ /**
9662
+ * Pick a CLEAN (never boxed) media file for a crop-forced request: the exact
9663
+ * `preferKind` if it is itself clean and present, else the first available
9664
+ * {@link CLEAN_MEDIA_KINDS} frame. Returns undefined when only boxed / no media
9665
+ * exists (caller 404s → the viewer shows an icon).
9666
+ */
9667
+ function pickCleanMedia(files, preferKind) {
9668
+ const isClean = (k) => CLEAN_MEDIA_KINDS.includes(k);
9669
+ if (isClean(preferKind)) {
9670
+ const exact = files.find((f) => f.kind === preferKind);
9671
+ if (exact) return exact;
9672
+ }
9673
+ for (const kind of CLEAN_MEDIA_KINDS) {
9674
+ const found = files.find((f) => f.kind === kind);
9675
+ if (found) return found;
9676
+ }
9677
+ }
9678
+ /**
9405
9679
  * Decode a base64 little-endian float32 vector (the `DetailResult.embedding`
9406
9680
  * wire encoding produced by `runDetailSubtree`) back into a plain number[].
9407
9681
  */
@@ -9613,7 +9887,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9613
9887
  let storage = this.ctx.kernel.storage;
9614
9888
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
9615
9889
  if (mediaRoot) {
9616
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DtltlqrH.js"));
9890
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BFF5_uIc.js"));
9617
9891
  storage = new FilesystemStorageProvider(mediaRoot);
9618
9892
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
9619
9893
  }
@@ -9820,7 +10094,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9820
10094
  });
9821
10095
  this.zoneAnalytics = new ZoneAnalyticsProvider({
9822
10096
  logger: logger.child("ZoneAnalytics"),
9823
- fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId)
10097
+ fetchDevice: (deviceId) => this.ctx.fetchDevice(deviceId),
10098
+ listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
10099
+ listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
10100
+ resolveZones: (deviceId) => this.resolveDeviceZones(deviceId)
9824
10101
  });
9825
10102
  this.audioMetrics = new AudioMetricsProvider({
9826
10103
  logger: logger.child("AudioMetrics"),
@@ -9836,9 +10113,9 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
9836
10113
  }
9837
10114
  });
9838
10115
  try {
9839
- const handler = createEventMediaHandler({ getMedia: async (id) => {
10116
+ const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
9840
10117
  try {
9841
- return await this.readMediaByEventOrKey(id);
10118
+ return await this.readMediaByEventOrKey(id, variant, preferKind);
9842
10119
  } catch (err) {
9843
10120
  this.ctx.logger.warn("readEventThumbnail failed", { meta: {
9844
10121
  eventId: id,
@@ -10341,7 +10618,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10341
10618
  const stationaryAsTracked = stationaryViews.map((v) => ({
10342
10619
  trackId: `stationary:${v.id}`,
10343
10620
  className: v.className,
10344
- zones: []
10621
+ zones: computeStationaryEntryZones(v, liveZones)
10345
10622
  }));
10346
10623
  this.zoneAnalytics?.recordFrame({
10347
10624
  deviceId,
@@ -11777,6 +12054,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11777
12054
  return null;
11778
12055
  }
11779
12056
  }
12057
+ /**
12058
+ * Resolve a device's current 0–1 zone catalogue independent of the live
12059
+ * frame path — used by zone-analytics snapshot hydration + the occupancy
12060
+ * baseline sampler when the camera is detached (no frames). Warms the proxy
12061
+ * (cold read via `fetchDevice`) and, when the cached slice is empty, forces
12062
+ * one `refresh()` round-trip so a just-created proxy returns real zones.
12063
+ */
12064
+ async resolveDeviceZones(deviceId) {
12065
+ const proxy = await this.ensureProxy(deviceId);
12066
+ if (!proxy) return [];
12067
+ const cached = proxy.state.zones.value?.zones;
12068
+ if (cached && cached.length > 0) return cached;
12069
+ await proxy.state.zones.refresh().catch(() => void 0);
12070
+ return proxy.state.zones.value?.zones ?? [];
12071
+ }
11780
12072
  releaseProxy(deviceId) {
11781
12073
  const unsubs = this.proxyUnsubs.get(deviceId);
11782
12074
  if (unsubs) for (const u of unsubs) try {
@@ -12152,19 +12444,51 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12152
12444
  * points its thumbnail at the embedding row's crop key (a `track:…:crop:…`
12153
12445
  * key), so this resolves that crop; event ids stay on the event-crop path.
12154
12446
  */
12155
- async readMediaByEventOrKey(id) {
12156
- if (id.includes(":")) {
12157
- const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12158
- if (!file) return null;
12447
+ async readMediaByEventOrKey(id, variant, preferKind) {
12448
+ const base = id.includes(":") ? await this.readMediaByKey(id) : await this.readEventThumbnail(id, preferKind);
12449
+ if (base === null || variant === void 0) return base;
12450
+ return this.applyThumbVariant(base, variant);
12451
+ }
12452
+ async readMediaByKey(id) {
12453
+ const file = await (this.mediaStore?.getByKey(id) ?? Promise.resolve(null));
12454
+ if (!file) return null;
12455
+ return {
12456
+ bytes: Buffer.from(file.base64, "base64"),
12457
+ key: file.key
12458
+ };
12459
+ }
12460
+ /**
12461
+ * Render a small center-cropped square from a resolved event media blob for
12462
+ * the reel / list surfaces. The returned `key` is variant-distinct so the
12463
+ * data-plane ETag never collides with the full-size blob's. On any encode
12464
+ * failure the full blob is served (a thumb must never 500 / blank a tile).
12465
+ */
12466
+ async applyThumbVariant(media, variant) {
12467
+ try {
12159
12468
  return {
12160
- bytes: Buffer.from(file.base64, "base64"),
12161
- key: file.key
12469
+ bytes: await makeSquareThumb(media.bytes, variant.size),
12470
+ key: `${media.key}|t${variant.size}`
12162
12471
  };
12472
+ } catch (err) {
12473
+ this.ctx.logger.debug("event media: thumb variant failed — serving full", { meta: {
12474
+ key: media.key,
12475
+ size: variant.size,
12476
+ error: require_dist.errMsg(err)
12477
+ } });
12478
+ return media;
12163
12479
  }
12164
- return this.readEventThumbnail(id);
12165
12480
  }
12166
- async readEventThumbnail(id) {
12481
+ async readEventThumbnail(id, preferKind) {
12167
12482
  const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
12483
+ if (preferKind !== void 0 && preferKind.length > 0) {
12484
+ const trackFiles = await (this.mediaStore?.listByOwner("track", id) ?? Promise.resolve([]));
12485
+ const clean = pickCleanMedia([...eventFiles, ...trackFiles], preferKind);
12486
+ if (!clean) return null;
12487
+ return {
12488
+ bytes: Buffer.from(clean.base64, "base64"),
12489
+ key: clean.key
12490
+ };
12491
+ }
12168
12492
  const chosenEvent = eventFiles.find((f) => f.kind === "crop") ?? eventFiles.find((f) => f.kind === "fullFrameBoxed") ?? eventFiles[0];
12169
12493
  if (chosenEvent) return {
12170
12494
  bytes: Buffer.from(chosenEvent.base64, "base64"),
@@ -12750,5 +13074,6 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
12750
13074
  };
12751
13075
  //#endregion
12752
13076
  exports.default = PipelineAnalyticsAddon;
13077
+ exports.pickCleanMedia = pickCleanMedia;
12753
13078
  exports.stripGlobalOnlyFields = stripGlobalOnlyFields;
12754
13079
  exports.toAnalyticsDeviceSections = toAnalyticsDeviceSections;