@camstack/addon-post-analysis 1.2.151 → 1.2.152

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 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-DSg_jDMt.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
  },
@@ -25724,7 +25758,7 @@ var MediaStore = class {
25724
25758
  });
25725
25759
  }
25726
25760
  /** Persist a media blob and register its metadata row. Returns the
25727
- * collection key (MediaFile.key) that callers store as reference. */
25761
+ * collection key (MediaFileRef.key) that callers store as reference. */
25728
25762
  async put(params) {
25729
25763
  if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
25730
25764
  return this.putUnchecked(params);
@@ -26046,8 +26080,20 @@ var MediaStore = class {
26046
26080
  * its device at write time, so scoping the query by it makes a media read
26047
26081
  * for a device the caller cannot name return nothing — the DB itself
26048
26082
  * enforces the boundary, no post-hoc ownership check to forget.
26083
+ *
26084
+ * `kinds` is the WORK filter, and it belongs here rather than on the answer.
26085
+ * Every row this returns has had its blob read off disk and base64-encoded,
26086
+ * so a caller that filters afterwards has already paid for every kind it is
26087
+ * about to discard: measured on the live hub, one track's six kinds are
26088
+ * 3 597 219 bytes and `thumbnailSmall` is 31 155 of them, and asking for the
26089
+ * small one alone cost exactly as much as asking for all six. `kind` is a
26090
+ * real column (see {@link MEDIA_COLUMNS}), so the narrowing happens in the
26091
+ * query and the blobs of the other kinds are never opened.
26092
+ *
26093
+ * Absent (or empty) means every kind — the back-compat default, and the
26094
+ * honest answer for a caller that has not decided what it needs.
26049
26095
  */
26050
- async listByOwner(ownerKind, ownerId, deviceId) {
26096
+ async listByOwner(ownerKind, ownerId, deviceId, kinds) {
26051
26097
  const rows = await this.store.query.query({
26052
26098
  collection: MEDIA_COLLECTION,
26053
26099
  filter: {
@@ -26059,6 +26105,7 @@ var MediaStore = class {
26059
26105
  ownerId,
26060
26106
  deviceId
26061
26107
  },
26108
+ ...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
26062
26109
  orderBy: {
26063
26110
  field: "timestamp",
26064
26111
  direction: "asc"
@@ -27974,6 +28021,63 @@ var embeddingActions = defineCustomActions({
27974
28021
  "embedding.reembedFacesStatus": customAction(NoInputSchema$1, ReembedFacesStatusSchema)
27975
28022
  });
27976
28023
  //#endregion
28024
+ //#region src/pipeline-analytics/media-file-url.ts
28025
+ /**
28026
+ * The plane path for one stored media key.
28027
+ *
28028
+ * `prefix` is `/addon/<addonId>/event-media` — a pure function of the addon
28029
+ * id, so it exists whether or not `dataPlane.serve` succeeded. A row that
28030
+ * named no address at all would be the silent drop this repo keeps paying for:
28031
+ * a client cannot tell "no bytes" from "no plane", and renders neither.
28032
+ */
28033
+ function mediaFileUrl(prefix, key) {
28034
+ return `${prefix}/${encodeURIComponent(key)}`;
28035
+ }
28036
+ /**
28037
+ * A stored row as the wire sees it: the ref, the address, and — transitionally
28038
+ * — the bytes.
28039
+ *
28040
+ * `base64` is copied through ONLY because the deployed viewer's track-detail
28041
+ * hero still requires the field to parse a row at all. See the `MediaFile`
28042
+ * docblock in `pipeline-analytics.cap.ts`; deleting the field is a one-line
28043
+ * change here and one in the schema.
28044
+ *
28045
+ * `provenance` is deliberately NOT spread: it is store-internal, and the
28046
+ * previous `{ ...row, kind }` shape published it by accident.
28047
+ */
28048
+ function toWireMediaFile(row, prefix) {
28049
+ return {
28050
+ key: row.key,
28051
+ kind: row.kind,
28052
+ sizeBytes: row.sizeBytes,
28053
+ timestamp: row.timestamp,
28054
+ url: mediaFileUrl(prefix, row.key),
28055
+ base64: row.base64
28056
+ };
28057
+ }
28058
+ /** The same row without its bytes — the manifest shape. Carries the SAME
28059
+ * `url` as the byte read, so a client never builds the plane path itself. */
28060
+ function toWireMediaFileInfo(row, prefix) {
28061
+ return {
28062
+ key: row.key,
28063
+ kind: row.kind,
28064
+ sizeBytes: row.sizeBytes,
28065
+ timestamp: row.timestamp,
28066
+ url: mediaFileUrl(prefix, row.key)
28067
+ };
28068
+ }
28069
+ //#endregion
28070
+ //#region src/pipeline-analytics/gallery-media-row.ts
28071
+ function toGalleryMediaRow(row, prefix) {
28072
+ return {
28073
+ key: row.key,
28074
+ kind: row.kind,
28075
+ url: mediaFileUrl(prefix, row.key),
28076
+ sizeBytes: row.sizeBytes,
28077
+ timestamp: row.timestamp
28078
+ };
28079
+ }
28080
+ //#endregion
27977
28081
  //#region src/pipeline-analytics/include-crops-default.ts
27978
28082
  function resolveIncludeCrops(input) {
27979
28083
  return input?.includeCrops === true;
@@ -29633,6 +29737,7 @@ var FaceGalleryProvider = class {
29633
29737
  logger;
29634
29738
  refreshGallery;
29635
29739
  eventMediaBaseUrl;
29740
+ eventMediaPathPrefix;
29636
29741
  emitFaceGalleryChanged;
29637
29742
  constructor(deps) {
29638
29743
  this.identityStore = deps.identityStore;
@@ -29643,6 +29748,7 @@ var FaceGalleryProvider = class {
29643
29748
  this.logger = deps.logger;
29644
29749
  this.refreshGallery = deps.refreshGallery;
29645
29750
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
29751
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
29646
29752
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
29647
29753
  }
29648
29754
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -29830,13 +29936,7 @@ var FaceGalleryProvider = class {
29830
29936
  * the enrolled identity copies — those live under identity owner).
29831
29937
  */
29832
29938
  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
- }));
29939
+ return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
29840
29940
  }
29841
29941
  /**
29842
29942
  * Assign a buffered face to an identity:
@@ -32547,50 +32647,136 @@ var FrameProcessor = class {
32547
32647
  };
32548
32648
  }
32549
32649
  };
32550
- //#endregion
32551
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
32650
+ /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32651
+ * evicts the oldest record. */
32652
+ var PendingPreviewUpgrades = class {
32653
+ entries = /* @__PURE__ */ new Map();
32654
+ ttlMs;
32655
+ maxEntries;
32656
+ now;
32657
+ constructor(options = {}) {
32658
+ this.ttlMs = options.ttlMs ?? 9e5;
32659
+ this.maxEntries = Math.max(1, options.maxEntries ?? 512);
32660
+ this.now = options.now ?? (() => Date.now());
32661
+ }
32662
+ /** Record (or replace) a closed track's pending upgrade. Prunes expired
32663
+ * entries first, then evicts the oldest while over the cap. */
32664
+ record(entry) {
32665
+ this.prune();
32666
+ this.entries.delete(entry.trackId);
32667
+ this.entries.set(entry.trackId, entry);
32668
+ while (this.entries.size > this.maxEntries) {
32669
+ const oldest = this.entries.keys().next();
32670
+ if (oldest.done === true) break;
32671
+ this.entries.delete(oldest.value);
32672
+ }
32673
+ }
32674
+ /** Claim a track's pending upgrade — removes it, so a second landing (or a
32675
+ * concurrent racer) finds nothing and the tile is rewritten at most once. */
32676
+ take(trackId) {
32677
+ this.prune();
32678
+ const entry = this.entries.get(trackId);
32679
+ if (entry === void 0) return void 0;
32680
+ this.entries.delete(trackId);
32681
+ return entry;
32682
+ }
32683
+ get size() {
32684
+ return this.entries.size;
32685
+ }
32686
+ prune() {
32687
+ const cutoff = this.now() - this.ttlMs;
32688
+ for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
32689
+ }
32690
+ };
32552
32691
  /**
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.
32692
+ * Redeem a closed track's pending preview off a keyFrame that landed late.
32693
+ * Every non-`upgraded` outcome that had a pending entry logs itself — a
32694
+ * branch that leaves the stand-in in place must say so.
32558
32695
  */
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
32696
+ async function runPreviewUpgrade(deps, input) {
32697
+ const { deviceId, trackId } = input;
32698
+ const pending = deps.takePending(trackId);
32699
+ if (pending === void 0) return "no-pending";
32700
+ const held = await deps.heldThumbnailProvenance(trackId);
32701
+ if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
32702
+ deps.logger.info("late keyFrame: preview already native — no upgrade", {
32703
+ tags: { deviceId },
32704
+ meta: { trackId }
32569
32705
  });
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;
32706
+ return "held-native";
32707
+ }
32708
+ if (pending.bestSeen === void 0) {
32709
+ deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
32710
+ tags: { deviceId },
32711
+ meta: {
32712
+ trackId,
32713
+ heldProvenance: held
32584
32714
  }
32585
- }
32715
+ });
32716
+ return "no-bbox";
32586
32717
  }
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
- })) };
32718
+ if (pending.bestSeen.timestamp === void 0) {
32719
+ deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32720
+ tags: { deviceId },
32721
+ meta: {
32722
+ trackId,
32723
+ keyFrameKey: input.keyFrameKey,
32724
+ heldProvenance: held
32725
+ }
32726
+ });
32727
+ return "no-bbox";
32728
+ }
32729
+ const derived = await deps.derive({
32730
+ deviceId,
32731
+ mediaKey: input.keyFrameKey,
32732
+ bbox: {
32733
+ x: pending.bestSeen.x,
32734
+ y: pending.bestSeen.y,
32735
+ w: pending.bestSeen.w,
32736
+ h: pending.bestSeen.h
32737
+ },
32738
+ frameWidth: pending.bestSeen.frameWidth,
32739
+ frameHeight: pending.bestSeen.frameHeight,
32740
+ timestamp: pending.bestSeen.timestamp
32741
+ });
32742
+ if (derived === null) {
32743
+ deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
32744
+ tags: { deviceId },
32745
+ meta: {
32746
+ trackId,
32747
+ keyFrameKey: input.keyFrameKey,
32748
+ heldProvenance: held
32749
+ }
32750
+ });
32751
+ return "derive-miss";
32752
+ }
32753
+ await deps.put({
32754
+ deviceId,
32755
+ trackId,
32756
+ kind: "thumbnail",
32757
+ timestamp: input.timestamp,
32758
+ data: derived.thumbnail,
32759
+ provenance: "native"
32760
+ });
32761
+ await deps.put({
32762
+ deviceId,
32763
+ trackId,
32764
+ kind: "thumbnailSmall",
32765
+ timestamp: input.timestamp,
32766
+ data: derived.thumbnailSmall,
32767
+ provenance: "native"
32768
+ });
32769
+ await deps.clearPreviewMiss(deviceId, trackId);
32770
+ deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
32771
+ tags: { deviceId },
32772
+ meta: {
32773
+ trackId,
32774
+ keyFrameKey: input.keyFrameKey,
32775
+ replaced: held ?? "none",
32776
+ skewMs: derived.skewMs
32777
+ }
32778
+ });
32779
+ return "upgraded";
32594
32780
  }
