@camstack/addon-post-analysis 1.2.151 → 1.2.153

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 audioModeOf, A as NcSnoozeSchema, At as BaseAddon, B as SCENE_DEFAULT_UNCOVERED_POLICY, Bt as discriminatedUnion, C as NcConditionDescriptorSchema, Ct as sceneMonitorCapability, D as NcRuleTargetSchema, Dt as videoclipsCapability, E as NcRuleSchema, Et as vectorDimFromBase64, Ft as nodePin, G as TimelapseRulePatchSchema, Gt as record, H as SceneMonitorSchema, Ht as number, I as RECORDING_EXPORT_MAX_READ_BYTES, It as sleep, J as VISIT_MERGE_GAP_MS, Jt as EventCategory, K as TimelapseRuleSchema, Kt as string, L as RetrainStatusSchema, Lt as _enum, M as NcSystemEventKindSchema, Mt as createEvent, N as NcTaxonomySchema, Nt as hydrateSchema, O as NcScheduleSchema, Ot as zoneAnalyticsCapability, P as OpsLogEntrySchema, Pt as isDeviceScopedCap, Q as audioMetricsCapability, Rt as array, S as NC_TAXONOMY, T as NcRulePatchSchema, Tt as systemEventFilterApplies, U as TIMELAPSE_DENSE_FLOOR_SEC, Ut as object, V as SCENE_DIVERGED, Vt as literal, W as TimelapseRuleInputSchema, Wt as partialRecord, X as alarmPanelCapability, Y as addonWidgetsSourceCapability, Z as assertTimelapseCadences, _ as MACRO_LABELS, _t as pickClusterStepModels, a as COCO_TO_MACRO, b as NC_DEFAULT_SNOOZE_MINUTES, bt as readDeviceStateFrom, ct as faceGalleryCapability, d as DeclaredDevices, dt as isDetectionMacroClass, et as buildEventKindDescriptor, f as EVENT_KIND_BY_CAP, ft as isScheduleActive, g as LabelAttributionSchema, h as FailureCounters, ht as notificationRulesCapability, i as CLUSTER_MODEL_SCOPED_STEPS, it as deriveRecordingMode, j as NcSnoozeSuppressedSchema, jt as DeviceType, k as NcSnoozeInputSchema, kt as errMsg, l as DETECTION_MACRO_CLASSES, lt as failureContributionCapability, m as FULL_IMAGE_BBOX, mt as kebabToCamel, n as AnalyticsGroupRecordSchema, nt as customAction, o as DEFAULT_EVENT_COLOR, ot as encodeVectorBase64, p as EVENT_PAD_MS, pt as isSourceCap, q as TrackSourceSchema, qt as unknown, r as BaseDevice, rt as defineCustomActions, st as evaluateSensorEdge, t as AnalyticsGroupDetailSchema, tt as cosineSimilarity$1, 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 boolean } from "../dist-B5K63Zmt.mjs";
1
+ import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DDIu0Q1W.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";
@@ -5406,12 +5406,6 @@ function buildAlarmButtons(input) {
5406
5406
  }];
5407
5407
  }
5408
5408
  //#endregion
5409
- //#region src/notification-center/cap-caller.ts
5410
- function requireCapCaller(method, caller) {
5411
- if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
5412
- return caller;
5413
- }
5414
- //#endregion
5415
5409
  //#region src/notification-center/audio-rule-matcher.ts
5416
5410
  /**
5417
5411
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -5654,6 +5648,12 @@ var AudioWatcher = class {
5654
5648
  }
5655
5649
  };
5656
5650
  //#endregion
5651
+ //#region src/notification-center/cap-caller.ts
5652
+ function requireCapCaller(method, caller) {
5653
+ if (caller === void 0 || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error(`notification-rules.${method} declares caller: 'required' and this call carried no caller identity — an addon-to-addon cap call has no authenticated user`);
5654
+ return caller;
5655
+ }
5656
+ //#endregion
5657
5657
  //#region src/shared/llm-vision/prompt-hygiene.ts
5658
5658
  /**
5659
5659
  * What a PIPELINE VALUE is allowed to look like once it is inside a prompt.
@@ -6288,25 +6288,46 @@ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6288
6288
  /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6289
6289
  var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6290
6290
  /**
6291
- * Kinds whose action is "go and look at why this camera stopped producing".
6291
+ * Kinds whose action is "go and look at this device's stream".
6292
+ *
6293
+ * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
6294
+ * because "did it really come back, and what did it do while it was gone" is
6295
+ * an action and the page that answers it is the one the down-notification
6296
+ * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
6297
+ * Videocamera sala feste è online" with `tapAction.value: null` — the same
6298
+ * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
6299
+ * the recovery opened nothing, purely because `device-online` had no entry
6300
+ * here.
6292
6301
  *
6293
- * The legacy `camera-offline` is here for the same reason it is still in the
6294
- * enum: outbox and history rows enqueued before the rename carry it, and an
6302
+ * The legacy `camera-*` pair is here for the same reason both are still in the
6303
+ * enum: outbox and history rows enqueued before the rename carry them, and an
6295
6304
  * old row is exactly the one whose action has to keep working.
6305
+ *
6306
+ * Kinds NOT here are kinds whose DOWN direction has no action either
6307
+ * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
6308
+ * recovery does not earn a destination its failure was never given.
6296
6309
  */
6297
- var DEVICE_DOWN_KINDS = new Set([
6310
+ var DEVICE_STREAM_KINDS = new Set([
6298
6311
  "device-offline",
6312
+ "device-online",
6299
6313
  "stream-offline",
6300
- "camera-offline"
6314
+ "stream-online",
6315
+ "camera-offline",
6316
+ "camera-online"
6301
6317
  ]);
6302
6318
  /**
6303
6319
  * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6304
6320
  *
6305
- * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6306
- * is one too — that name is precisely the claim it made, and it is why the
6307
- * kind was split into `device-offline` + a `deviceTypes` condition.
6321
+ * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
6322
+ * too — that name is precisely the claim it made, and it is why the kind was
6323
+ * split into `device-*` + a `deviceTypes` condition.
6308
6324
  */
6309
- var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6325
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
6326
+ "stream-offline",
6327
+ "stream-online",
6328
+ "camera-offline",
6329
+ "camera-online"
6330
+ ]);
6310
6331
  function withParam(path, key, value) {
6311
6332
  if (value === void 0 || value.length === 0) return path;
6312
6333
  return `${path}?${key}=${encodeURIComponent(value)}`;
@@ -6321,7 +6342,7 @@ function withParam(path, key, value) {
6321
6342
  function systemEventTapPath(subject) {
6322
6343
  if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6323
6344
  if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6324
- if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6345
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
6325
6346
  const deviceId = subject.deviceId;
6326
6347
  if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6327
6348
  const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
@@ -10723,16 +10744,26 @@ function incomingFromUpdateAvailable(facts, data) {
10723
10744
  ...nodeIds.length > 0 ? { nodeIds } : {}
10724
10745
  });
10725
10746
  }
10726
- /** Version-bearing `addon.updated` only — settings saves carry neither. */
10747
+ /**
10748
+ * Version-bearing `addon.updated` only — a settings save carries no versions
10749
+ * at all (`BaseAddon.updateGlobalSettings`).
10750
+ *
10751
+ * `toVersion` IS the discriminator, and `fromVersion` is not: the emitter
10752
+ * omits the predecessor when it cannot read what was installed (a first
10753
+ * install, an unreadable tree). Requiring both dropped those updates on the
10754
+ * floor entirely — the operator got no notification for an update that
10755
+ * happened — which is the price that was being paid to keep `'0.0.0'`
10756
+ * out. Report the half that is known; say nothing about the half that is not.
10757
+ */
10727
10758
  function parseAddonUpdateComplete(data) {
10728
10759
  const packageName = stringField(data, "packageName");
10729
10760
  const fromVersion = stringField(data, "fromVersion");
10730
10761
  const toVersion = stringField(data, "toVersion");
10731
- if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10762
+ if (packageName === void 0 || toVersion === void 0) return null;
10732
10763
  const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10733
10764
  return {
10734
10765
  packageName,
10735
- fromVersion,
10766
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10736
10767
  toVersion,
10737
10768
  ...nodeId !== void 0 ? { nodeId } : {}
10738
10769
  };
@@ -10742,7 +10773,10 @@ function incomingFromAddonUpdates(facts, updates) {
10742
10773
  const packageNames = unique(updates.map((update) => update.packageName));
10743
10774
  const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10744
10775
  const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10745
- const list = updates.map((update) => update.nodeId !== void 0 ? `${update.packageName} ${update.fromVersion} → ${update.toVersion} (${update.nodeId})` : `${update.packageName} ${update.fromVersion} → ${update.toVersion}`).join("\n");
10776
+ const list = updates.map((update) => {
10777
+ const transition = update.fromVersion !== void 0 ? `${update.fromVersion} → ${update.toVersion}` : `→ ${update.toVersion}`;
10778
+ return update.nodeId !== void 0 ? `${update.packageName} ${transition} (${update.nodeId})` : `${update.packageName} ${transition}`;
10779
+ }).join("\n");
10746
10780
  const head = updates[0];
10747
10781
  return systemSubject({
10748
10782
  facts,
@@ -23564,17 +23598,17 @@ function planRuleTest(input) {
23564
23598
  deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
23565
23599
  nodeId: systemScope.nodeId ?? "hub",
23566
23600
  packageName: systemScope.packageName ?? "@camstack/server",
23567
- currentVersion: "0.0.0",
23568
- latestVersion: "0.0.0",
23601
+ currentVersion: "1.0.0",
23602
+ latestVersion: "1.0.1",
23569
23603
  profile: "low",
23570
23604
  seconds: "30",
23571
23605
  devices: "cuda:0",
23572
23606
  minutes: "15",
23573
23607
  addons: TEST_CRASHED_RUNNER,
23574
- list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
23608
+ list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
23575
23609
  nodes: "hub",
23576
- fromVersion: "0.0.0",
23577
- toVersion: "0.0.1",
23610
+ fromVersion: "1.0.0",
23611
+ toVersion: "1.0.1",
23578
23612
  title: "Export",
23579
23613
  count: "1"
23580
23614
  },
@@ -25638,6 +25672,67 @@ var MEDIA_INDEXES = [
25638
25672
  {
25639
25673
  name: "idx_media_location",
25640
25674
  columns: ["locationId"]
25675
+ }),
25676
+ (
25677
+ /**
25678
+ * The events-management footprint — `GROUP BY deviceId` and
25679
+ * `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
25680
+ *
25681
+ * **Why not `idx_media_device_ts`.** That index already leads with
25682
+ * `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
25683
+ * so the sum has to fetch the table row behind every index entry it visits.
25684
+ * Over the whole collection that is one scattered read of the entire ~1.25 M
25685
+ * row table (`path` alone is ~80 characters per row) on the volume the
25686
+ * recorder is writing to. With `sizeBytes` in the index the planner never
25687
+ * touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
25688
+ *
25689
+ * It has to be a NEW index rather than a third column on the existing one.
25690
+ * `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
25691
+ * NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
25692
+ * every hub that already has it — the declaration would read as done and the
25693
+ * query would still hit the table.
25694
+ *
25695
+ * **What it costs.** ~19 bytes of index per row (two integers plus the
25696
+ * rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
25697
+ * insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
25698
+ * is noise beside the JPEG write the same call performs. The cost that is not
25699
+ * noise is the one-off: the first `declareCollection` after this ships builds
25700
+ * the index over every existing row. Do not deploy it beside a running drain
25701
+ * or seal — the same caveat `idx_media_location` carries, for the same
25702
+ * reason.
25703
+ */
25704
+ {
25705
+ name: "idx_media_device_size",
25706
+ columns: ["deviceId", "sizeBytes"]
25707
+ }),
25708
+ (
25709
+ /**
25710
+ * The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
25711
+ * the same scoped to one camera. See {@link MediaStore.footprintByKind}.
25712
+ *
25713
+ * `kind` leads no existing index, so without this each of the thirteen
25714
+ * per-kind questions is a full table scan and the breakdown costs thirteen of
25715
+ * them. With it, each kind is a contiguous covering range and the thirteen
25716
+ * together scan the index once — the cost of the whole breakdown is one pass
25717
+ * over ~36 MB of index instead of thirteen passes over the table.
25718
+ *
25719
+ * `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
25720
+ * (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
25721
+ * more than `(kind, sizeBytes)` would cost. Without it that query finds its
25722
+ * range from the index and then reads the table for every row in it.
25723
+ *
25724
+ * ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
25725
+ * on every insert. Same one-off caveat as the two indexes above: the first
25726
+ * declare builds it over every existing row, so do not ship it beside a
25727
+ * running drain or seal.
25728
+ */
25729
+ {
25730
+ name: "idx_media_kind_device_size",
25731
+ columns: [
25732
+ "kind",
25733
+ "deviceId",
25734
+ "sizeBytes"
25735
+ ]
25641
25736
  })
25642
25737
  ];
25643
25738
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -25724,7 +25819,7 @@ var MediaStore = class {
25724
25819
  });
25725
25820
  }
25726
25821
  /** Persist a media blob and register its metadata row. Returns the
25727
- * collection key (MediaFile.key) that callers store as reference. */
25822
+ * collection key (MediaFileRef.key) that callers store as reference. */
25728
25823
  async put(params) {
25729
25824
  if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
25730
25825
  return this.putUnchecked(params);
@@ -26046,8 +26141,20 @@ var MediaStore = class {
26046
26141
  * its device at write time, so scoping the query by it makes a media read
26047
26142
  * for a device the caller cannot name return nothing — the DB itself
26048
26143
  * enforces the boundary, no post-hoc ownership check to forget.
26144
+ *
26145
+ * `kinds` is the WORK filter, and it belongs here rather than on the answer.
26146
+ * Every row this returns has had its blob read off disk and base64-encoded,
26147
+ * so a caller that filters afterwards has already paid for every kind it is
26148
+ * about to discard: measured on the live hub, one track's six kinds are
26149
+ * 3 597 219 bytes and `thumbnailSmall` is 31 155 of them, and asking for the
26150
+ * small one alone cost exactly as much as asking for all six. `kind` is a
26151
+ * real column (see {@link MEDIA_COLUMNS}), so the narrowing happens in the
26152
+ * query and the blobs of the other kinds are never opened.
26153
+ *
26154
+ * Absent (or empty) means every kind — the back-compat default, and the
26155
+ * honest answer for a caller that has not decided what it needs.
26049
26156
  */
26050
- async listByOwner(ownerKind, ownerId, deviceId) {
26157
+ async listByOwner(ownerKind, ownerId, deviceId, kinds) {
26051
26158
  const rows = await this.store.query.query({
26052
26159
  collection: MEDIA_COLLECTION,
26053
26160
  filter: {
@@ -26059,6 +26166,7 @@ var MediaStore = class {
26059
26166
  ownerId,
26060
26167
  deviceId
26061
26168
  },
26169
+ ...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
26062
26170
  orderBy: {
26063
26171
  field: "timestamp",
26064
26172
  direction: "asc"
@@ -26279,47 +26387,136 @@ var MediaStore = class {
26279
26387
  };
26280
26388
  }
26281
26389
  /**
26282
- * On-demand footprint aggregation for the events-management UI: sum
26283
- * `sizeBytes` and count rows per device across the whole media collection.
26284
- * Pages by `id` (offset-based) so a large collection never loads at once.
26285
- * Metadata rows are small (no blob) — the image bytes live on the storage
26286
- * provider and are counted via the persisted `sizeBytes` column.
26390
+ * On-demand footprint aggregation for the events-management UI: rows and
26391
+ * summed `sizeBytes` per camera, across the whole media collection.
26392
+ *
26393
+ * ## Why this is a histogram plus one aggregate per camera
26394
+ *
26395
+ * It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
26396
+ * no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
26397
+ * stepping over `n` entries, so the walk is quadratic in the table: at the
26398
+ * live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
26399
+ * with every row serialised across an RPC boundary and rebuilt in V8 to be
26400
+ * added to a number. It timed out at 60 s on every call it ever made, and it
26401
+ * is the only thing that can size the surplus-media deletion it gates.
26402
+ *
26403
+ * The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
26404
+ *
26405
+ * - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
26406
+ * — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
26407
+ * scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
26408
+ * `bucket` IS the deviceId: the column is INTEGER and the bucket
26409
+ * expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
26410
+ * - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
26411
+ * `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
26412
+ *
26413
+ * Both figures for a camera come from the SAME statement deliberately. Taking
26414
+ * `rows` from the histogram and `bytes` from the aggregate would let a write
26415
+ * landing between the two produce a footprint whose row count and byte count
26416
+ * describe different populations — a disagreement an operator sizing a
26417
+ * deletion has no way to detect.
26418
+ *
26419
+ * A read that fails THROWS. It must never degrade to a zero: "this camera
26420
+ * holds nothing" is precisely the claim a failed measurement must not be
26421
+ * allowed to make to something about to delete (D295, D312).
26287
26422
  */
26288
26423
  async footprintByDevice() {
26289
- const PAGE = 1e3;
26424
+ const buckets = await this.store.histogram.query({
26425
+ collection: MEDIA_COLLECTION,
26426
+ field: "deviceId",
26427
+ bucketSize: 1,
26428
+ origin: 0
26429
+ });
26290
26430
  const out = /* @__PURE__ */ new Map();
26291
- let offset = 0;
26292
- for (;;) {
26293
- const rows = await this.store.query.query({
26431
+ for (const bucket of buckets) {
26432
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
26433
+ const deviceId = bucket.bucket;
26434
+ const agg = await this.store.aggregate.query({
26294
26435
  collection: MEDIA_COLLECTION,
26295
- filter: {
26296
- orderBy: {
26297
- field: "id",
26298
- direction: "asc"
26299
- },
26300
- limit: PAGE,
26301
- offset
26302
- }
26436
+ fields: [{
26437
+ as: "bytes",
26438
+ field: "sizeBytes",
26439
+ op: "sum"
26440
+ }],
26441
+ filter: { where: { deviceId } }
26442
+ });
26443
+ if (agg.count === 0) continue;
26444
+ const bytes = agg.values["bytes"];
26445
+ out.set(deviceId, {
26446
+ bytes: bytes ?? 0,
26447
+ rows: agg.count
26303
26448
  });
26304
- if (rows.length === 0) break;
26305
- for (const row of rows) {
26306
- const data = row.data;
26307
- const deviceId = Number(data["deviceId"]);
26308
- const sizeBytes = Number(data["sizeBytes"]);
26309
- if (!Number.isFinite(deviceId)) continue;
26310
- const acc = out.get(deviceId) ?? {
26311
- bytes: 0,
26312
- rows: 0
26313
- };
26314
- acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
26315
- acc.rows += 1;
26316
- out.set(deviceId, acc);
26317
- }
26318
- if (rows.length < PAGE) break;
26319
- offset += PAGE;
26320
26449
  }
26321
26450
  return out;
26322
26451
  }
26452
+ /**
26453
+ * The media footprint broken down by KIND — fleet-wide, or for one camera.
26454
+ *
26455
+ * `footprintByDevice` answers "how much is where"; this answers "how much is
26456
+ * WHAT", which is the axis a deletion decision turns on. The deletable set
26457
+ * (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
26458
+ * the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
26459
+ * `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
26460
+ * gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
26461
+ * else. A byte total cannot size a deletion; this can.
26462
+ *
26463
+ * One `aggregate` per kind, each an indexed covering range on
26464
+ * `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
26465
+ *
26466
+ * That last statement is the point of the design and not a spare round trip.
26467
+ * `MediaFileKindEnum.options` is the closed set the writers use, but the
26468
+ * TABLE is not closed: a row written by a retired path, or by a version that
26469
+ * knew a kind this one does not, matches no per-kind query. Adding the kinds
26470
+ * up to produce the total would make such a row invisible and the denominator
26471
+ * quietly too small — the failure mode that matters most here, because the
26472
+ * caller is about to delete against it. So the total is measured
26473
+ * independently and the difference is REPORTED as `unaccounted*` rather than
26474
+ * being absorbed.
26475
+ */
26476
+ async footprintByKind(deviceId) {
26477
+ const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
26478
+ const fields = [{
26479
+ as: "bytes",
26480
+ field: "sizeBytes",
26481
+ op: "sum"
26482
+ }];
26483
+ const totals = await this.store.aggregate.query({
26484
+ collection: MEDIA_COLLECTION,
26485
+ fields: [...fields],
26486
+ ...scope === void 0 ? {} : { filter: scope }
26487
+ });
26488
+ const kinds = [];
26489
+ let countedRows = 0;
26490
+ let countedBytes = 0;
26491
+ for (const kind of MediaFileKindEnum.options) {
26492
+ const agg = await this.store.aggregate.query({
26493
+ collection: MEDIA_COLLECTION,
26494
+ fields: [...fields],
26495
+ filter: { where: {
26496
+ kind,
26497
+ ...deviceId === void 0 ? {} : { deviceId }
26498
+ } }
26499
+ });
26500
+ if (agg.count === 0) continue;
26501
+ const bytes = agg.values["bytes"] ?? 0;
26502
+ countedRows += agg.count;
26503
+ countedBytes += bytes;
26504
+ kinds.push({
26505
+ kind,
26506
+ rows: agg.count,
26507
+ bytes
26508
+ });
26509
+ }
26510
+ kinds.sort((a, b) => b.bytes - a.bytes);
26511
+ const totalBytes = totals.values["bytes"] ?? 0;
26512
+ return {
26513
+ totalRows: totals.count,
26514
+ totalBytes,
26515
+ kinds,
26516
+ unaccountedRows: totals.count - countedRows,
26517
+ unaccountedBytes: totalBytes - countedBytes
26518
+ };
26519
+ }
26323
26520
  /** Move a single media entry from one owner to another.
26324
26521
  * The new copy is written before the old one is removed.
26325
26522
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -27974,6 +28171,63 @@ var embeddingActions = defineCustomActions({
27974
28171
  "embedding.reembedFacesStatus": customAction(NoInputSchema$1, ReembedFacesStatusSchema)
27975
28172
  });
27976
28173
  //#endregion
28174
+ //#region src/pipeline-analytics/media-file-url.ts
28175
+ /**
28176
+ * The plane path for one stored media key.
28177
+ *
28178
+ * `prefix` is `/addon/<addonId>/event-media` — a pure function of the addon
28179
+ * id, so it exists whether or not `dataPlane.serve` succeeded. A row that
28180
+ * named no address at all would be the silent drop this repo keeps paying for:
28181
+ * a client cannot tell "no bytes" from "no plane", and renders neither.
28182
+ */
28183
+ function mediaFileUrl(prefix, key) {
28184
+ return `${prefix}/${encodeURIComponent(key)}`;
28185
+ }
28186
+ /**
28187
+ * A stored row as the wire sees it: the ref, the address, and — transitionally
28188
+ * — the bytes.
28189
+ *
28190
+ * `base64` is copied through ONLY because the deployed viewer's track-detail
28191
+ * hero still requires the field to parse a row at all. See the `MediaFile`
28192
+ * docblock in `pipeline-analytics.cap.ts`; deleting the field is a one-line
28193
+ * change here and one in the schema.
28194
+ *
28195
+ * `provenance` is deliberately NOT spread: it is store-internal, and the
28196
+ * previous `{ ...row, kind }` shape published it by accident.
28197
+ */
28198
+ function toWireMediaFile(row, prefix) {
28199
+ return {
28200
+ key: row.key,
28201
+ kind: row.kind,
28202
+ sizeBytes: row.sizeBytes,
28203
+ timestamp: row.timestamp,
28204
+ url: mediaFileUrl(prefix, row.key),
28205
+ base64: row.base64
28206
+ };
28207
+ }
28208
+ /** The same row without its bytes — the manifest shape. Carries the SAME
28209
+ * `url` as the byte read, so a client never builds the plane path itself. */
28210
+ function toWireMediaFileInfo(row, prefix) {
28211
+ return {
28212
+ key: row.key,
28213
+ kind: row.kind,
28214
+ sizeBytes: row.sizeBytes,
28215
+ timestamp: row.timestamp,
28216
+ url: mediaFileUrl(prefix, row.key)
28217
+ };
28218
+ }
28219
+ //#endregion
28220
+ //#region src/pipeline-analytics/gallery-media-row.ts
28221
+ function toGalleryMediaRow(row, prefix) {
28222
+ return {
28223
+ key: row.key,
28224
+ kind: row.kind,
28225
+ url: mediaFileUrl(prefix, row.key),
28226
+ sizeBytes: row.sizeBytes,
28227
+ timestamp: row.timestamp
28228
+ };
28229
+ }
28230
+ //#endregion
27977
28231
  //#region src/pipeline-analytics/include-crops-default.ts
27978
28232
  function resolveIncludeCrops(input) {
27979
28233
  return input?.includeCrops === true;
@@ -29633,6 +29887,7 @@ var FaceGalleryProvider = class {
29633
29887
  logger;
29634
29888
  refreshGallery;
29635
29889
  eventMediaBaseUrl;
29890
+ eventMediaPathPrefix;
29636
29891
  emitFaceGalleryChanged;
29637
29892
  constructor(deps) {
29638
29893
  this.identityStore = deps.identityStore;
@@ -29643,6 +29898,7 @@ var FaceGalleryProvider = class {
29643
29898
  this.logger = deps.logger;
29644
29899
  this.refreshGallery = deps.refreshGallery;
29645
29900
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
29901
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
29646
29902
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
29647
29903
  }
29648
29904
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -29830,13 +30086,7 @@ var FaceGalleryProvider = class {
29830
30086
  * the enrolled identity copies — those live under identity owner).
29831
30087
  */
29832
30088
  async getFaceMedia(input) {
29833
- return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => ({
29834
- key: m.key,
29835
- kind: m.kind,
29836
- base64: m.base64,
29837
- sizeBytes: m.sizeBytes,
29838
- timestamp: m.timestamp
29839
- }));
30089
+ return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
29840
30090
  }
29841
30091
  /**
29842
30092
  * Assign a buffered face to an identity:
@@ -32547,50 +32797,136 @@ var FrameProcessor = class {
32547
32797
  };
32548
32798
  }
32549
32799
  };
32550
- //#endregion
32551
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
32800
+ /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32801
+ * evicts the oldest record. */
32802
+ var PendingPreviewUpgrades = class {
32803
+ entries = /* @__PURE__ */ new Map();
32804
+ ttlMs;
32805
+ maxEntries;
32806
+ now;
32807
+ constructor(options = {}) {
32808
+ this.ttlMs = options.ttlMs ?? 9e5;
32809
+ this.maxEntries = Math.max(1, options.maxEntries ?? 512);
32810
+ this.now = options.now ?? (() => Date.now());
32811
+ }
32812
+ /** Record (or replace) a closed track's pending upgrade. Prunes expired
32813
+ * entries first, then evicts the oldest while over the cap. */
32814
+ record(entry) {
32815
+ this.prune();
32816
+ this.entries.delete(entry.trackId);
32817
+ this.entries.set(entry.trackId, entry);
32818
+ while (this.entries.size > this.maxEntries) {
32819
+ const oldest = this.entries.keys().next();
32820
+ if (oldest.done === true) break;
32821
+ this.entries.delete(oldest.value);
32822
+ }
32823
+ }
32824
+ /** Claim a track's pending upgrade — removes it, so a second landing (or a
32825
+ * concurrent racer) finds nothing and the tile is rewritten at most once. */
32826
+ take(trackId) {
32827
+ this.prune();
32828
+ const entry = this.entries.get(trackId);
32829
+ if (entry === void 0) return void 0;
32830
+ this.entries.delete(trackId);
32831
+ return entry;
32832
+ }
32833
+ get size() {
32834
+ return this.entries.size;
32835
+ }
32836
+ prune() {
32837
+ const cutoff = this.now() - this.ttlMs;
32838
+ for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
32839
+ }
32840
+ };
32552
32841
  /**
32553
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
32554
- * return the tracks it produced.
32555
- *
32556
- * No `TrackStore`, no bus, no media — a track here is a summary built
32557
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
32842
+ * Redeem a closed track's pending preview off a keyFrame that landed late.
32843
+ * Every non-`upgraded` outcome that had a pending entry logs itself — a
32844
+ * branch that leaves the stand-in in place must say so.
32558
32845
  */
32559
- function runFrameProcessorPass(input) {
32560
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
32561
- if (input.zones !== void 0) processor.setZones(input.zones);
32562
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
32563
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
32564
- const accum = /* @__PURE__ */ new Map();
32565
- for (const { timestamp, frame } of input.frames) {
32566
- const result = processor.process({
32567
- timestamp,
32568
- frame
32846
+ async function runPreviewUpgrade(deps, input) {
32847
+ const { deviceId, trackId } = input;
32848
+ const pending = deps.takePending(trackId);
32849
+ if (pending === void 0) return "no-pending";
32850
+ const held = await deps.heldThumbnailProvenance(trackId);
32851
+ if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
32852
+ deps.logger.info("late keyFrame: preview already native — no upgrade", {
32853
+ tags: { deviceId },
32854
+ meta: { trackId }
32569
32855
  });
32570
- for (const td of result.tracked) {
32571
- const existing = accum.get(td.trackId);
32572
- const matched = td.matchedThisFrame ? 1 : 0;
32573
- if (existing === void 0) accum.set(td.trackId, {
32574
- className: td.className,
32575
- firstSeenMs: timestamp,
32576
- lastSeenMs: timestamp,
32577
- bbox: { ...td.bbox },
32578
- framesMatched: matched
32579
- });
32580
- else {
32581
- existing.className = td.className;
32582
- existing.lastSeenMs = timestamp;
32583
- existing.framesMatched += matched;
32856
+ return "held-native";
32857
+ }
32858
+ if (pending.bestSeen === void 0) {
32859
+ deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
32860
+ tags: { deviceId },
32861
+ meta: {
32862
+ trackId,
32863
+ heldProvenance: held
32584
32864
  }
32585
- }
32865
+ });
32866
+ return "no-bbox";
32586
32867
  }
32587
- return { tracks: [...accum.values()].map((t) => ({
32588
- className: t.className,
32589
- firstSeenMs: t.firstSeenMs,
32590
- lastSeenMs: t.lastSeenMs,
32591
- bbox: t.bbox,
32592
- framesMatched: t.framesMatched
32593
- })) };
32868
+ if (pending.bestSeen.timestamp === void 0) {
32869
+ deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32870
+ tags: { deviceId },
32871
+ meta: {
32872
+ trackId,
32873
+ keyFrameKey: input.keyFrameKey,
32874
+ heldProvenance: held
32875
+ }
32876
+ });
32877
+ return "no-bbox";
32878
+ }
32879
+ const derived = await deps.derive({
32880
+ deviceId,
32881
+ mediaKey: input.keyFrameKey,
32882
+ bbox: {
32883
+ x: pending.bestSeen.x,
32884
+ y: pending.bestSeen.y,
32885
+ w: pending.bestSeen.w,
32886
+ h: pending.bestSeen.h
32887
+ },
32888
+ frameWidth: pending.bestSeen.frameWidth,
32889
+ frameHeight: pending.bestSeen.frameHeight,
32890
+ timestamp: pending.bestSeen.timestamp
32891
+ });
32892
+ if (derived === null) {
32893
+ deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
32894
+ tags: { deviceId },
32895
+ meta: {
32896
+ trackId,
32897
+ keyFrameKey: input.keyFrameKey,
32898
+ heldProvenance: held
32899
+ }
32900
+ });
32901
+ return "derive-miss";
32902
+ }
32903
+ await deps.put({
32904
+ deviceId,
32905
+ trackId,
32906
+ kind: "thumbnail",
32907
+ timestamp: input.timestamp,
32908
+ data: derived.thumbnail,
32909
+ provenance: "native"
32910
+ });
32911
+ await deps.put({
32912
+ deviceId,
32913
+ trackId,
32914
+ kind: "thumbnailSmall",
32915
+ timestamp: input.timestamp,
32916
+ data: derived.thumbnailSmall,
32917
+ provenance: "native"
32918
+ });
32919
+ await deps.clearPreviewMiss(deviceId, trackId);
32920
+ deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
32921
+ tags: { deviceId },
32922
+ meta: {
32923
+ trackId,
32924
+ keyFrameKey: input.keyFrameKey,
32925
+ replaced: held ?? "none",
32926
+ skewMs: derived.skewMs
32927
+ }
32928
+ });
32929
+ return "upgraded";
32594
32930
  }
32595
32931
  function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32596
32932
  if (frameWidth <= 0 || frameHeight <= 0) return true;
@@ -32750,137 +33086,6 @@ function promoteSemanticBestFrame(deps, input) {
32750
33086
  captured: true
32751
33087
  };
32752
33088
  }
32753
- /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32754
- * evicts the oldest record. */
32755
- var PendingPreviewUpgrades = class {
32756
- entries = /* @__PURE__ */ new Map();
32757
- ttlMs;
32758
- maxEntries;
32759
- now;
32760
- constructor(options = {}) {
32761
- this.ttlMs = options.ttlMs ?? 9e5;
32762
- this.maxEntries = Math.max(1, options.maxEntries ?? 512);
32763
- this.now = options.now ?? (() => Date.now());
32764
- }
32765
- /** Record (or replace) a closed track's pending upgrade. Prunes expired
32766
- * entries first, then evicts the oldest while over the cap. */
32767
- record(entry) {
32768
- this.prune();
32769
- this.entries.delete(entry.trackId);
32770
- this.entries.set(entry.trackId, entry);
32771
- while (this.entries.size > this.maxEntries) {
32772
- const oldest = this.entries.keys().next();
32773
- if (oldest.done === true) break;
32774
- this.entries.delete(oldest.value);
32775
- }
32776
- }
32777
- /** Claim a track's pending upgrade — removes it, so a second landing (or a
32778
- * concurrent racer) finds nothing and the tile is rewritten at most once. */
32779
- take(trackId) {
32780
- this.prune();
32781
- const entry = this.entries.get(trackId);
32782
- if (entry === void 0) return void 0;
32783
- this.entries.delete(trackId);
32784
- return entry;
32785
- }
32786
- get size() {
32787
- return this.entries.size;
32788
- }
32789
- prune() {
32790
- const cutoff = this.now() - this.ttlMs;
32791
- for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
32792
- }
32793
- };
32794
- /**
32795
- * Redeem a closed track's pending preview off a keyFrame that landed late.
32796
- * Every non-`upgraded` outcome that had a pending entry logs itself — a
32797
- * branch that leaves the stand-in in place must say so.
32798
- */
32799
- async function runPreviewUpgrade(deps, input) {
32800
- const { deviceId, trackId } = input;
32801
- const pending = deps.takePending(trackId);
32802
- if (pending === void 0) return "no-pending";
32803
- const held = await deps.heldThumbnailProvenance(trackId);
32804
- if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
32805
- deps.logger.info("late keyFrame: preview already native — no upgrade", {
32806
- tags: { deviceId },
32807
- meta: { trackId }
32808
- });
32809
- return "held-native";
32810
- }
32811
- if (pending.bestSeen === void 0) {
32812
- deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
32813
- tags: { deviceId },
32814
- meta: {
32815
- trackId,
32816
- heldProvenance: held
32817
- }
32818
- });
32819
- return "no-bbox";
32820
- }
32821
- if (pending.bestSeen.timestamp === void 0) {
32822
- deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32823
- tags: { deviceId },
32824
- meta: {
32825
- trackId,
32826
- keyFrameKey: input.keyFrameKey,
32827
- heldProvenance: held
32828
- }
32829
- });
32830
- return "no-bbox";
32831
- }
32832
- const derived = await deps.derive({
32833
- deviceId,
32834
- mediaKey: input.keyFrameKey,
32835
- bbox: {
32836
- x: pending.bestSeen.x,
32837
- y: pending.bestSeen.y,
32838
- w: pending.bestSeen.w,
32839
- h: pending.bestSeen.h
32840
- },
32841
- frameWidth: pending.bestSeen.frameWidth,
32842
- frameHeight: pending.bestSeen.frameHeight,
32843
- timestamp: pending.bestSeen.timestamp
32844
- });
32845
- if (derived === null) {
32846
- deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
32847
- tags: { deviceId },
32848
- meta: {
32849
- trackId,
32850
- keyFrameKey: input.keyFrameKey,
32851
- heldProvenance: held
32852
- }
32853
- });
32854
- return "derive-miss";
32855
- }
32856
- await deps.put({
32857
- deviceId,
32858
- trackId,
32859
- kind: "thumbnail",
32860
- timestamp: input.timestamp,
32861
- data: derived.thumbnail,
32862
- provenance: "native"
32863
- });
32864
- await deps.put({
32865
- deviceId,
32866
- trackId,
32867
- kind: "thumbnailSmall",
32868
- timestamp: input.timestamp,
32869
- data: derived.thumbnailSmall,
32870
- provenance: "native"
32871
- });
32872
- await deps.clearPreviewMiss(deviceId, trackId);
32873
- deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
32874
- tags: { deviceId },
32875
- meta: {
32876
- trackId,
32877
- keyFrameKey: input.keyFrameKey,
32878
- replaced: held ?? "none",
32879
- skewMs: derived.skewMs
32880
- }
32881
- });
32882
- return "upgraded";
32883
- }
32884
33089
  //#endregion