32595
32781
  function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32596
32782
  if (frameWidth <= 0 || frameHeight <= 0) return true;
@@ -32750,137 +32936,6 @@ function promoteSemanticBestFrame(deps, input) {
32750
32936
  captured: true
32751
32937
  };
32752
32938
  }
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
32939
  //#endregion
32885
32940
  //#region src/pipeline-analytics/store/zone-geometry.ts
32886
32941
  /**
@@ -34619,6 +34674,7 @@ var PlateGalleryProvider = class {
34619
34674
  refreshGallery;
34620
34675
  emitPlateGalleryChanged;
34621
34676
  eventMediaBaseUrl;
34677
+ eventMediaPathPrefix;
34622
34678
  constructor(deps) {
34623
34679
  this.plateStore = deps.plateStore;
34624
34680
  this.vehicleStore = deps.vehicleStore;
@@ -34629,6 +34685,7 @@ var PlateGalleryProvider = class {
34629
34685
  this.refreshGallery = deps.refreshGallery;
34630
34686
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
34631
34687
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
34688
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
34632
34689
  }
34633
34690
  /** Best-effort emit — never allowed to fail the mutation it follows. */
34634
34691
  safeEmitPlateGalleryChanged(payload) {
@@ -34699,13 +34756,7 @@ var PlateGalleryProvider = class {
34699
34756
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34700
34757
  }
34701
34758
  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
- }));
34759
+ return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
34709
34760
  }