32885
33090
  //#region src/pipeline-analytics/store/zone-geometry.ts
32886
33091
  /**
@@ -34619,6 +34824,7 @@ var PlateGalleryProvider = class {
34619
34824
  refreshGallery;
34620
34825
  emitPlateGalleryChanged;
34621
34826
  eventMediaBaseUrl;
34827
+ eventMediaPathPrefix;
34622
34828
  constructor(deps) {
34623
34829
  this.plateStore = deps.plateStore;
34624
34830
  this.vehicleStore = deps.vehicleStore;
@@ -34629,6 +34835,7 @@ var PlateGalleryProvider = class {
34629
34835
  this.refreshGallery = deps.refreshGallery;
34630
34836
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
34631
34837
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
34838
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
34632
34839
  }
34633
34840
  /** Best-effort emit — never allowed to fail the mutation it follows. */
34634
34841
  safeEmitPlateGalleryChanged(payload) {
@@ -34699,13 +34906,7 @@ var PlateGalleryProvider = class {
34699
34906
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34700
34907
  }
34701
34908
  async getPlateMedia(input) {
34702
- return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
34703
- key: m.key,
34704
- kind: m.kind,
34705
- base64: m.base64,
34706
- sizeBytes: m.sizeBytes,
34707
- timestamp: m.timestamp
34708
- }));
34909
+ return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
34709
34910
  }
34710
34911
  async searchPlates(input) {
34711
34912
  const maxDistance = input.maxDistance ?? 1;
@@ -34948,6 +35149,51 @@ var PlateGalleryProvider = class {
34948
35149
  }
34949
35150
  };
34950
35151
  //#endregion
35152
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
35153
+ /**
35154
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
35155
+ * return the tracks it produced.
35156
+ *
35157
+ * No `TrackStore`, no bus, no media — a track here is a summary built
35158
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
35159
+ */
35160
+ function runFrameProcessorPass(input) {
35161
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
35162
+ if (input.zones !== void 0) processor.setZones(input.zones);
35163
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
35164
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
35165
+ const accum = /* @__PURE__ */ new Map();
35166
+ for (const { timestamp, frame } of input.frames) {
35167
+ const result = processor.process({
35168
+ timestamp,
35169
+ frame
35170
+ });
35171
+ for (const td of result.tracked) {
35172
+ const existing = accum.get(td.trackId);
35173
+ const matched = td.matchedThisFrame ? 1 : 0;
35174
+ if (existing === void 0) accum.set(td.trackId, {
35175
+ className: td.className,
35176
+ firstSeenMs: timestamp,
35177
+ lastSeenMs: timestamp,
35178
+ bbox: { ...td.bbox },
35179
+ framesMatched: matched
35180
+ });
35181
+ else {
35182
+ existing.className = td.className;
35183
+ existing.lastSeenMs = timestamp;
35184
+ existing.framesMatched += matched;
35185
+ }
35186
+ }
35187
+ }
35188
+ return { tracks: [...accum.values()].map((t) => ({
35189
+ className: t.className,
35190
+ firstSeenMs: t.firstSeenMs,
35191
+ lastSeenMs: t.lastSeenMs,
35192
+ bbox: t.bbox,
35193
+ framesMatched: t.framesMatched
35194
+ })) };
35195
+ }
35196
+ //#endregion
34951
35197
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
34952
35198
  /**
34953
35199
  * Disk-wins reconcile for pipeline analytics.
@@ -35012,92 +35258,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
35012
35258
  };
35013
35259
  }
35014
35260
  //#endregion
35015
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35016
- /**
35017
- * The operator button for the debug-media reclaim.
35018
- *
35019
- * The mechanism ships armed with nothing: **arming it is the operator's
35020
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35021
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35022
- * that has to be typed.
35023
- *
35024
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35025
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35026
- * report you can read instead of a log line you have to find.
35027
- */
35028
- /**
35029
- * `report` counts; `reclaim` deletes.
35030
- *
35031
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
35032
- * button with no fields, and the safe reading of an unstated intent is the one
35033
- * that destroys nothing.
35034
- */
35035
- var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35036
- var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35037
- var MediaReclaimActionInputSchema = object({
35038
- mode: MediaReclaimActionModeSchema,
35039
- /** Which populations to walk. Omitted = both. */
35040
- scopes: array(MediaReclaimScopeSchema).min(1).optional(),
35041
- /** Restrict to these cameras. Omitted = every camera in retention scope. */
35042
- deviceIds: array(number().int()).min(1).optional(),
35043
- /** Start every device from the top instead of resuming its cursor. */
35044
- restart: boolean().optional(),
35045
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
35046
- pageSize: number().int().min(50).max(5e3).optional(),
35047
- /** Rows EXAMINED per camera in this pass — the pacing bound. */
35048
- maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
35049
- /** Rows DELETED per camera in this pass — the blast-radius bound. */
35050
- maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
35051
- /** Bytes released across the whole run — the disk-contention bound. */
35052
- maxBytesPerRun: number().int().min(1).optional(),
35053
- /** Wall-clock ceiling for the run. */
35054
- budgetMinutes: number().int().min(1).max(720).optional(),
35055
- /**
35056
- * Bytes per second the pass may release. The recordings volume sustains
35057
- * ~4 MB/s and the archive needs it more than this does; the default is half
35058
- * of that.
35059
- */
35060
- throttleBytesPerSec: number().int().min(64 * 1024).optional(),
35061
- /**
35062
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
35063
- * exclusively save rows, never select them.
35064
- */
35065
- graceMinutes: number().int().min(1).max(10080).optional()
35066
- });
35067
- var MediaReclaimDeviceReportSchema = object({
35068
- deviceId: number(),
35069
- scope: MediaReclaimScopeSchema,
35070
- examined: number(),
35071
- eligible: number(),
35072
- reclaimed: number(),
35073
- bytesReclaimed: number(),
35074
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35075
- held: number(),
35076
- /** Rows kept because the owner could not be shown to be a motion event. */
35077
- unresolved: number(),
35078
- withinGrace: number(),
35079
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
35080
- * The row was kept and the cursor did not pass it. */
35081
- refused: number(),
35082
- stoppedBy: string(),
35083
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35084
- nextCursor: number()
35085
- });
35086
- var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
35087
- /** `false` when a pass was already in flight — nothing was done. */
35088
- ran: boolean(),
35089
- mode: _enum(["report", "reclaim"]),
35090
- devices: array(MediaReclaimDeviceReportSchema),
35091
- totalExamined: number(),
35092
- totalEligible: number(),
35093
- totalReclaimed: number(),
35094
- totalBytesReclaimed: number(),
35095
- totalRefused: number(),
35096
- durationMs: number(),
35097
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
35098
- complete: boolean()
35099
- }), { auth: "admin" }) });
35100
- //#endregion
35101
35261
  //#region src/pipeline-analytics/retention/media-reclaim.ts