34710
34761
  async searchPlates(input) {
34711
34762
  const maxDistance = input.maxDistance ?? 1;
@@ -34948,6 +34999,51 @@ var PlateGalleryProvider = class {
34948
34999
  }
34949
35000
  };
34950
35001
  //#endregion
35002
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
35003
+ /**
35004
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
35005
+ * return the tracks it produced.
35006
+ *
35007
+ * No `TrackStore`, no bus, no media — a track here is a summary built
35008
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
35009
+ */
35010
+ function runFrameProcessorPass(input) {
35011
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
35012
+ if (input.zones !== void 0) processor.setZones(input.zones);
35013
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
35014
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
35015
+ const accum = /* @__PURE__ */ new Map();
35016
+ for (const { timestamp, frame } of input.frames) {
35017
+ const result = processor.process({
35018
+ timestamp,
35019
+ frame
35020
+ });
35021
+ for (const td of result.tracked) {
35022
+ const existing = accum.get(td.trackId);
35023
+ const matched = td.matchedThisFrame ? 1 : 0;
35024
+ if (existing === void 0) accum.set(td.trackId, {
35025
+ className: td.className,
35026
+ firstSeenMs: timestamp,
35027
+ lastSeenMs: timestamp,
35028
+ bbox: { ...td.bbox },
35029
+ framesMatched: matched
35030
+ });
35031
+ else {
35032
+ existing.className = td.className;
35033
+ existing.lastSeenMs = timestamp;
35034
+ existing.framesMatched += matched;
35035
+ }
35036
+ }
35037
+ }
35038
+ return { tracks: [...accum.values()].map((t) => ({
35039
+ className: t.className,
35040
+ firstSeenMs: t.firstSeenMs,
35041
+ lastSeenMs: t.lastSeenMs,
35042
+ bbox: t.bbox,
35043
+ framesMatched: t.framesMatched
35044
+ })) };
35045
+ }
35046
+ //#endregion
34951
35047
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
34952
35048
  /**
34953
35049
  * Disk-wins reconcile for pipeline analytics.
@@ -35012,92 +35108,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
35012
35108
  };
35013
35109
  }
35014
35110
  //#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
35111
  //#region src/pipeline-analytics/retention/media-reclaim.ts
35102
35112
  var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
35103
35113
  var DEFAULT_MEDIA_RECLAIM_LIMITS = {
@@ -35308,6 +35318,92 @@ async function runMediaReclaim(input) {
35308
35318
  cursors: nextCursors
35309
35319
  };
35310
35320
  }
35321
+ //#endregion
35322
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35323
+ /**
35324
+ * The operator button for the debug-media reclaim.
35325
+ *
35326
+ * The mechanism ships armed with nothing: **arming it is the operator's
35327
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35328
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35329
+ * that has to be typed.
35330
+ *
35331
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35332
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35333
+ * report you can read instead of a log line you have to find.
35334
+ */
35335
+ /**
35336
+ * `report` counts; `reclaim` deletes.
35337
+ *
35338
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35339
+ * button with no fields, and the safe reading of an unstated intent is the one
35340
+ * that destroys nothing.
35341
+ */
35342
+ var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35343
+ var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35344
+ var MediaReclaimActionInputSchema = object({
35345
+ mode: MediaReclaimActionModeSchema,
35346
+ /** Which populations to walk. Omitted = both. */
35347
+ scopes: array(MediaReclaimScopeSchema).min(1).optional(),
35348
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35349
+ deviceIds: array(number().int()).min(1).optional(),
35350
+ /** Start every device from the top instead of resuming its cursor. */
35351
+ restart: boolean().optional(),
35352
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35353
+ pageSize: number().int().min(50).max(5e3).optional(),
35354
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35355
+ maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
35356
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35357
+ maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
35358
+ /** Bytes released across the whole run — the disk-contention bound. */
35359
+ maxBytesPerRun: number().int().min(1).optional(),
35360
+ /** Wall-clock ceiling for the run. */
35361
+ budgetMinutes: number().int().min(1).max(720).optional(),
35362
+ /**
35363
+ * Bytes per second the pass may release. The recordings volume sustains
35364
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35365
+ * of that.
35366
+ */
35367
+ throttleBytesPerSec: number().int().min(64 * 1024).optional(),
35368
+ /**
35369
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35370
+ * exclusively save rows, never select them.
35371
+ */
35372
+ graceMinutes: number().int().min(1).max(10080).optional()
35373
+ });
35374
+ var MediaReclaimDeviceReportSchema = object({
35375
+ deviceId: number(),
35376
+ scope: MediaReclaimScopeSchema,
35377
+ examined: number(),
35378
+ eligible: number(),
35379
+ reclaimed: number(),
35380
+ bytesReclaimed: number(),
35381
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35382
+ held: number(),
35383
+ /** Rows kept because the owner could not be shown to be a motion event. */
35384
+ unresolved: number(),
35385
+ withinGrace: number(),
35386
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35387
+ * The row was kept and the cursor did not pass it. */
35388
+ refused: number(),
35389
+ stoppedBy: string(),
35390
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35391
+ nextCursor: number()
35392
+ });
35393
+ var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
35394
+ /** `false` when a pass was already in flight — nothing was done. */
35395
+ ran: boolean(),
35396
+ mode: _enum(["report", "reclaim"]),
35397
+ devices: array(MediaReclaimDeviceReportSchema),
35398
+ totalExamined: number(),
35399
+ totalEligible: number(),
35400
+ totalReclaimed: number(),
35401
+ totalBytesReclaimed: number(),
35402
+ totalRefused: number(),
35403
+ durationMs: number(),
35404
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35405
+ complete: boolean()
35406
+ }), { auth: "admin" }) });
35311
35407
  var OrphanAuditActionInputSchema = object({
35312
35408
  mode: _enum(["report", "reclaim"]).default("report"),
35313
35409
  /**
@@ -36627,6 +36723,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
36627
36723
  return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
36628
36724
  }
36629
36725
  /**
36726
+ * The STORED kinds worth reading from one scope for a `kinds` request, or
36727
+ * `null` when the scope cannot contribute to it at all.
36728
+ *
36729
+ * This is the same vocabulary translation {@link collectTrackMedia} performs on
36730
+ * the way out, moved to the way IN so it can narrow the query instead of the
36731
+ * answer. A caller naming `faceCrop` must ask the face owner for its stored
36732
+ * `crop` — asking it for `faceCrop` matches nothing, and asking it for
36733
+ * everything opens blobs the answer will discard.
36734
+ *
36735
+ * `undefined` means "every kind": the honest translation of an unfiltered
36736
+ * request, and the only safe answer for the open-ended track scope.
36737
+ *
36738
+ * `null` (skip) is only ever returned for a DETAIL owner whose one publishable
36739
+ * kind was not asked for. The track scope is never skipped — its kinds are
36740
+ * open-ended, and dropping it on a guess is how a listing starts lying.
36741
+ */
36742
+ function storedKindsForScope(scope, kinds) {
36743
+ if (kinds === void 0 || kinds.length === 0) return void 0;
36744
+ if (scope.publishCropAs === null) return kinds;
36745
+ return kinds.includes(scope.publishCropAs) ? [STORED_DETAIL_CROP_KIND] : null;
36746
+ }
36747
+ /**
36630
36748
  * Fold every scope's rows into ONE track media listing: detail crops
36631
36749
  * relabelled, everything filtered to `kinds`, ordered oldest first.
36632
36750
  *
@@ -36763,8 +36881,10 @@ var AnalyticsQueryFacade = class {
36763
36881
  return this.withMediaUrl(events);
36764
36882
  }
36765
36883
  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;
36884
+ const store = this.deps.mediaStore();
36885
+ if (store === null) return [];
36886
+ const prefix = this.deps.eventMediaPathPrefix();
36887
+ return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
36768
36888
  }
36769
36889
  /**
36770
36890
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -36777,14 +36897,36 @@ var AnalyticsQueryFacade = class {
36777
36897
  async getTrackMedia(input) {
36778
36898
  const store = this.deps.mediaStore();
36779
36899
  if (store === null) return [];
36900
+ const prefix = this.deps.eventMediaPathPrefix();
36780
36901
  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) => ({
36902
+ return collectTrackMedia(await Promise.all(scopes.map(async (scope) => {
36903
+ const stored = storedKindsForScope(scope, input.kinds);
36904
+ if (stored === null) return {
36905
+ scope,
36906
+ rows: []
36907
+ };
36908
+ return {
36909
+ scope,
36910
+ rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId, stored)
36911
+ };
36912
+ })), (row, kind) => ({
36785
36913
  ...row,
36786
36914
  kind
36787
- }), input.kinds);
36915
+ }), input.kinds).map((row) => toWireMediaFile(row, prefix));
36916
+ }
36917
+ /**
36918
+ * An event's media WITHOUT the bytes.
36919
+ *
36920
+ * Index rows only — it never opens a blob. The track detail modal unions
36921
+ * this with {@link listTrackMedia} to build its filmstrip and then fetches
36922
+ * each tile from the `event-media` plane by key, so the bytes this used to
36923
+ * carry were read, base64'd, shipped and discarded.
36924
+ */
36925
+ async listEventMedia(input) {
36926
+ const store = this.deps.mediaStore();
36927
+ if (store === null) return [];
36928
+ const prefix = this.deps.eventMediaPathPrefix();
36929
+ return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
36788
36930
  }
36789
36931
  /**
36790
36932
  * A track's media WITHOUT the bytes.
@@ -36802,13 +36944,14 @@ var AnalyticsQueryFacade = class {
36802
36944
  async listTrackMedia(input) {
36803
36945
  const store = this.deps.mediaStore();
36804
36946
  if (store === null) return [];
36947
+ const prefix = this.deps.eventMediaPathPrefix();
36805
36948
  return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
36806
36949
  scope,
36807
36950
  rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36808
36951
  }))), (row, kind) => ({
36809
36952
  ...row,
36810
36953
  kind
36811
- }), void 0);
36954
+ }), void 0).map((row) => toWireMediaFileInfo(row, prefix));
36812
36955
  }
36813
36956
  async pruneEventsBefore(input) {
36814
36957
  const eventStore = this.deps.eventStore();
@@ -39253,6 +39396,31 @@ function createEventMediaHandler(deps) {
39253
39396
  }
39254
39397
  //#endregion
39255
39398
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
39399
+ /**
39400
+ * Rank `candidates` with `pick`, then open ONE blob — the winner's.
39401
+ *
39402
+ * The ladders decide on `kind` alone, so the bytes are dead weight until the
39403
+ * choice is made. Handing them pre-read rows meant a tile that serves the
39404
+ * 31 KB `thumbnailSmall` first read the whole 3 597 219-byte set, including a
39405
+ * 2.8 MB `keyFrame` no list view has ever rendered.
39406
+ *
39407
+ * A winner whose blob is GONE is dropped and the ladder re-run on the rest.
39408
+ * That fall-through is not an extra: `listByOwner` used to provide it for free
39409
+ * by skipping unreadable rows before the ladder ever saw them, and without it
39410
+ * an index row that outlived its file would 404 a tile that used to render.
39411
+ * Bounded by the candidate count — each attempt removes one row.
39412
+ */
39413
+ async function readWinningBlob(candidates, pick, readBlob) {
39414
+ let remaining = candidates;
39415
+ while (remaining.length > 0) {
39416
+ const winner = pick(remaining);
39417
+ if (winner === void 0) return null;
39418
+ const bytes = await readBlob(winner.key);
39419
+ if (bytes !== null) return bytes;
39420
+ remaining = remaining.filter((row) => row.key !== winner.key);
39421
+ }
39422
+ return null;
39423
+ }
39256
39424
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
39257
39425
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
39258
39426
  * for new tracks (served only for old stored rows). Undefined when the event
@@ -39303,15 +39471,24 @@ async function collectCropForcedMedia(source, id) {
39303
39471
  };
39304
39472
  }
39305
39473
  /**
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).
39474
+ * Which rows the default path should serve from, and how to rank them: the
39475
+ * event's OWN media when it has any, else the owning track's.
39476
+ *
39477
+ * It yields the candidate SET rather than a winner so the caller can open one
39478
+ * blob and, if that blob has gone, re-rank the rest ({@link readWinningBlob}).
39479
+ * Returning a single row would force the caller to read it blind and 404 a tile
39480
+ * whose index row outlived its file.
39309
39481
  */
39310
- async function resolveDefaultEventMedia(deps) {
39311
- const owned = pickEventOwnedMedia(deps.eventFiles);
39312
- if (owned) return owned;
39482
+ async function collectDefaultEventMedia(deps) {
39483
+ if (deps.eventFiles.length > 0) return {
39484
+ candidates: deps.eventFiles,
39485
+ pick: pickEventOwnedMedia
39486
+ };
39313
39487
  const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
39314
- return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
39488
+ return {
39489
+ candidates: await deps.listTrackMedia(trackId),
39490
+ pick: pickTrackFallbackMedia
39491
+ };
39315
39492
  }
39316
39493
  //#endregion
39317
39494
  //#region src/pipeline-analytics/face-settings.ts
@@ -47418,6 +47595,70 @@ var PackageDropDetector = class {
47418
47595
  }
47419
47596
  };
47420
47597
  //#endregion
47598
+ //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
47599
+ /**
47600
+ * How much of the PERSON box must sit inside a vehicle box to be counted.
47601
+ *
47602
+ * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
47603
+ * routinely pokes a little outside the vehicle box, and requiring total
47604
+ * containment would count almost none of them. 0.8 and not 0.5: half a person
47605
+ * box overlapping a car is what a pedestrian walking in front of one looks
47606
+ * like, and counting those would make the number mean "somebody was near a
47607
+ * car", which is not a defect.
47608
+ */
47609
+ var CONTAINMENT_THRESHOLD = .8;
47610
+ /**
47611
+ * The classes that count as a vehicle for this test.
47612
+ *
47613
+ * A local list rather than a shared taxonomy on purpose: it exists to describe
47614
+ * ONE measurement, and a shared enum would make a change here silently change
47615
+ * detection behaviour somewhere else.
47616
+ */
47617
+ var VEHICLE_CLASSES$1 = [
47618
+ "car",
47619
+ "truck",
47620
+ "bus",
47621
+ "motorcycle",
47622
+ "vehicle"
47623
+ ];
47624
+ /** Area of the intersection of two boxes. `0` when they do not overlap. */
47625
+ function intersectionArea(a, b) {
47626
+ const left = Math.max(a.x, b.x);
47627
+ const right = Math.min(a.x + a.w, b.x + b.w);
47628
+ const top = Math.max(a.y, b.y);
47629
+ const bottom = Math.min(a.y + a.h, b.y + b.h);
47630
+ if (right <= left || bottom <= top) return 0;
47631
+ return (right - left) * (bottom - top);
47632
+ }
47633
+ /**
47634
+ * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
47635
+ *
47636
+ * `0` for a degenerate person box: a zero-area box is inside everything and
47637
+ * inside nothing, and answering `1` would count every one of them.
47638
+ */
47639
+ function containedFraction(person, vehicle) {
47640
+ const area = person.w * person.h;
47641
+ if (area <= 0) return 0;
47642
+ return intersectionArea(person, vehicle) / area;
47643
+ }
47644
+ /**
47645
+ * Is this person box contained in any vehicle box in the same frame?
47646
+ *
47647
+ * Returns the fraction for the BEST-containing vehicle when it clears
47648
+ * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
47649
+ * a counter that only ever says "yes" cannot later be re-thresholded against
47650
+ * what was actually measured.
47651
+ */
47652
+ function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
47653
+ let best = 0;
47654
+ for (const neighbour of neighbours) {
47655
+ if (!VEHICLE_CLASSES$1.includes(neighbour.className)) continue;
47656
+ const fraction = containedFraction(person, neighbour.bbox);
47657
+ if (fraction > best) best = fraction;
47658
+ }
47659
+ return best >= threshold ? best : null;
47660
+ }
47661
+ //#endregion
47421
47662
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
47422
47663
  /**
47423
47664
  * stationary-evidence — how a parked object EARNS a registry entry.
@@ -49949,83 +50190,6 @@ function buildRecordedStillPorts(input) {
49949
50190
  })
49950
50191
  };
49951
50192
  }
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
50193
  //#endregion
50030
50194
  //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
50031
50195
  /** The scope id this leg reports and persists its cursor under. */
@@ -50142,6 +50306,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
50142
50306
  nextCursor: Math.max(0, offset)
50143
50307
  };
50144
50308
  }
50309
+ function errorMessage(err) {
50310
+ return err instanceof Error ? err.message : String(err);
50311
+ }
50312
+ async function reconcileGallerySamples(deps, options) {
50313
+ let checked = 0;
50314
+ let reclaimed = 0;
50315
+ let keyless = 0;
50316
+ let skippedGrace = 0;
50317
+ let stoppedByCeiling = false;
50318
+ const identities = await deps.listIdentities();
50319
+ for (const identity of identities) {
50320
+ const samples = await deps.listSamples(identity.id);
50321
+ for (const sample of samples) {
50322
+ if (sample.mediaKey === void 0) {
50323
+ keyless += 1;
50324
+ continue;
50325
+ }
50326
+ if (options.now - sample.addedAt < options.graceMs) {
50327
+ skippedGrace += 1;
50328
+ continue;
50329
+ }
50330
+ if (reclaimed >= options.maxReclaimPerRun) {
50331
+ stoppedByCeiling = true;
50332
+ deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
50333
+ return {
50334
+ checked,
50335
+ reclaimed,
50336
+ keyless,
50337
+ skippedGrace,
50338
+ stoppedByCeiling
50339
+ };
50340
+ }
50341
+ let state;
50342
+ try {
50343
+ state = await deps.blobState(sample.mediaKey);
50344
+ } catch (err) {
50345
+ const reason = errorMessage(err);
50346
+ deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
50347
+ sampleId: sample.id,
50348
+ identity: identity.name,
50349
+ error: reason
50350
+ } });
50351
+ return {
50352
+ checked,
50353
+ reclaimed,
50354
+ keyless,
50355
+ skippedGrace,
50356
+ stoppedByCeiling,
50357
+ aborted: reason
50358
+ };
50359
+ }
50360
+ checked += 1;
50361
+ if (state === "exists") continue;
50362
+ await deps.removeSample(identity.id, sample.id);
50363
+ reclaimed += 1;
50364
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50365
+ sampleId: sample.id,
50366
+ identity: identity.name,
50367
+ mediaKey: sample.mediaKey,
50368
+ state
50369
+ } });
50370
+ }
50371
+ }
50372
+ if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50373
+ checked,
50374
+ reclaimed,
50375
+ keyless,
50376
+ skippedGrace
50377
+ } });
50378
+ return {
50379
+ checked,
50380
+ reclaimed,
50381
+ keyless,
50382
+ skippedGrace,
50383
+ stoppedByCeiling
50384
+ };
50385
+ }
50145
50386
  /**
50146
50387
  * Where the completion marker lives.
50147
50388
  *
@@ -53204,14 +53445,14 @@ function isAfterCursor(row, cursor) {
53204
53445
  if (row.lastSeen < cursor.lastSeen) return true;
53205
53446
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
53206
53447
  }
53207
- var VEHICLE_CLASSES$1 = new Set([
53448
+ var VEHICLE_CLASSES = new Set([
53208
53449
  "vehicle",
53209
53450
  "car",
53210
53451
  "truck",
53211
53452
  "bus"
53212
53453
  ]);
53213
53454
  function motionlessMaxPxForClass(className) {
53214
- return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
53455
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
53215
53456
  }
53216
53457
  function netDisplacementPx(positions) {
53217
53458
  const first = positions[0];
@@ -56971,70 +57212,6 @@ function hasWrappedDetection(entries) {
56971
57212
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
56972
57213
  }
56973
57214
  //#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
57215
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
57039
57216
  function createCaptureCrop(deps) {
57040
57217
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -63476,6 +63653,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63476
63653
  * Set once the data-plane is registered; null until then (e.g. no
63477
63654
  * dataPlane facility in the current environment). */