35102
35262
  var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
35103
35263
  var DEFAULT_MEDIA_RECLAIM_LIMITS = {
@@ -35308,6 +35468,92 @@ async function runMediaReclaim(input) {
35308
35468
  cursors: nextCursors
35309
35469
  };
35310
35470
  }
35471
+ //#endregion
35472
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35473
+ /**
35474
+ * The operator button for the debug-media reclaim.
35475
+ *
35476
+ * The mechanism ships armed with nothing: **arming it is the operator's
35477
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35478
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35479
+ * that has to be typed.
35480
+ *
35481
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35482
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35483
+ * report you can read instead of a log line you have to find.
35484
+ */
35485
+ /**
35486
+ * `report` counts; `reclaim` deletes.
35487
+ *
35488
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35489
+ * button with no fields, and the safe reading of an unstated intent is the one
35490
+ * that destroys nothing.
35491
+ */
35492
+ var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35493
+ var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35494
+ var MediaReclaimActionInputSchema = object({
35495
+ mode: MediaReclaimActionModeSchema,
35496
+ /** Which populations to walk. Omitted = both. */
35497
+ scopes: array(MediaReclaimScopeSchema).min(1).optional(),
35498
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35499
+ deviceIds: array(number().int()).min(1).optional(),
35500
+ /** Start every device from the top instead of resuming its cursor. */
35501
+ restart: boolean().optional(),
35502
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35503
+ pageSize: number().int().min(50).max(5e3).optional(),
35504
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35505
+ maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
35506
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35507
+ maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
35508
+ /** Bytes released across the whole run — the disk-contention bound. */
35509
+ maxBytesPerRun: number().int().min(1).optional(),
35510
+ /** Wall-clock ceiling for the run. */
35511
+ budgetMinutes: number().int().min(1).max(720).optional(),
35512
+ /**
35513
+ * Bytes per second the pass may release. The recordings volume sustains
35514
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35515
+ * of that.
35516
+ */
35517
+ throttleBytesPerSec: number().int().min(64 * 1024).optional(),
35518
+ /**
35519
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35520
+ * exclusively save rows, never select them.
35521
+ */
35522
+ graceMinutes: number().int().min(1).max(10080).optional()
35523
+ });
35524
+ var MediaReclaimDeviceReportSchema = object({
35525
+ deviceId: number(),
35526
+ scope: MediaReclaimScopeSchema,
35527
+ examined: number(),
35528
+ eligible: number(),
35529
+ reclaimed: number(),
35530
+ bytesReclaimed: number(),
35531
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35532
+ held: number(),
35533
+ /** Rows kept because the owner could not be shown to be a motion event. */
35534
+ unresolved: number(),
35535
+ withinGrace: number(),
35536
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35537
+ * The row was kept and the cursor did not pass it. */
35538
+ refused: number(),
35539
+ stoppedBy: string(),
35540
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35541
+ nextCursor: number()
35542
+ });
35543
+ var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
35544
+ /** `false` when a pass was already in flight — nothing was done. */
35545
+ ran: boolean(),
35546
+ mode: _enum(["report", "reclaim"]),
35547
+ devices: array(MediaReclaimDeviceReportSchema),
35548
+ totalExamined: number(),
35549
+ totalEligible: number(),
35550
+ totalReclaimed: number(),
35551
+ totalBytesReclaimed: number(),
35552
+ totalRefused: number(),
35553
+ durationMs: number(),
35554
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35555
+ complete: boolean()
35556
+ }), { auth: "admin" }) });
35311
35557
  var OrphanAuditActionInputSchema = object({
35312
35558
  mode: _enum(["report", "reclaim"]).default("report"),
35313
35559
  /**
@@ -36627,6 +36873,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
36627
36873
  return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
36628
36874
  }
36629
36875
  /**
36876
+ * The STORED kinds worth reading from one scope for a `kinds` request, or
36877
+ * `null` when the scope cannot contribute to it at all.
36878
+ *
36879
+ * This is the same vocabulary translation {@link collectTrackMedia} performs on
36880
+ * the way out, moved to the way IN so it can narrow the query instead of the
36881
+ * answer. A caller naming `faceCrop` must ask the face owner for its stored
36882
+ * `crop` — asking it for `faceCrop` matches nothing, and asking it for
36883
+ * everything opens blobs the answer will discard.
36884
+ *
36885
+ * `undefined` means "every kind": the honest translation of an unfiltered
36886
+ * request, and the only safe answer for the open-ended track scope.
36887
+ *
36888
+ * `null` (skip) is only ever returned for a DETAIL owner whose one publishable
36889
+ * kind was not asked for. The track scope is never skipped — its kinds are
36890
+ * open-ended, and dropping it on a guess is how a listing starts lying.
36891
+ */
36892
+ function storedKindsForScope(scope, kinds) {
36893
+ if (kinds === void 0 || kinds.length === 0) return void 0;
36894
+ if (scope.publishCropAs === null) return kinds;
36895
+ return kinds.includes(scope.publishCropAs) ? [STORED_DETAIL_CROP_KIND] : null;
36896
+ }
36897
+ /**
36630
36898
  * Fold every scope's rows into ONE track media listing: detail crops
36631
36899
  * relabelled, everything filtered to `kinds`, ordered oldest first.
36632
36900
  *
@@ -36763,8 +37031,10 @@ var AnalyticsQueryFacade = class {
36763
37031
  return this.withMediaUrl(events);
36764
37032
  }
36765
37033
  async getEventMedia(input) {
36766
- const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
36767
- return input.kind ? all.filter((m) => m.kind === input.kind) : all;
37034
+ const store = this.deps.mediaStore();
37035
+ if (store === null) return [];
37036
+ const prefix = this.deps.eventMediaPathPrefix();
37037
+ return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
36768
37038
  }
36769
37039
  /**
36770
37040
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -36777,14 +37047,36 @@ var AnalyticsQueryFacade = class {
36777
37047
  async getTrackMedia(input) {
36778
37048
  const store = this.deps.mediaStore();
36779
37049
  if (store === null) return [];
37050
+ const prefix = this.deps.eventMediaPathPrefix();
36780
37051
  const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
36781
- return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
36782
- scope,
36783
- rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36784
- }))), (row, kind) => ({
37052
+ return collectTrackMedia(await Promise.all(scopes.map(async (scope) => {
37053
+ const stored = storedKindsForScope(scope, input.kinds);
37054
+ if (stored === null) return {
37055
+ scope,
37056
+ rows: []
37057
+ };
37058
+ return {
37059
+ scope,
37060
+ rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId, stored)
37061
+ };
37062
+ })), (row, kind) => ({
36785
37063
  ...row,
36786
37064
  kind
36787
- }), input.kinds);
37065
+ }), input.kinds).map((row) => toWireMediaFile(row, prefix));
37066
+ }
37067
+ /**
37068
+ * An event's media WITHOUT the bytes.
37069
+ *
37070
+ * Index rows only — it never opens a blob. The track detail modal unions
37071
+ * this with {@link listTrackMedia} to build its filmstrip and then fetches
37072
+ * each tile from the `event-media` plane by key, so the bytes this used to
37073
+ * carry were read, base64'd, shipped and discarded.
37074
+ */
37075
+ async listEventMedia(input) {
37076
+ const store = this.deps.mediaStore();
37077
+ if (store === null) return [];
37078
+ const prefix = this.deps.eventMediaPathPrefix();
37079
+ return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
36788
37080
  }
36789
37081
  /**
36790
37082
  * A track's media WITHOUT the bytes.
@@ -36802,13 +37094,14 @@ var AnalyticsQueryFacade = class {
36802
37094
  async listTrackMedia(input) {
36803
37095
  const store = this.deps.mediaStore();
36804
37096
  if (store === null) return [];
37097
+ const prefix = this.deps.eventMediaPathPrefix();
36805
37098
  return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
36806
37099
  scope,
36807
37100
  rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36808
37101
  }))), (row, kind) => ({
36809
37102
  ...row,
36810
37103
  kind
36811
- }), void 0);
37104
+ }), void 0).map((row) => toWireMediaFileInfo(row, prefix));
36812
37105
  }
36813
37106
  async pruneEventsBefore(input) {
36814
37107
  const eventStore = this.deps.eventStore();
@@ -36835,10 +37128,34 @@ var AnalyticsQueryFacade = class {
36835
37128
  }
36836
37129
  /**
36837
37130
  * Durable event-store footprint for the events-management UI: event ROWS
36838
- * (motion + object + audio) counted per camera (indexed `count`) + total, and
36839
- * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
36840
- * The device set is the union of cameras that have media OR persisted tracks;
36841
- * cameras with neither rows nor bytes are omitted.
37131
+ * (motion + object + audio) per camera + total, and event-owned media BYTES
37132
+ * on disk per camera + total. Cameras with neither rows nor bytes are
37133
+ * omitted; the rest come back ordered by bytes, descending, which is the
37134
+ * order the operator's table renders.
37135
+ *
37136
+ * ## The device set is a union of THREE sources
37137
+ *
37138
+ * It used to be `media ∪ tracks`, and a camera holding events but no media
37139
+ * row and no persisted track was therefore invisible — not listed, and its
37140
+ * events missing from `totalRows`. That combination is not exotic: media
37141
+ * retention, a media deletion and `pruneTracksBefore` all run on clocks of
37142
+ * their own, and every one of them can outlive the event rows. The number
37143
+ * this method exists to produce is the denominator for a deletion, so an
37144
+ * undercount is the one failure mode that matters, and it was silent.
37145
+ *
37146
+ * Events now contribute their own device set, so a camera is present if ANY
37147
+ * of the three sources has ever heard of it.
37148
+ *
37149
+ * ## The cost is the answer, not the table
37150
+ *
37151
+ * Three grouped reads for the event rows (one per event collection, never
37152
+ * three per camera), one grouped read plus one aggregate per camera for the
37153
+ * media bytes, one for the track device set. The retired shape paid ~1 250
37154
+ * round trips for the media half alone and timed out at 60 s on every call.
37155
+ * See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
37156
+ *
37157
+ * A store that cannot answer THROWS rather than returning zeroes — see the
37158
+ * same note on both.
36842
37159
  */
36843
37160
  async getEventStoreFootprint() {
36844
37161
  const eventStore = this.deps.eventStore();
@@ -36848,15 +37165,16 @@ var AnalyticsQueryFacade = class {
36848
37165
  totalBytes: 0,
36849
37166
  devices: []
36850
37167
  };
36851
- const mediaByDevice = await mediaStore.footprintByDevice();
37168
+ const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
36852
37169
  const deviceIds = new Set(mediaByDevice.keys());
37170
+ for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
36853
37171
  const trackStore = this.deps.trackStore();
36854
37172
  if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
36855
37173
  const devices = [];
36856
37174
  let totalRows = 0;
36857
37175
  let totalBytes = 0;
36858
37176
  for (const deviceId of deviceIds) {
36859
- const rows = await eventStore.countForDevice(deviceId);
37177
+ const rows = eventRowsByDevice.get(deviceId) ?? 0;
36860
37178
  const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
36861
37179
  if (rows === 0 && bytes === 0) continue;
36862
37180
  totalRows += rows;
@@ -36875,6 +37193,26 @@ var AnalyticsQueryFacade = class {
36875
37193
  };
36876
37194
  }
36877
37195
  /**
37196
+ * The media footprint by KIND — the axis that says what is safe to delete.
37197
+ * See {@link MediaStore.footprintByKind}.
37198
+ *
37199
+ * An absent media store is an empty breakdown, not a refusal: it is the same
37200
+ * "the addon has not finished booting" branch every other method here takes,
37201
+ * and the zeroes are accompanied by an empty `kinds` list, which no reader
37202
+ * can mistake for a measured "there is nothing".
37203
+ */
37204
+ async getEventMediaFootprintByKind(input) {
37205
+ const mediaStore = this.deps.mediaStore();
37206
+ if (!mediaStore) return {
37207
+ totalRows: 0,
37208
+ totalBytes: 0,
37209
+ kinds: [],
37210
+ unaccountedRows: 0,
37211
+ unaccountedBytes: 0
37212
+ };
37213
+ return mediaStore.footprintByKind(input.deviceId);
37214
+ }
37215
+ /**
36878
37216
  * Cluster-wide prune of events with `timestamp < olderThanMs` across every
36879
37217
  * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
36880
37218
  * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
@@ -39253,6 +39591,31 @@ function createEventMediaHandler(deps) {
39253
39591
  }
39254
39592
  //#endregion
39255
39593
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
39594
+ /**
39595
+ * Rank `candidates` with `pick`, then open ONE blob — the winner's.
39596
+ *
39597
+ * The ladders decide on `kind` alone, so the bytes are dead weight until the
39598
+ * choice is made. Handing them pre-read rows meant a tile that serves the
39599
+ * 31 KB `thumbnailSmall` first read the whole 3 597 219-byte set, including a
39600
+ * 2.8 MB `keyFrame` no list view has ever rendered.
39601
+ *
39602
+ * A winner whose blob is GONE is dropped and the ladder re-run on the rest.
39603
+ * That fall-through is not an extra: `listByOwner` used to provide it for free
39604
+ * by skipping unreadable rows before the ladder ever saw them, and without it
39605
+ * an index row that outlived its file would 404 a tile that used to render.
39606
+ * Bounded by the candidate count — each attempt removes one row.
39607
+ */
39608
+ async function readWinningBlob(candidates, pick, readBlob) {
39609
+ let remaining = candidates;
39610
+ while (remaining.length > 0) {
39611
+ const winner = pick(remaining);
39612
+ if (winner === void 0) return null;
39613
+ const bytes = await readBlob(winner.key);
39614
+ if (bytes !== null) return bytes;
39615
+ remaining = remaining.filter((row) => row.key !== winner.key);
39616
+ }
39617
+ return null;
39618
+ }
39256
39619
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
39257
39620
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
39258
39621
  * for new tracks (served only for old stored rows). Undefined when the event
@@ -39303,15 +39666,24 @@ async function collectCropForcedMedia(source, id) {
39303
39666
  };
39304
39667
  }
39305
39668
  /**
39306
- * Resolve the default-path media for an event id: the event's own media when it
39307
- * has any, else the owning track's fallback media. Returns `null` when neither
39308
- * yields a blob (caller 404s → the surface shows an icon).
39669
+ * Which rows the default path should serve from, and how to rank them: the
39670
+ * event's OWN media when it has any, else the owning track's.
39671
+ *
39672
+ * It yields the candidate SET rather than a winner so the caller can open one
39673
+ * blob and, if that blob has gone, re-rank the rest ({@link readWinningBlob}).
39674
+ * Returning a single row would force the caller to read it blind and 404 a tile
39675
+ * whose index row outlived its file.
39309
39676
  */
39310
- async function resolveDefaultEventMedia(deps) {
39311
- const owned = pickEventOwnedMedia(deps.eventFiles);
39312
- if (owned) return owned;
39677
+ async function collectDefaultEventMedia(deps) {
39678
+ if (deps.eventFiles.length > 0) return {
39679
+ candidates: deps.eventFiles,
39680
+ pick: pickEventOwnedMedia
39681
+ };
39313
39682
  const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
39314
- return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
39683
+ return {
39684
+ candidates: await deps.listTrackMedia(trackId),
39685
+ pick: pickTrackFallbackMedia
39686
+ };
39315
39687
  }
39316
39688
  //#endregion
39317
39689
  //#region src/pipeline-analytics/face-settings.ts
@@ -44508,6 +44880,15 @@ function snapshot(j) {
44508
44880
  */
44509
44881
  var LOCATION_ID_FIELD = "locationId";
44510
44882
  /**
44883
+ * Unstamped rows claimed per bulk seal statement.
44884
+ *
44885
+ * Bounds two things at once: the number of bound parameters in the `IN` list,
44886
+ * and how long one `UPDATE` holds the write lock on the database every process
44887
+ * in the cluster reads configuration through. Deliberately well under SQLite's
44888
+ * variable ceiling, and small enough that a cancel lands within one statement.
44889
+ */
44890
+ var SEAL_BATCH_SIZE = 500;
44891
+ /**
44511
44892
  * True when the row names NO location — the population `seal` exists for.
44512
44893
  *
44513
44894
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44739,6 +45120,17 @@ var MediaRelocateEngine = class {
44739
45120
  }
44740
45121
  async countSelected(mode, toLocationId, stop) {
44741
45122
  try {
45123
+ if (mode === "seal") {
45124
+ let sealable = 0;
45125
+ for (const collection of collectionsFor(mode)) {
45126
+ if (stop()) return null;
45127
+ sealable += await this.deps.store.count.query({
45128
+ collection: collection.name,
45129
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
45130
+ });
45131
+ }
45132
+ return { rows: sealable };
45133
+ }
44742
45134
  let rows = 0;
44743
45135
  for (const collection of collectionsFor(mode)) {
44744
45136
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44770,6 +45162,14 @@ var MediaRelocateEngine = class {
44770
45162
  * gets believed. */
44771
45163
  let pathlessRows = 0;
44772
45164
  job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45165
+ if (job.mode === "seal") {
45166
+ for (const collection of collectionsFor(job.mode)) {
45167
+ if (job.cancelRequested) break;
45168
+ await this.sealCollection(collection.name, job);
45169
+ }
45170
+ job.state = job.cancelRequested ? "cancelled" : "done";
45171
+ return;
45172
+ }
44773
45173
  for (const collection of collectionsFor(job.mode)) {
44774
45174
  if (job.cancelRequested) break;
44775
45175
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44777,20 +45177,6 @@ var MediaRelocateEngine = class {
44777
45177
  if (job.cancelRequested) break;
44778
45178
  const data = row.data;
44779
45179
  if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
44780
- if (job.mode === "seal") {
44781
- try {
44782
- await this.stamp(collection.name, row.id, data, job.toLocationId);
44783
- job.filesMoved++;
44784
- } catch (err) {
44785
- failedRows++;
44786
- this.deps.logger.debug("event-media seal row failed", { meta: {
44787
- collection: collection.name,
44788
- key: row.id,
44789
- error: String(err)
44790
- } });
44791
- }
44792
- continue;
44793
- }
44794
45180
  const from = mediaRowLocation(data);
44795
45181
  const relativePath = String(data["path"] ?? "");
44796
45182
  if (relativePath.length === 0) {
@@ -44853,6 +45239,73 @@ var MediaRelocateEngine = class {
44853
45239
  this.deps.onFinished?.(snapshot(job));
44854
45240
  }
44855
45241
  }
45242
+ /**
45243
+ * Stamp every unstamped row of one collection with the job's target, in
45244
+ * bounded bulk statements.
45245
+ *
45246
+ * ## What this replaced
45247
+ *
45248
+ * A per-row loop: page the whole collection through {@link pageRows}, then
45249
+ * one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
45250
+ * column to change one field, one RPC each, plus a second full walk before it
45251
+ * to produce the denominator. Measured on the live hub against the 464 902
45252
+ * unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
45253
+ * drain**, i.e. over two and a half days for a metadata stamp that reads no
45254
+ * file and moves no byte. It was cancelled. This is the write-side twin of
45255
+ * the read-side walk {@link countUnstamped} retired, and the fourth instance
45256
+ * of the same pattern found in one day — work looped over the door instead of
45257
+ * pushed into the statement.
45258
+ *
45259
+ * ## The shape
45260
+ *
45261
+ * Probe the indexed `IS NULL` range for a batch of ids, then ONE
45262
+ * `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
45263
+ * rows instead of one write per row.
45264
+ *
45265
+ * The probe carries no cursor and needs none: the predicate CONSUMES itself.
45266
+ * A stamped row leaves the range, so the next probe returns the next batch,
45267
+ * and a run that is interrupted anywhere simply resumes on what is left. That
45268
+ * is also why the seal is idempotent (a second run probes an empty range and
45269
+ * stamps nothing) and why it cannot repoint a row that already names a
45270
+ * different location — `IS NULL` never selects one.
45271
+ *
45272
+ * A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
45273
+ * statement instead of ~1 860, and is not used: half a million row rewrites,
45274
+ * each maintaining four indexes, is an unbounded statement holding the write
45275
+ * lock on a database the whole cluster reads configuration through, under an
45276
+ * RPC deadline that must not be raised. Batching bounds the statement, keeps
45277
+ * `cancel` responsive between batches, and keeps `filesMoved` honest.
45278
+ *
45279
+ * ## The termination guard
45280
+ *
45281
+ * If the probe finds rows and the update reports **0**, this THROWS. That is
45282
+ * not defensive noise: it is precisely what a backend compiling
45283
+ * `locationId = NULL` does — matches nothing, reports success (D312) — and
45284
+ * against it a self-consuming loop never terminates and a non-looping one
45285
+ * reports a completed seal having stamped nothing. Zero unstamped rows is the
45286
+ * single value the migration gate reads as "safe to cut over".
45287
+ */
45288
+ async sealCollection(collection, job) {
45289
+ for (;;) {
45290
+ if (job.cancelRequested) return;
45291
+ const batch = await this.deps.store.query.query({
45292
+ collection,
45293
+ filter: {
45294
+ where: { [LOCATION_ID_FIELD]: null },
45295
+ limit: SEAL_BATCH_SIZE
45296
+ },
45297
+ columns: [LOCATION_ID_FIELD]
45298
+ });
45299
+ if (batch.length === 0) return;
45300
+ const { updated } = await this.deps.store.updateWhere.mutate({
45301
+ collection,
45302
+ filter: { whereIn: { id: batch.map((row) => row.id) } },
45303
+ data: { [LOCATION_ID_FIELD]: job.toLocationId }
45304
+ });
45305
+ if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
45306
+ job.filesMoved += updated;
45307
+ }
45308
+ }
44856
45309
  async stamp(collection, key, data, locationId) {
44857
45310
  await this.deps.store.set.mutate({
44858
45311
  collection,
@@ -47418,6 +47871,70 @@ var PackageDropDetector = class {
47418
47871
  }
47419
47872
  };
47420
47873
  //#endregion
47874
+ //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
47875
+ /**
47876
+ * How much of the PERSON box must sit inside a vehicle box to be counted.
47877
+ *
47878
+ * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
47879
+ * routinely pokes a little outside the vehicle box, and requiring total
47880
+ * containment would count almost none of them. 0.8 and not 0.5: half a person
47881
+ * box overlapping a car is what a pedestrian walking in front of one looks
47882
+ * like, and counting those would make the number mean "somebody was near a
47883
+ * car", which is not a defect.
47884
+ */
47885
+ var CONTAINMENT_THRESHOLD = .8;
47886
+ /**
47887
+ * The classes that count as a vehicle for this test.
47888
+ *
47889
+ * A local list rather than a shared taxonomy on purpose: it exists to describe
47890
+ * ONE measurement, and a shared enum would make a change here silently change
47891
+ * detection behaviour somewhere else.
47892
+ */
47893
+ var VEHICLE_CLASSES$1 = [
47894
+ "car",
47895
+ "truck",
47896
+ "bus",
47897
+ "motorcycle",
47898
+ "vehicle"
47899
+ ];
47900
+ /** Area of the intersection of two boxes. `0` when they do not overlap. */
47901
+ function intersectionArea(a, b) {
47902
+ const left = Math.max(a.x, b.x);
47903
+ const right = Math.min(a.x + a.w, b.x + b.w);
47904
+ const top = Math.max(a.y, b.y);
47905
+ const bottom = Math.min(a.y + a.h, b.y + b.h);
47906
+ if (right <= left || bottom <= top) return 0;
47907
+ return (right - left) * (bottom - top);
47908
+ }
47909
+ /**
47910
+ * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
47911
+ *
47912
+ * `0` for a degenerate person box: a zero-area box is inside everything and
47913
+ * inside nothing, and answering `1` would count every one of them.
47914
+ */
47915
+ function containedFraction(person, vehicle) {
47916
+ const area = person.w * person.h;
47917
+ if (area <= 0) return 0;
47918
+ return intersectionArea(person, vehicle) / area;
47919
+ }
47920
+ /**
47921
+ * Is this person box contained in any vehicle box in the same frame?
47922
+ *
47923
+ * Returns the fraction for the BEST-containing vehicle when it clears
47924
+ * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
47925
+ * a counter that only ever says "yes" cannot later be re-thresholded against
47926
+ * what was actually measured.
47927
+ */
47928
+ function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
47929
+ let best = 0;
47930
+ for (const neighbour of neighbours) {
47931
+ if (!VEHICLE_CLASSES$1.includes(neighbour.className)) continue;
47932
+ const fraction = containedFraction(person, neighbour.bbox);
47933
+ if (fraction > best) best = fraction;
47934
+ }
47935
+ return best >= threshold ? best : null;
47936
+ }
47937
+ //#endregion
47421
47938
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
47422
47939
  /**
47423
47940
  * stationary-evidence — how a parked object EARNS a registry entry.
@@ -49949,83 +50466,6 @@ function buildRecordedStillPorts(input) {
49949
50466
  })
49950
50467
  };
49951
50468
  }
49952
- function errorMessage(err) {
49953
- return err instanceof Error ? err.message : String(err);
49954
- }
49955
- async function reconcileGallerySamples(deps, options) {
49956
- let checked = 0;
49957
- let reclaimed = 0;
49958
- let keyless = 0;
49959
- let skippedGrace = 0;
49960
- let stoppedByCeiling = false;
49961
- const identities = await deps.listIdentities();
49962
- for (const identity of identities) {
49963
- const samples = await deps.listSamples(identity.id);
49964
- for (const sample of samples) {
49965
- if (sample.mediaKey === void 0) {
49966
- keyless += 1;
49967
- continue;
49968
- }
49969
- if (options.now - sample.addedAt < options.graceMs) {
49970
- skippedGrace += 1;
49971
- continue;
49972
- }
49973
- if (reclaimed >= options.maxReclaimPerRun) {
49974
- stoppedByCeiling = true;
49975
- deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
49976
- return {
49977
- checked,
49978
- reclaimed,
49979
- keyless,
49980
- skippedGrace,
49981
- stoppedByCeiling
49982
- };
49983
- }
49984
- let state;
49985
- try {
49986
- state = await deps.blobState(sample.mediaKey);
49987
- } catch (err) {
49988
- const reason = errorMessage(err);
49989
- deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
49990
- sampleId: sample.id,
49991
- identity: identity.name,
49992
- error: reason
49993
- } });
49994
- return {
49995
- checked,
49996
- reclaimed,
49997
- keyless,
49998
- skippedGrace,
49999
- stoppedByCeiling,
50000
- aborted: reason
50001
- };
50002
- }
50003
- checked += 1;
50004
- if (state === "exists") continue;
50005
- await deps.removeSample(identity.id, sample.id);
50006
- reclaimed += 1;
50007
- deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50008
- sampleId: sample.id,
50009
- identity: identity.name,
50010
- mediaKey: sample.mediaKey,
50011
- state
50012
- } });
50013
- }
50014
- }
50015
- if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50016
- checked,
50017
- reclaimed,
50018
- keyless,
50019
- skippedGrace
50020
- } });
50021
- return {
50022
- checked,
50023
- reclaimed,
50024
- keyless,
50025
- skippedGrace,
50026
- stoppedByCeiling
50027
- };
50028
- }
50029
50469
  //#endregion
50030
50470
  //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
50031
50471
  /** The scope id this leg reports and persists its cursor under. */
@@ -50142,6 +50582,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
50142
50582
  nextCursor: Math.max(0, offset)
50143
50583
  };
50144
50584
  }
50585
+ function errorMessage(err) {
50586
+ return err instanceof Error ? err.message : String(err);
50587
+ }
50588
+ async function reconcileGallerySamples(deps, options) {
50589
+ let checked = 0;
50590
+ let reclaimed = 0;
50591
+ let keyless = 0;
50592
+ let skippedGrace = 0;
50593
+ let stoppedByCeiling = false;
50594
+ const identities = await deps.listIdentities();
50595
+ for (const identity of identities) {
50596
+ const samples = await deps.listSamples(identity.id);
50597
+ for (const sample of samples) {
50598
+ if (sample.mediaKey === void 0) {
50599
+ keyless += 1;
50600
+ continue;
50601
+ }
50602
+ if (options.now - sample.addedAt < options.graceMs) {
50603
+ skippedGrace += 1;
50604
+ continue;
50605
+ }
50606
+ if (reclaimed >= options.maxReclaimPerRun) {
50607
+ stoppedByCeiling = true;
50608
+ deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
50609
+ return {
50610
+ checked,
50611
+ reclaimed,
50612
+ keyless,
50613
+ skippedGrace,
50614
+ stoppedByCeiling
50615
+ };
50616
+ }
50617
+ let state;
50618
+ try {
50619
+ state = await deps.blobState(sample.mediaKey);
50620
+ } catch (err) {
50621
+ const reason = errorMessage(err);
50622
+ deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
50623
+ sampleId: sample.id,
50624
+ identity: identity.name,
50625
+ error: reason
50626
+ } });
50627
+ return {
50628
+ checked,
50629
+ reclaimed,
50630
+ keyless,
50631
+ skippedGrace,
50632
+ stoppedByCeiling,
50633
+ aborted: reason
50634
+ };
50635
+ }
50636
+ checked += 1;
50637
+ if (state === "exists") continue;
50638
+ await deps.removeSample(identity.id, sample.id);
50639
+ reclaimed += 1;
50640
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50641
+ sampleId: sample.id,
50642
+ identity: identity.name,
50643
+ mediaKey: sample.mediaKey,
50644
+ state
50645
+ } });
50646
+ }
50647
+ }
50648
+ if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50649
+ checked,
50650
+ reclaimed,
50651
+ keyless,
50652
+ skippedGrace
50653
+ } });
50654
+ return {
50655
+ checked,
50656
+ reclaimed,
50657
+ keyless,
50658
+ skippedGrace,
50659
+ stoppedByCeiling
50660
+ };
50661
+ }
50145
50662
  /**
50146
50663
  * Where the completion marker lives.
50147
50664
  *
@@ -51520,6 +52037,43 @@ var EventStore = class {
51520
52037
  return motion + object + audio;
51521
52038
  }
51522
52039
  /**
52040
+ * Persisted event rows (motion + object + audio) for EVERY device that has
52041
+ * any — three round trips for the whole fleet.
52042
+ *
52043
+ * {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
52044
+ * right for one camera and wrong in a loop: the events-management footprint
52045
+ * called it once per device, so the cost of "how many events does each camera
52046
+ * hold" grew with the camera count for a result that is a few dozen integers.
52047
+ *
52048
+ * A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
52049
+ * every event collection already carries `idx_<kind>_device_ts`, which leads
52050
+ * with `deviceId` — so the planner serves each of these from a covering index
52051
+ * with no table access at all. `bucket` IS the deviceId (the column is
52052
+ * INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
52053
+ *
52054
+ * Not best-effort: a failure propagates. The one caller is sizing a deletion,
52055
+ * and a camera silently reported as holding zero events is the answer that
52056
+ * must never come from a read that did not happen.
52057
+ */
52058
+ async countByDevice() {
52059
+ const perCollection = await Promise.all([
52060
+ MOTION_EVENTS_COLLECTION,
52061
+ OBJECT_EVENTS_COLLECTION,
52062
+ AUDIO_EVENTS_COLLECTION
52063
+ ].map((collection) => this.store.histogram.query({
52064
+ collection,
52065
+ field: "deviceId",
52066
+ bucketSize: 1,
52067
+ origin: 0
52068
+ })));
52069
+ const out = /* @__PURE__ */ new Map();
52070
+ for (const buckets of perCollection) for (const bucket of buckets) {
52071
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
52072
+ out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
52073
+ }
52074
+ return out;
52075
+ }
52076
+ /**
51523
52077
  * Return per-kind event counts in equal-width time buckets.
51524
52078
  *
51525
52079
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -53204,14 +53758,14 @@ function isAfterCursor(row, cursor) {
53204
53758
  if (row.lastSeen < cursor.lastSeen) return true;
53205
53759
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
53206
53760
  }
53207
- var VEHICLE_CLASSES$1 = new Set([
53761
+ var VEHICLE_CLASSES = new Set([
53208
53762
  "vehicle",
53209
53763
  "car",
53210
53764
  "truck",
53211
53765
  "bus"
53212
53766
  ]);
53213
53767
  function motionlessMaxPxForClass(className) {
53214
- return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
53768
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
53215
53769
  }
53216
53770
  function netDisplacementPx(positions) {
53217
53771
  const first = positions[0];
@@ -56971,70 +57525,6 @@ function hasWrappedDetection(entries) {
56971
57525
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
56972
57526
  }
56973
57527
  //#endregion
56974
- //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
56975
- /**
56976
- * How much of the PERSON box must sit inside a vehicle box to be counted.
56977
- *
56978
- * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
56979
- * routinely pokes a little outside the vehicle box, and requiring total
56980
- * containment would count almost none of them. 0.8 and not 0.5: half a person
56981
- * box overlapping a car is what a pedestrian walking in front of one looks
56982
- * like, and counting those would make the number mean "somebody was near a
56983
- * car", which is not a defect.
56984
- */
56985
- var CONTAINMENT_THRESHOLD = .8;
56986
- /**
56987
- * The classes that count as a vehicle for this test.
56988
- *
56989
- * A local list rather than a shared taxonomy on purpose: it exists to describe
56990
- * ONE measurement, and a shared enum would make a change here silently change
56991
- * detection behaviour somewhere else.
56992
- */
56993
- var VEHICLE_CLASSES = [
56994
- "car",
56995
- "truck",
56996
- "bus",
56997
- "motorcycle",
56998
- "vehicle"
56999
- ];
57000
- /** Area of the intersection of two boxes. `0` when they do not overlap. */
57001
- function intersectionArea(a, b) {
57002
- const left = Math.max(a.x, b.x);
57003
- const right = Math.min(a.x + a.w, b.x + b.w);
57004
- const top = Math.max(a.y, b.y);
57005
- const bottom = Math.min(a.y + a.h, b.y + b.h);
57006
- if (right <= left || bottom <= top) return 0;
57007
- return (right - left) * (bottom - top);
57008
- }
57009
- /**
57010
- * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
57011
- *
57012
- * `0` for a degenerate person box: a zero-area box is inside everything and
57013
- * inside nothing, and answering `1` would count every one of them.
57014
- */
57015
- function containedFraction(person, vehicle) {
57016
- const area = person.w * person.h;
57017
- if (area <= 0) return 0;
57018
- return intersectionArea(person, vehicle) / area;
57019
- }
57020
- /**
57021
- * Is this person box contained in any vehicle box in the same frame?
57022
- *
57023
- * Returns the fraction for the BEST-containing vehicle when it clears
57024
- * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
57025
- * a counter that only ever says "yes" cannot later be re-thresholded against
57026
- * what was actually measured.
57027
- */
57028
- function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
57029
- let best = 0;
57030
- for (const neighbour of neighbours) {
57031
- if (!VEHICLE_CLASSES.includes(neighbour.className)) continue;
57032
- const fraction = containedFraction(person, neighbour.bbox);
57033
- if (fraction > best) best = fraction;
57034
- }
57035
- return best >= threshold ? best : null;
57036
- }
57037
- //#endregion
57038
57528
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
57039
57529
  function createCaptureCrop(deps) {
57040
57530
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -63476,6 +63966,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63476
63966
  * Set once the data-plane is registered; null until then (e.g. no
63477
63967
  * dataPlane facility in the current environment). */
63478
63968
  eventMediaBaseUrl = null;
63969
+ /**
63970
+ * The SAME path, derived rather than observed — a pure function of the addon
63971
+ * id, so it exists before `serve()` is attempted and survives its failure.
63972
+ *
63973
+ * `eventMediaBaseUrl` above answers "is the plane up?", which is the right
63974
+ * question for the OPTIONAL `mediaUrl` stamp on a slim event projection.
63975
+ * It is the wrong question for a media row's `url`, which is that row's only
63976
+ * address: a row that dropped its address on a boot race would be
63977
+ * indistinguishable from a row with no bytes, and this repo has already paid
63978
+ * for one read that quietly returned less than it was asked for.
63979
+ */
63980
+ eventMediaPathPrefix() {
63981
+ return `/addon/${this.ctx.id}/event-media`;
63982
+ }
63479
63983
  lastActiveTrackIds = /* @__PURE__ */ new Map();
63480
63984
  /** See `pipeline/suppressed-births.ts` — rejected births must not be
63481
63985
  * re-upserted, and must be forgotten when the tracker drops the id. */
@@ -63726,6 +64230,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63726
64230
  sensorEventStore: () => this.sensorEventStore,
63727
64231
  opsLog: () => this.eventsOpsLog,
63728
64232
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
64233
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
63729
64234
  logger: () => this.ctx.logger,
63730
64235
  zoneCatalogue: () => this.zoneCatalogue
63731
64236
  });