63478
63655
  eventMediaBaseUrl = null;
63656
+ /**
63657
+ * The SAME path, derived rather than observed — a pure function of the addon
63658
+ * id, so it exists before `serve()` is attempted and survives its failure.
63659
+ *
63660
+ * `eventMediaBaseUrl` above answers "is the plane up?", which is the right
63661
+ * question for the OPTIONAL `mediaUrl` stamp on a slim event projection.
63662
+ * It is the wrong question for a media row's `url`, which is that row's only
63663
+ * address: a row that dropped its address on a boot race would be
63664
+ * indistinguishable from a row with no bytes, and this repo has already paid
63665
+ * for one read that quietly returned less than it was asked for.
63666
+ */
63667
+ eventMediaPathPrefix() {
63668
+ return `/addon/${this.ctx.id}/event-media`;
63669
+ }
63479
63670
  lastActiveTrackIds = /* @__PURE__ */ new Map();
63480
63671
  /** See `pipeline/suppressed-births.ts` — rejected births must not be
63481
63672
  * re-upserted, and must be forgotten when the tracker drops the id. */
@@ -63726,6 +63917,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63726
63917
  sensorEventStore: () => this.sensorEventStore,
63727
63918
  opsLog: () => this.eventsOpsLog,
63728
63919
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
63920
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
63729
63921
  logger: () => this.ctx.logger,