@@ -66268,6 +66773,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66268
66773
  logger: this.ctx.logger,
66269
66774
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
66270
66775
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66776
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66271
66777
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
66272
66778
  });
66273
66779
  this.faceGalleryProvider = faceGallery;
@@ -66282,6 +66788,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66282
66788
  this.plateRecognizer?.refreshGallery();
66283
66789
  },
66284
66790
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66791
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66285
66792
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
66286
66793
  });
66287
66794
  return [
@@ -69967,6 +70474,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69967
70474
  async listTrackMedia(input) {
69968
70475
  return this.queryFacade.listTrackMedia(input);
69969
70476
  }
70477
+ /** What media an EVENT has, without any of it — the same manifest, for the
70478
+ * half of the detail modal's filmstrip that is event-owned. */
70479
+ async listEventMedia(input) {
70480
+ return this.queryFacade.listEventMedia(input);
70481
+ }
69970
70482
  /**
69971
70483
  * Search object events by text using CLIP cosine similarity.
69972
70484
  *
@@ -71285,6 +71797,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71285
71797
  async getEventStoreFootprint() {
71286
71798
  return this.queryFacade.getEventStoreFootprint();
71287
71799
  }
71800
+ async getEventMediaFootprintByKind(input) {
71801
+ return this.queryFacade.getEventMediaFootprintByKind(input);
71802
+ }
71288
71803
  async pruneEvents(input) {
71289
71804
  return this.queryFacade.pruneEvents(input);
71290
71805
  }
@@ -71687,31 +72202,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71687
72202
  return media;
71688
72203
  }
71689
72204
  }
72205
+ /**
72206
+ * Resolve an event/track id to ONE tile's bytes.
72207
+ *
72208
+ * Every ladder below ranks rows by `kind` alone, so this walks the INDEX
72209
+ * (`listInfoByOwner`, no blob opened) and opens only the winner. It used to
72210
+ * walk `listByOwner`, which reads and base64-encodes every blob an owner
72211
+ * holds before the first comparison is made: measured on the live hub
72212
+ * 2026-08-30, a track's six kinds are 3 597 219 bytes and the tile serves
72213
+ * `thumbnailSmall` — 31 155 of them. An Events page of forty tiles read
72214
+ * ~144 MB and allocated ~190 MB of base64 to deliver ~1.2 MB, all of it on
72215
+ * the single-threaded post-analysis runner.
72216
+ */
71690
72217
  async readEventThumbnail(id, preferKind) {
71691
- const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
72218
+ const listInfo = (ownerKind, ownerId) => this.mediaStore?.listInfoByOwner(ownerKind, ownerId) ?? Promise.resolve([]);
72219
+ const getTrackIdForEvent = (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null);
72220
+ const readBlob = async (key) => {
72221
+ const file = await (this.mediaStore?.getByKey(key) ?? Promise.resolve(null));
72222
+ return file === null ? null : {
72223
+ bytes: Buffer.from(file.base64, "base64"),
72224
+ key: file.key
72225
+ };
72226
+ };
72227
+ const eventFiles = await listInfo("event", id);
71692
72228
  if (preferKind !== void 0 && preferKind.length > 0) {
71693
72229
  const layers = await collectCropForcedMedia({
71694
- listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
71695
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
72230
+ listByOwner: listInfo,
72231
+ getTrackIdForEvent
71696
72232
  }, id);
71697
- const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
71698
- if (!chosen) return null;
71699
- return {
71700
- bytes: Buffer.from(chosen.base64, "base64"),
71701
- key: chosen.key
71702
- };
72233
+ const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
72234
+ return await readWinningBlob([...layers.bodyFiles, ...layers.detailFiles], (rows) => resolveCropForcedMedia(rows.filter((r) => bodyKeys.has(r.key)), (files) => pickCleanMedia(files, preferKind), rows.filter((r) => !bodyKeys.has(r.key))), readBlob);
71703
72235
  }
71704
- const chosen = await resolveDefaultEventMedia({
72236
+ const { candidates, pick } = await collectDefaultEventMedia({
71705
72237
  eventId: id,
71706
72238
  eventFiles,
71707
- listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
71708
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
72239
+ listTrackMedia: (trackId) => listInfo("track", trackId),
72240
+ getTrackIdForEvent
71709
72241
  });
71710
- if (!chosen) return null;
71711
- return {
71712
- bytes: Buffer.from(chosen.base64, "base64"),
71713
- key: chosen.key
71714
- };
72242
+ return await readWinningBlob(candidates, pick, readBlob);
71715
72243
  }
71716
72244
  globalSettingsSchema(cap) {
71717
72245
  return this.schema(buildGlobalSettingsSchema(cap));