63730
63922
  zoneCatalogue: () => this.zoneCatalogue
63731
63923
  });
@@ -66268,6 +66460,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66268
66460
  logger: this.ctx.logger,
66269
66461
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
66270
66462
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66463
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66271
66464
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
66272
66465
  });
66273
66466
  this.faceGalleryProvider = faceGallery;
@@ -66282,6 +66475,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66282
66475
  this.plateRecognizer?.refreshGallery();
66283
66476
  },
66284
66477
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66478
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66285
66479
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
66286
66480
  });
66287
66481
  return [
@@ -69967,6 +70161,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69967
70161
  async listTrackMedia(input) {
69968
70162
  return this.queryFacade.listTrackMedia(input);
69969
70163
  }
70164
+ /** What media an EVENT has, without any of it — the same manifest, for the
70165
+ * half of the detail modal's filmstrip that is event-owned. */
70166
+ async listEventMedia(input) {
70167
+ return this.queryFacade.listEventMedia(input);
70168
+ }
69970
70169
  /**
69971
70170
  * Search object events by text using CLIP cosine similarity.
69972
70171
  *
@@ -71687,31 +71886,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71687
71886
  return media;
71688
71887
  }
71689
71888
  }
71889
+ /**
71890
+ * Resolve an event/track id to ONE tile's bytes.
71891
+ *
71892
+ * Every ladder below ranks rows by `kind` alone, so this walks the INDEX
71893
+ * (`listInfoByOwner`, no blob opened) and opens only the winner. It used to
71894
+ * walk `listByOwner`, which reads and base64-encodes every blob an owner
71895
+ * holds before the first comparison is made: measured on the live hub
71896
+ * 2026-08-30, a track's six kinds are 3 597 219 bytes and the tile serves
71897
+ * `thumbnailSmall` — 31 155 of them. An Events page of forty tiles read
71898
+ * ~144 MB and allocated ~190 MB of base64 to deliver ~1.2 MB, all of it on
71899
+ * the single-threaded post-analysis runner.
71900
+ */
71690
71901
  async readEventThumbnail(id, preferKind) {
71691
- const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
71902
+ const listInfo = (ownerKind, ownerId) => this.mediaStore?.listInfoByOwner(ownerKind, ownerId) ?? Promise.resolve([]);
71903
+ const getTrackIdForEvent = (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null);
71904
+ const readBlob = async (key) => {
71905
+ const file = await (this.mediaStore?.getByKey(key) ?? Promise.resolve(null));
71906
+ return file === null ? null : {
71907
+ bytes: Buffer.from(file.base64, "base64"),
71908
+ key: file.key
71909
+ };
71910
+ };
71911
+ const eventFiles = await listInfo("event", id);
71692
71912
  if (preferKind !== void 0 && preferKind.length > 0) {
71693
71913
  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)
71914
+ listByOwner: listInfo,
71915
+ getTrackIdForEvent
71696
71916
  }, 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
- };
71917
+ const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
71918
+ 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
71919
  }
71704
- const chosen = await resolveDefaultEventMedia({
71920
+ const { candidates, pick } = await collectDefaultEventMedia({
71705
71921
  eventId: id,
71706
71922
  eventFiles,
71707
- listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
71708
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
71923
+ listTrackMedia: (trackId) => listInfo("track", trackId),
71924
+ getTrackIdForEvent
71709
71925
  });
71710
- if (!chosen) return null;
71711
- return {
71712
- bytes: Buffer.from(chosen.base64, "base64"),
71713
- key: chosen.key
71714
- };
71926
+ return await readWinningBlob(candidates, pick, readBlob);
71715
71927
  }
71716
71928
  globalSettingsSchema(cap) {
71717
71929
  return this.schema(buildGlobalSettingsSchema(cap));