@camstack/addon-post-analysis 1.2.150 → 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-C8cA7PX_.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.
@@ -6263,12 +6263,107 @@ var NcDeviceStateCache = class {
6263
6263
  }
6264
6264
  };
6265
6265
  //#endregion
6266
+ //#region src/notification-center/system-event-tap-url.ts
6267
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6268
+ var ADMIN_ADDONS_PATH = "/system/addons";
6269
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6270
+ var ADMIN_DEVICES_PATH = "/devices";
6271
+ /**
6272
+ * The query parameter naming a node.
6273
+ *
6274
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6275
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6276
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6277
+ */
6278
+ var NODE_PARAM = "node";
6279
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6280
+ var TAB_PARAM = "tab";
6281
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6282
+ var STREAM_BROKER_TAB = "stream-broker";
6283
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6284
+ * string map with no device-model dependency; the guard pins it. */
6285
+ var CAMERA_DEVICE_TYPE = "camera";
6286
+ /** Kinds whose action is "go and apply an ADDON update". */
6287
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6288
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6289
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6290
+ /**
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.
6301
+ *
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
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.
6309
+ */
6310
+ var DEVICE_STREAM_KINDS = new Set([
6311
+ "device-offline",
6312
+ "device-online",
6313
+ "stream-offline",
6314
+ "stream-online",
6315
+ "camera-offline",
6316
+ "camera-online"
6317
+ ]);
6318
+ /**
6319
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6320
+ *
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.
6324
+ */
6325
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
6326
+ "stream-offline",
6327
+ "stream-online",
6328
+ "camera-offline",
6329
+ "camera-online"
6330
+ ]);
6331
+ function withParam(path, key, value) {
6332
+ if (value === void 0 || value.length === 0) return path;
6333
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6334
+ }
6335
+ /**
6336
+ * The admin path (with its query) a system event taps through to, or `null`
6337
+ * when the kind has no action.
6338
+ *
6339
+ * Exported for the cross-package guard and for tests that want the path
6340
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6341
+ */
6342
+ function systemEventTapPath(subject) {
6343
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6344
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6345
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
6346
+ const deviceId = subject.deviceId;
6347
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6348
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6349
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6350
+ }
6351
+ return null;
6352
+ }
6353
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6354
+ function systemEventTapUrl(subject, baseUrl) {
6355
+ if (baseUrl === null || baseUrl.length === 0) return null;
6356
+ const path = systemEventTapPath(subject);
6357
+ if (path === null) return null;
6358
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6359
+ }
6360
+ //#endregion
6266
6361
  //#region src/notification-center/default-tap-url.ts
6267
6362
  /** The viewer's hosted base path under the hub. */
6268
6363
  var VIEWER_BASE_PATH = "/viewer/camstack";
6269
6364
  function defaultTapUrl(input) {
6270
- if (input.systemEvent === true) return null;
6271
6365
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6366
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6272
6367
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6273
6368
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6274
6369
  }
@@ -8861,13 +8956,14 @@ function trailAt(trail, timestamp) {
8861
8956
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8862
8957
  */
8863
8958
  function selectTriggerZoneCandidates(input) {
8864
- const { candidates, trail, polygons } = input;
8959
+ const { candidates, trail, polygons, strict } = input;
8865
8960
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8866
8961
  const qualifying = candidates.filter((candidate) => {
8867
8962
  const point = trailAt(trail, candidate.timestamp);
8868
8963
  return point !== null && zoneOverlap(point, polygons) > 0;
8869
8964
  });
8870
- return qualifying.length > 0 ? qualifying : candidates;
8965
+ if (qualifying.length > 0) return qualifying;
8966
+ return strict === true ? [] : candidates;
8871
8967
  }
8872
8968
  //#endregion
8873
8969
  //#region src/notification-center/dispatcher.ts
@@ -8905,6 +9001,24 @@ var NC_STILL_WAIT_MS = 3e3;
8905
9001
  * is the instant; the ladder then runs among the frames that are about it.
8906
9002
  */
8907
9003
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
9004
+ /**
9005
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
9006
+ *
9007
+ * The one thing that separates a row worth waiting for from a row whose only
9008
+ * frame already exists. A live visual track keeps writing media for as long as
9009
+ * it is open — its first frame, its periodic snapshots, its last frame all land
9010
+ * after the instant a zone condition fired. Every other record is photographed
9011
+ * once: an audio window, a device event and a package event are served by the
9012
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
9013
+ * `track-end` row is by definition past the last of them.
9014
+ *
9015
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
9016
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
9017
+ * 'Persona su Uscio' when something was.
9018
+ */
9019
+ function canStillGainMedia(entry) {
9020
+ return entry.recordKind === "object-event";
9021
+ }
8908
9022
  var NcDispatcher = class {
8909
9023
  deps;
8910
9024
  targetCache = null;
@@ -9384,7 +9498,12 @@ var NcDispatcher = class {
9384
9498
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9385
9499
  timestampMs: subject.timestamp,
9386
9500
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9387
- systemEvent: systemEvent !== void 0
9501
+ ...systemEvent !== void 0 ? { systemEvent: {
9502
+ kind: systemEvent.kind,
9503
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9504
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9505
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9506
+ } } : {}
9388
9507
  }) : null;
9389
9508
  const actions = await this.mintActions(entry, target);
9390
9509
  return {
@@ -9519,7 +9638,7 @@ var NcDispatcher = class {
9519
9638
  const out = [];
9520
9639
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9521
9640
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9522
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9641
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9523
9642
  const footagePending = this.renderFootage(entry);
9524
9643
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9525
9644
  const footage = await footagePending;
@@ -9763,20 +9882,34 @@ var NcDispatcher = class {
9763
9882
  * throw. And when nothing qualifies, the full list comes back: a real picture
9764
9883
  * beats no picture (the same rule D212 settled for the best shot).
9765
9884
  */
9766
- async narrowToTriggerZone(entry, ordered) {
9885
+ async narrowToTriggerZone(entry, ordered, freshness) {
9767
9886
  const zoneIds = entry.payload.triggerZoneIds;
9768
9887
  const trackId = entry.payload.subject.trackId;
9769
9888
  const getTrail = this.deps.getSubjectTrail;
9770
9889
  const deviceId = entry.payload.subject.deviceId;
9771
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9890
+ const strict = freshness === "trigger-only";
9891
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9772
9892
  try {
9773
9893
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9774
9894
  const kept = selectTriggerZoneCandidates({
9775
9895
  candidates: ordered,
9776
9896
  trail,
9777
9897
  polygons,
9778
- triggerAt: entry.payload.subject.timestamp
9898
+ triggerAt: entry.payload.subject.timestamp,
9899
+ strict
9779
9900
  });
9901
+ if (kept.length === 0) {
9902
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9903
+ tags: { deviceId },
9904
+ meta: {
9905
+ ruleId: entry.ruleId,
9906
+ trackId,
9907
+ zoneIds: zoneIds.join(","),
9908
+ rejected: ordered.length
9909
+ }
9910
+ });
9911
+ return kept;
9912
+ }
9780
9913
  if (kept.length === ordered.length) return kept;
9781
9914
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9782
9915
  tags: { deviceId },
@@ -9856,7 +9989,8 @@ var NcDispatcher = class {
9856
9989
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9857
9990
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9858
9991
  if (inBand.length === 0) continue;
9859
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9992
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
9993
+ if (shortlist.length === 0) continue;
9860
9994
  for (const file of shortlist) {
9861
9995
  const raw = Buffer.from(file.base64, "base64");
9862
9996
  if (raw.byteLength === 0) continue;
@@ -10610,16 +10744,26 @@ function incomingFromUpdateAvailable(facts, data) {
10610
10744
  ...nodeIds.length > 0 ? { nodeIds } : {}
10611
10745
  });
10612
10746
  }
10613
- /** 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
+ */
10614
10758
  function parseAddonUpdateComplete(data) {
10615
10759
  const packageName = stringField(data, "packageName");
10616
10760
  const fromVersion = stringField(data, "fromVersion");
10617
10761
  const toVersion = stringField(data, "toVersion");
10618
- if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10762
+ if (packageName === void 0 || toVersion === void 0) return null;
10619
10763
  const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10620
10764
  return {
10621
10765
  packageName,
10622
- fromVersion,
10766
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10623
10767
  toVersion,
10624
10768
  ...nodeId !== void 0 ? { nodeId } : {}
10625
10769
  };
@@ -10629,7 +10773,10 @@ function incomingFromAddonUpdates(facts, updates) {
10629
10773
  const packageNames = unique(updates.map((update) => update.packageName));
10630
10774
  const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10631
10775
  const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10632
- 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");
10633
10780
  const head = updates[0];
10634
10781
  return systemSubject({
10635
10782
  facts,
@@ -23451,17 +23598,17 @@ function planRuleTest(input) {
23451
23598
  deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
23452
23599
  nodeId: systemScope.nodeId ?? "hub",
23453
23600
  packageName: systemScope.packageName ?? "@camstack/server",
23454
- currentVersion: "0.0.0",
23455
- latestVersion: "0.0.0",
23601
+ currentVersion: "1.0.0",
23602
+ latestVersion: "1.0.1",
23456
23603
  profile: "low",
23457
23604
  seconds: "30",
23458
23605
  devices: "cuda:0",
23459
23606
  minutes: "15",
23460
23607
  addons: TEST_CRASHED_RUNNER,
23461
- list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
23608
+ list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
23462
23609
  nodes: "hub",
23463
- fromVersion: "0.0.0",
23464
- toVersion: "0.0.1",
23610
+ fromVersion: "1.0.0",
23611
+ toVersion: "1.0.1",
23465
23612
  title: "Export",
23466
23613
  count: "1"
23467
23614
  },
@@ -25487,13 +25634,46 @@ var MEDIA_COLUMNS = [
25487
25634
  type: "TEXT"
25488
25635
  })
25489
25636
  ];
25490
- var MEDIA_INDEXES = [{
25491
- name: "idx_media_owner",
25492
- columns: ["ownerKind", "ownerId"]
25493
- }, {
25494
- name: "idx_media_device_ts",
25495
- columns: ["deviceId", "timestamp"]
25496
- }];
25637
+ var MEDIA_INDEXES = [
25638
+ {
25639
+ name: "idx_media_owner",
25640
+ columns: ["ownerKind", "ownerId"]
25641
+ },
25642
+ {
25643
+ name: "idx_media_device_ts",
25644
+ columns: ["deviceId", "timestamp"]
25645
+ },
25646
+ (
25647
+ /**
25648
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25649
+ *
25650
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25651
+ * contiguous range from the head of the b-tree: its cost is proportional to
25652
+ * the ANSWER, not to the table. That is the property the gate needs. The
25653
+ * question it must answer fastest is "are there none left", and with this
25654
+ * index that is one seek that immediately falls off the end of an empty
25655
+ * range — regardless of how many million stamped rows sit behind it.
25656
+ *
25657
+ * **What it costs.** One extra b-tree maintained by every insert into a
25658
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25659
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25660
+ * against the JPEG write the same call performs. The one-off cost is real
25661
+ * and worth naming: the FIRST declare after this ships builds the index over
25662
+ * every existing row, on a recordings volume that is currently saturated —
25663
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25664
+ * of minutes, once.
25665
+ *
25666
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25667
+ * would cost nothing at all once sealed. It is not used because
25668
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25669
+ * not hash one — adding both to serve one query is a bigger change than the
25670
+ * query deserves, and the plain index already makes the gate O(log n).
25671
+ */
25672
+ {
25673
+ name: "idx_media_location",
25674
+ columns: ["locationId"]
25675
+ })
25676
+ ];
25497
25677
  /** The storage location of a media row/record: its stamped `locationId`, or
25498
25678
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25499
25679
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -25578,7 +25758,7 @@ var MediaStore = class {
25578
25758
  });
25579
25759
  }
25580
25760
  /** Persist a media blob and register its metadata row. Returns the
25581
- * collection key (MediaFile.key) that callers store as reference. */
25761
+ * collection key (MediaFileRef.key) that callers store as reference. */
25582
25762
  async put(params) {
25583
25763
  if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
25584
25764
  return this.putUnchecked(params);
@@ -25900,8 +26080,20 @@ var MediaStore = class {
25900
26080
  * its device at write time, so scoping the query by it makes a media read
25901
26081
  * for a device the caller cannot name return nothing — the DB itself
25902
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.
25903
26095
  */
25904
- async listByOwner(ownerKind, ownerId, deviceId) {
26096
+ async listByOwner(ownerKind, ownerId, deviceId, kinds) {
25905
26097
  const rows = await this.store.query.query({
25906
26098
  collection: MEDIA_COLLECTION,
25907
26099
  filter: {
@@ -25913,6 +26105,7 @@ var MediaStore = class {
25913
26105
  ownerId,
25914
26106
  deviceId
25915
26107
  },
26108
+ ...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
25916
26109
  orderBy: {
25917
26110
  field: "timestamp",
25918
26111
  direction: "asc"
@@ -27828,6 +28021,63 @@ var embeddingActions = defineCustomActions({
27828
28021
  "embedding.reembedFacesStatus": customAction(NoInputSchema$1, ReembedFacesStatusSchema)
27829
28022
  });
27830
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
27831
28081
  //#region src/pipeline-analytics/include-crops-default.ts
27832
28082
  function resolveIncludeCrops(input) {
27833
28083
  return input?.includeCrops === true;
@@ -29487,6 +29737,7 @@ var FaceGalleryProvider = class {
29487
29737
  logger;
29488
29738
  refreshGallery;
29489
29739
  eventMediaBaseUrl;
29740
+ eventMediaPathPrefix;
29490
29741
  emitFaceGalleryChanged;
29491
29742
  constructor(deps) {
29492
29743
  this.identityStore = deps.identityStore;
@@ -29497,6 +29748,7 @@ var FaceGalleryProvider = class {
29497
29748
  this.logger = deps.logger;
29498
29749
  this.refreshGallery = deps.refreshGallery;
29499
29750
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
29751
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
29500
29752
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
29501
29753
  }
29502
29754
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -29684,13 +29936,7 @@ var FaceGalleryProvider = class {
29684
29936
  * the enrolled identity copies — those live under identity owner).
29685
29937
  */
29686
29938
  async getFaceMedia(input) {
29687
- return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => ({
29688
- key: m.key,
29689
- kind: m.kind,
29690
- base64: m.base64,
29691
- sizeBytes: m.sizeBytes,
29692
- timestamp: m.timestamp
29693
- }));
29939
+ return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
29694
29940
  }
29695
29941
  /**
29696
29942
  * Assign a buffered face to an identity:
@@ -32401,209 +32647,6 @@ var FrameProcessor = class {
32401
32647
  };
32402
32648
  }
32403
32649
  };
32404
- //#endregion
32405
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
32406
- /**
32407
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
32408
- * return the tracks it produced.
32409
- *
32410
- * No `TrackStore`, no bus, no media — a track here is a summary built
32411
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
32412
- */
32413
- function runFrameProcessorPass(input) {
32414
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
32415
- if (input.zones !== void 0) processor.setZones(input.zones);
32416
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
32417
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
32418
- const accum = /* @__PURE__ */ new Map();
32419
- for (const { timestamp, frame } of input.frames) {
32420
- const result = processor.process({
32421
- timestamp,
32422
- frame
32423
- });
32424
- for (const td of result.tracked) {
32425
- const existing = accum.get(td.trackId);
32426
- const matched = td.matchedThisFrame ? 1 : 0;
32427
- if (existing === void 0) accum.set(td.trackId, {
32428
- className: td.className,
32429
- firstSeenMs: timestamp,
32430
- lastSeenMs: timestamp,
32431
- bbox: { ...td.bbox },
32432
- framesMatched: matched
32433
- });
32434
- else {
32435
- existing.className = td.className;
32436
- existing.lastSeenMs = timestamp;
32437
- existing.framesMatched += matched;
32438
- }
32439
- }
32440
- }
32441
- return { tracks: [...accum.values()].map((t) => ({
32442
- className: t.className,
32443
- firstSeenMs: t.firstSeenMs,
32444
- lastSeenMs: t.lastSeenMs,
32445
- bbox: t.bbox,
32446
- framesMatched: t.framesMatched
32447
- })) };
32448
- }
32449
- function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32450
- if (frameWidth <= 0 || frameHeight <= 0) return true;
32451
- if (score !== void 0 && score >= .5) return true;
32452
- if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
32453
- if (bbox.h >= frameHeight * .95) return false;
32454
- if (bbox.w >= frameWidth * .95) return false;
32455
- return true;
32456
- }
32457
- /**
32458
- * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
32459
- * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
32460
- * normal breathing of the tracker box (~2/3 per side) while rejecting the
32461
- * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
32462
- * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
32463
- * "best" thumbnail was a sliver of a distant car).
32464
- */
32465
- var RETRY_MIN_FRACTION_OF_BEST = .45;
32466
- /**
32467
- * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
32468
- * native lease misses the first seconds, so the large-subject captures return
32469
- * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
32470
- * time the lease warms the subject may have shrunk to a sliver; capturing THAT
32471
- * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
32472
- * stops all further retries. Reject a retry whose current bbox area has
32473
- * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
32474
- *
32475
- * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
32476
- * capture is never routed through this gate (the caller bypasses it on
32477
- * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
32478
- * info to reject on → plausible.
32479
- */
32480
- function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
32481
- if (bestSeen === null) return true;
32482
- const bestArea = bestSeen.w * bestSeen.h;
32483
- if (bestArea <= 0) return true;
32484
- return current.w * current.h >= minFractionOfBest * bestArea;
32485
- }
32486
- //#endregion
32487
- //#region src/pipeline-analytics/pipeline/semantic-promotion.ts
32488
- /**
32489
- * Compose the {@link SemanticRecognitionFrame} for a plate read / accepted
32490
- * fine classification. ONE rule, enforced here rather than at the call site:
32491
- * the promoted `bbox` is ALWAYS the SUBJECT box (`frame.bbox`), never the
32492
- * detail box — see {@link DetailAttributionResult.bbox} for what feeding the
32493
- * detail box cost. The face path (`face-recognizer.ts` `onSemanticMatch`)
32494
- * already passes its body box; this keeps the plate/classification path on the
32495
- * same discipline.
32496
- */
32497
- function semanticFrameForDetailAttribution(deviceId, trackId, detail, frame) {
32498
- return {
32499
- deviceId,
32500
- trackId,
32501
- timestamp: frame.timestamp,
32502
- bbox: {
32503
- x: frame.bbox.x,
32504
- y: frame.bbox.y,
32505
- w: frame.bbox.w,
32506
- h: frame.bbox.h
32507
- },
32508
- frameWidth: frame.frameWidth,
32509
- frameHeight: frame.frameHeight,
32510
- kind: detail.className === "plate" ? "plate" : "classification",
32511
- score: detail.score,
32512
- confidence: frame.confidence,
32513
- ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
32514
- };
32515
- }
32516
- /**
32517
- * Offer the recognition frame to the best-frame ranking with the semantic
32518
- * tier. On a win: record the best-seen bbox (plausible boxes only — an
32519
- * envelope-exploded box must not poison the retry reference, mirroring
32520
- * `buildSnapshotTargets`) and re-fire the captures when the pixels are still
32521
- * reachable.
32522
- */
32523
- function promoteSemanticBestFrame(deps, input) {
32524
- const { deviceId, trackId, bbox, frameWidth, frameHeight, timestamp } = input;
32525
- if (!deps.observeBestFrame({
32526
- deviceId,
32527
- trackId,
32528
- confidence: input.confidence,
32529
- timestamp,
32530
- edgeClear: isEdgeClear({
32531
- bbox,
32532
- frameWidth,
32533
- frameHeight
32534
- }),
32535
- centerScore: bboxCenterScore(bbox, frameWidth, frameHeight),
32536
- containment: bboxContainment(bbox, frameWidth, frameHeight),
32537
- sizeScore: bboxSizeScore(bbox, frameWidth, frameHeight),
32538
- squareFraction: centralSquareContainmentFraction(bbox, {
32539
- W: frameWidth,
32540
- H: frameHeight
32541
- }),
32542
- semantic: true
32543
- })) {
32544
- deps.logger.debug("best-shot: semantic frame did not beat the held peak", {
32545
- tags: { deviceId },
32546
- meta: {
32547
- trackId,
32548
- kind: input.kind,
32549
- score: input.score,
32550
- timestamp
32551
- }
32552
- });
32553
- return {
32554
- promoted: false,
32555
- captured: false
32556
- };
32557
- }
32558
- const plausible = isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, input.confidence);
32559
- if (plausible) deps.recordBestSeenBbox(deviceId, trackId, {
32560
- x: bbox.x,
32561
- y: bbox.y,
32562
- w: bbox.w,
32563
- h: bbox.h,
32564
- frameWidth,
32565
- frameHeight,
32566
- timestamp
32567
- });
32568
- if (!plausible || input.frameHandle === void 0) {
32569
- deps.logger.info("best-shot: semantic promotion without capture", {
32570
- tags: { deviceId },
32571
- meta: {
32572
- trackId,
32573
- kind: input.kind,
32574
- score: input.score,
32575
- timestamp,
32576
- reason: plausible ? "no frame handle" : "implausible box"
32577
- }
32578
- });
32579
- return {
32580
- promoted: true,
32581
- captured: false
32582
- };
32583
- }
32584
- deps.captureBestShot({
32585
- deviceId,
32586
- trackId,
32587
- timestamp,
32588
- bbox,
32589
- frameWidth,
32590
- frameHeight,
32591
- frameHandle: input.frameHandle
32592
- });
32593
- deps.logger.info("best-shot: semantic promotion", {
32594
- tags: { deviceId },
32595
- meta: {
32596
- trackId,
32597
- kind: input.kind,
32598
- score: input.score,
32599
- timestamp
32600
- }
32601
- });
32602
- return {
32603
- promoted: true,
32604
- captured: true
32605
- };
32606
- }
32607
32650
  /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32608
32651
  * evicts the oldest record. */
32609
32652
  var PendingPreviewUpgrades = class {
@@ -32735,6 +32778,164 @@ async function runPreviewUpgrade(deps, input) {
32735
32778
  });
32736
32779
  return "upgraded";
32737
32780
  }
32781
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32782
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
32783
+ if (score !== void 0 && score >= .5) return true;
32784
+ if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
32785
+ if (bbox.h >= frameHeight * .95) return false;
32786
+ if (bbox.w >= frameWidth * .95) return false;
32787
+ return true;
32788
+ }
32789
+ /**
32790
+ * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
32791
+ * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
32792
+ * normal breathing of the tracker box (~2/3 per side) while rejecting the
32793
+ * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
32794
+ * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
32795
+ * "best" thumbnail was a sliver of a distant car).
32796
+ */
32797
+ var RETRY_MIN_FRACTION_OF_BEST = .45;
32798
+ /**
32799
+ * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
32800
+ * native lease misses the first seconds, so the large-subject captures return
32801
+ * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
32802
+ * time the lease warms the subject may have shrunk to a sliver; capturing THAT
32803
+ * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
32804
+ * stops all further retries. Reject a retry whose current bbox area has
32805
+ * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
32806
+ *
32807
+ * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
32808
+ * capture is never routed through this gate (the caller bypasses it on
32809
+ * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
32810
+ * info to reject on → plausible.
32811
+ */
32812
+ function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
32813
+ if (bestSeen === null) return true;
32814
+ const bestArea = bestSeen.w * bestSeen.h;
32815
+ if (bestArea <= 0) return true;
32816
+ return current.w * current.h >= minFractionOfBest * bestArea;
32817
+ }
32818
+ //#endregion
32819
+ //#region src/pipeline-analytics/pipeline/semantic-promotion.ts
32820
+ /**
32821
+ * Compose the {@link SemanticRecognitionFrame} for a plate read / accepted
32822
+ * fine classification. ONE rule, enforced here rather than at the call site:
32823
+ * the promoted `bbox` is ALWAYS the SUBJECT box (`frame.bbox`), never the
32824
+ * detail box — see {@link DetailAttributionResult.bbox} for what feeding the
32825
+ * detail box cost. The face path (`face-recognizer.ts` `onSemanticMatch`)
32826
+ * already passes its body box; this keeps the plate/classification path on the
32827
+ * same discipline.
32828
+ */
32829
+ function semanticFrameForDetailAttribution(deviceId, trackId, detail, frame) {
32830
+ return {
32831
+ deviceId,
32832
+ trackId,
32833
+ timestamp: frame.timestamp,
32834
+ bbox: {
32835
+ x: frame.bbox.x,
32836
+ y: frame.bbox.y,
32837
+ w: frame.bbox.w,
32838
+ h: frame.bbox.h
32839
+ },
32840
+ frameWidth: frame.frameWidth,
32841
+ frameHeight: frame.frameHeight,
32842
+ kind: detail.className === "plate" ? "plate" : "classification",
32843
+ score: detail.score,
32844
+ confidence: frame.confidence,
32845
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
32846
+ };
32847
+ }
32848
+ /**
32849
+ * Offer the recognition frame to the best-frame ranking with the semantic
32850
+ * tier. On a win: record the best-seen bbox (plausible boxes only — an
32851
+ * envelope-exploded box must not poison the retry reference, mirroring
32852
+ * `buildSnapshotTargets`) and re-fire the captures when the pixels are still
32853
+ * reachable.
32854
+ */
32855
+ function promoteSemanticBestFrame(deps, input) {
32856
+ const { deviceId, trackId, bbox, frameWidth, frameHeight, timestamp } = input;
32857
+ if (!deps.observeBestFrame({
32858
+ deviceId,
32859
+ trackId,
32860
+ confidence: input.confidence,
32861
+ timestamp,
32862
+ edgeClear: isEdgeClear({
32863
+ bbox,
32864
+ frameWidth,
32865
+ frameHeight
32866
+ }),
32867
+ centerScore: bboxCenterScore(bbox, frameWidth, frameHeight),
32868
+ containment: bboxContainment(bbox, frameWidth, frameHeight),
32869
+ sizeScore: bboxSizeScore(bbox, frameWidth, frameHeight),
32870
+ squareFraction: centralSquareContainmentFraction(bbox, {
32871
+ W: frameWidth,
32872
+ H: frameHeight
32873
+ }),
32874
+ semantic: true
32875
+ })) {
32876
+ deps.logger.debug("best-shot: semantic frame did not beat the held peak", {
32877
+ tags: { deviceId },
32878
+ meta: {
32879
+ trackId,
32880
+ kind: input.kind,
32881
+ score: input.score,
32882
+ timestamp
32883
+ }
32884
+ });
32885
+ return {
32886
+ promoted: false,
32887
+ captured: false
32888
+ };
32889
+ }
32890
+ const plausible = isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, input.confidence);
32891
+ if (plausible) deps.recordBestSeenBbox(deviceId, trackId, {
32892
+ x: bbox.x,
32893
+ y: bbox.y,
32894
+ w: bbox.w,
32895
+ h: bbox.h,
32896
+ frameWidth,
32897
+ frameHeight,
32898
+ timestamp
32899
+ });
32900
+ if (!plausible || input.frameHandle === void 0) {
32901
+ deps.logger.info("best-shot: semantic promotion without capture", {
32902
+ tags: { deviceId },
32903
+ meta: {
32904
+ trackId,
32905
+ kind: input.kind,
32906
+ score: input.score,
32907
+ timestamp,
32908
+ reason: plausible ? "no frame handle" : "implausible box"
32909
+ }
32910
+ });
32911
+ return {
32912
+ promoted: true,
32913
+ captured: false
32914
+ };
32915
+ }
32916
+ deps.captureBestShot({
32917
+ deviceId,
32918
+ trackId,
32919
+ timestamp,
32920
+ bbox,
32921
+ frameWidth,
32922
+ frameHeight,
32923
+ frameHandle: input.frameHandle
32924
+ });
32925
+ deps.logger.info("best-shot: semantic promotion", {
32926
+ tags: { deviceId },
32927
+ meta: {
32928
+ trackId,
32929
+ kind: input.kind,
32930
+ score: input.score,
32931
+ timestamp
32932
+ }
32933
+ });
32934
+ return {
32935
+ promoted: true,
32936
+ captured: true
32937
+ };
32938
+ }
32738
32939
  //#endregion
32739
32940
  //#region src/pipeline-analytics/store/zone-geometry.ts
32740
32941
  /**
@@ -34473,6 +34674,7 @@ var PlateGalleryProvider = class {
34473
34674
  refreshGallery;
34474
34675
  emitPlateGalleryChanged;
34475
34676
  eventMediaBaseUrl;
34677
+ eventMediaPathPrefix;
34476
34678
  constructor(deps) {
34477
34679
  this.plateStore = deps.plateStore;
34478
34680
  this.vehicleStore = deps.vehicleStore;
@@ -34483,6 +34685,7 @@ var PlateGalleryProvider = class {
34483
34685
  this.refreshGallery = deps.refreshGallery;
34484
34686
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
34485
34687
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
34688
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
34486
34689
  }
34487
34690
  /** Best-effort emit — never allowed to fail the mutation it follows. */
34488
34691
  safeEmitPlateGalleryChanged(payload) {
@@ -34553,13 +34756,7 @@ var PlateGalleryProvider = class {
34553
34756
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34554
34757
  }
34555
34758
  async getPlateMedia(input) {
34556
- return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
34557
- key: m.key,
34558
- kind: m.kind,
34559
- base64: m.base64,
34560
- sizeBytes: m.sizeBytes,
34561
- timestamp: m.timestamp
34562
- }));
34759
+ return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
34563
34760
  }
34564
34761
  async searchPlates(input) {
34565
34762
  const maxDistance = input.maxDistance ?? 1;
@@ -34802,6 +34999,51 @@ var PlateGalleryProvider = class {
34802
34999
  }
34803
35000
  };
34804
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
34805
35047
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
34806
35048
  /**
34807
35049
  * Disk-wins reconcile for pipeline analytics.
@@ -34866,92 +35108,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
34866
35108
  };
34867
35109
  }
34868
35110
  //#endregion
34869
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
34870
- /**
34871
- * The operator button for the debug-media reclaim.
34872
- *
34873
- * The mechanism ships armed with nothing: **arming it is the operator's
34874
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
34875
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
34876
- * that has to be typed.
34877
- *
34878
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
34879
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
34880
- * report you can read instead of a log line you have to find.
34881
- */
34882
- /**
34883
- * `report` counts; `reclaim` deletes.
34884
- *
34885
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
34886
- * button with no fields, and the safe reading of an unstated intent is the one
34887
- * that destroys nothing.
34888
- */
34889
- var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
34890
- var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
34891
- var MediaReclaimActionInputSchema = object({
34892
- mode: MediaReclaimActionModeSchema,
34893
- /** Which populations to walk. Omitted = both. */
34894
- scopes: array(MediaReclaimScopeSchema).min(1).optional(),
34895
- /** Restrict to these cameras. Omitted = every camera in retention scope. */
34896
- deviceIds: array(number().int()).min(1).optional(),
34897
- /** Start every device from the top instead of resuming its cursor. */
34898
- restart: boolean().optional(),
34899
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
34900
- pageSize: number().int().min(50).max(5e3).optional(),
34901
- /** Rows EXAMINED per camera in this pass — the pacing bound. */
34902
- maxRowsPerDevice: number().int().min(1).max(5e5).optional(),
34903
- /** Rows DELETED per camera in this pass — the blast-radius bound. */
34904
- maxReclaimPerDevice: number().int().min(1).max(5e5).optional(),
34905
- /** Bytes released across the whole run — the disk-contention bound. */
34906
- maxBytesPerRun: number().int().min(1).optional(),
34907
- /** Wall-clock ceiling for the run. */
34908
- budgetMinutes: number().int().min(1).max(720).optional(),
34909
- /**
34910
- * Bytes per second the pass may release. The recordings volume sustains
34911
- * ~4 MB/s and the archive needs it more than this does; the default is half
34912
- * of that.
34913
- */
34914
- throttleBytesPerSec: number().int().min(64 * 1024).optional(),
34915
- /**
34916
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
34917
- * exclusively save rows, never select them.
34918
- */
34919
- graceMinutes: number().int().min(1).max(10080).optional()
34920
- });
34921
- var MediaReclaimDeviceReportSchema = object({
34922
- deviceId: number(),
34923
- scope: MediaReclaimScopeSchema,
34924
- examined: number(),
34925
- eligible: number(),
34926
- reclaimed: number(),
34927
- bytesReclaimed: number(),
34928
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
34929
- held: number(),
34930
- /** Rows kept because the owner could not be shown to be a motion event. */
34931
- unresolved: number(),
34932
- withinGrace: number(),
34933
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
34934
- * The row was kept and the cursor did not pass it. */
34935
- refused: number(),
34936
- stoppedBy: string(),
34937
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
34938
- nextCursor: number()
34939
- });
34940
- var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
34941
- /** `false` when a pass was already in flight — nothing was done. */
34942
- ran: boolean(),
34943
- mode: _enum(["report", "reclaim"]),
34944
- devices: array(MediaReclaimDeviceReportSchema),
34945
- totalExamined: number(),
34946
- totalEligible: number(),
34947
- totalReclaimed: number(),
34948
- totalBytesReclaimed: number(),
34949
- totalRefused: number(),
34950
- durationMs: number(),
34951
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
34952
- complete: boolean()
34953
- }), { auth: "admin" }) });
34954
- //#endregion
34955
35111
  //#region src/pipeline-analytics/retention/media-reclaim.ts
34956
35112
  var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
34957
35113
  var DEFAULT_MEDIA_RECLAIM_LIMITS = {
@@ -35162,6 +35318,92 @@ async function runMediaReclaim(input) {
35162
35318
  cursors: nextCursors
35163
35319
  };
35164
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" }) });
35165
35407
  var OrphanAuditActionInputSchema = object({
35166
35408
  mode: _enum(["report", "reclaim"]).default("report"),
35167
35409
  /**
@@ -36481,6 +36723,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
36481
36723
  return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
36482
36724
  }
36483
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
+ /**
36484
36748
  * Fold every scope's rows into ONE track media listing: detail crops
36485
36749
  * relabelled, everything filtered to `kinds`, ordered oldest first.
36486
36750
  *
@@ -36617,8 +36881,10 @@ var AnalyticsQueryFacade = class {
36617
36881
  return this.withMediaUrl(events);
36618
36882
  }
36619
36883
  async getEventMedia(input) {
36620
- const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
36621
- 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));
36622
36888
  }
36623
36889
  /**
36624
36890
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -36631,14 +36897,36 @@ var AnalyticsQueryFacade = class {
36631
36897
  async getTrackMedia(input) {
36632
36898
  const store = this.deps.mediaStore();
36633
36899
  if (store === null) return [];
36900
+ const prefix = this.deps.eventMediaPathPrefix();
36634
36901
  const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
36635
- return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
36636
- scope,
36637
- rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36638
- }))), (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) => ({
36639
36913
  ...row,
36640
36914
  kind
36641
- }), 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));
36642
36930
  }
36643
36931
  /**
36644
36932
  * A track's media WITHOUT the bytes.
@@ -36656,13 +36944,14 @@ var AnalyticsQueryFacade = class {
36656
36944
  async listTrackMedia(input) {
36657
36945
  const store = this.deps.mediaStore();
36658
36946
  if (store === null) return [];
36947
+ const prefix = this.deps.eventMediaPathPrefix();
36659
36948
  return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
36660
36949
  scope,
36661
36950
  rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36662
36951
  }))), (row, kind) => ({
36663
36952
  ...row,
36664
36953
  kind
36665
- }), void 0);
36954
+ }), void 0).map((row) => toWireMediaFileInfo(row, prefix));
36666
36955
  }
36667
36956
  async pruneEventsBefore(input) {
36668
36957
  const eventStore = this.deps.eventStore();
@@ -39107,6 +39396,31 @@ function createEventMediaHandler(deps) {
39107
39396
  }
39108
39397
  //#endregion
39109
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
+ }
39110
39424
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
39111
39425
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
39112
39426
  * for new tracks (served only for old stored rows). Undefined when the event
@@ -39157,15 +39471,24 @@ async function collectCropForcedMedia(source, id) {
39157
39471
  };
39158
39472
  }
39159
39473
  /**
39160
- * Resolve the default-path media for an event id: the event's own media when it
39161
- * has any, else the owning track's fallback media. Returns `null` when neither
39162
- * 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.
39163
39481
  */
39164
- async function resolveDefaultEventMedia(deps) {
39165
- const owned = pickEventOwnedMedia(deps.eventFiles);
39166
- if (owned) return owned;
39482
+ async function collectDefaultEventMedia(deps) {
39483
+ if (deps.eventFiles.length > 0) return {
39484
+ candidates: deps.eventFiles,
39485
+ pick: pickEventOwnedMedia
39486
+ };
39167
39487
  const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
39168
- return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
39488
+ return {
39489
+ candidates: await deps.listTrackMedia(trackId),
39490
+ pick: pickTrackFallbackMedia
39491
+ };
39169
39492
  }
39170
39493
  //#endregion
39171
39494
  //#region src/pipeline-analytics/face-settings.ts
@@ -41066,13 +41389,29 @@ var RETRAIN_FRAME_COLUMNS = [
41066
41389
  type: "TEXT"
41067
41390
  })
41068
41391
  ];
41069
- var RETRAIN_FRAME_INDEXES = [{
41070
- name: "idx_retrain_frames_track",
41071
- columns: ["trackId"]
41072
- }, {
41073
- name: "idx_retrain_frames_device",
41074
- columns: ["deviceId", "copiedAt"]
41075
- }];
41392
+ var RETRAIN_FRAME_INDEXES = [
41393
+ {
41394
+ name: "idx_retrain_frames_track",
41395
+ columns: ["trackId"]
41396
+ },
41397
+ {
41398
+ name: "idx_retrain_frames_device",
41399
+ columns: ["deviceId", "copiedAt"]
41400
+ },
41401
+ (
41402
+ /**
41403
+ * The seal gate's other half. This collection is orders of magnitude smaller
41404
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41405
+ * so the index buys much less here. It exists anyway because the gate reads
41406
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41407
+ * a lane that starts timing out the day the dataset grows. See
41408
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41409
+ */
41410
+ {
41411
+ name: "idx_retrain_frames_location",
41412
+ columns: ["locationId"]
41413
+ })
41414
+ ];
41076
41415
  /**
41077
41416
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
41078
41417
  *
@@ -44337,14 +44676,27 @@ function snapshot(j) {
44337
44676
  };
44338
44677
  }
44339
44678
  /**
44679
+ * The column both walked collections use for the storage location.
44680
+ *
44681
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44682
+ * well as a row field. A predicate naming a column the collection cannot
44683
+ * express is refused by `count` rather than dropped, so a rename that misses
44684
+ * one site fails loudly instead of counting the whole table.
44685
+ */
44686
+ var LOCATION_ID_FIELD = "locationId";
44687
+ /**
44340
44688
  * True when the row names NO location — the population `seal` exists for.
44341
44689
  *
44342
44690
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
44343
44691
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
44344
44692
  * answer, and only the first is a row whose meaning changes under a repoint.
44693
+ *
44694
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44695
+ * sends. It stays because the PASS still walks rows and must decide per row;
44696
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
44345
44697
  */
44346
44698
  function rowIsUnstamped(data) {
44347
- const id = data["locationId"];
44699
+ const id = data[LOCATION_ID_FIELD];
44348
44700
  return !(typeof id === "string" && id.length > 0);
44349
44701
  }
44350
44702
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -44402,26 +44754,91 @@ var MediaRelocateEngine = class {
44402
44754
  return true;
44403
44755
  }
44404
44756
  /**
44405
- * How many rows still carry no `locationId`, per collection.
44757
+ * Rows that still carry no `locationId`, per collection.
44406
44758
  *
44407
- * Read-only, and the ONLY number the migration planner's non-blocking gate
44408
- * is allowed to trust: it is counted here, against the rows themselves,
44759
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44760
+ * is allowed to trust: it is measured here, against the rows themselves,
44409
44761
  * rather than inferred from "a seal job reported done" — a seal that failed
44410
44762
  * some rows still reports a terminal state.
44763
+ *
44764
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44765
+ *
44766
+ * It used to be. It paged both collections through {@link pageRows} — 200
44767
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44768
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44769
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44770
+ * deadline **every time it was called since it shipped**. The gate it exists
44771
+ * to serve could therefore never open, and 152 GB of event media could not
44772
+ * be migrated.
44773
+ *
44774
+ * The walk is right for the PASS, which must visit every row to act on it.
44775
+ * It was never right for a question the store can answer with a predicate,
44776
+ * and the predicate is now expressible: `where: { locationId: null }`
44777
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44778
+ * nothing and reports it as an answer — the number the gate treats as "safe
44779
+ * to cut over"). With `idx_media_location` in place both statements read a
44780
+ * contiguous range from the head of a b-tree.
44781
+ *
44782
+ * The cheap question is asked FIRST and on its own. `present` is a single
44783
+ * seek and decides the gate; `rows` is a second statement and only decorates
44784
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44785
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44786
+ * degrades into `0`.
44411
44787
  */
44412
44788
  async countUnstamped() {
44413
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
44414
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
44415
- return {
44416
- media,
44417
- retrainFrames,
44418
- total: media + retrainFrames
44419
- };
44789
+ try {
44790
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44791
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44792
+ return {
44793
+ media,
44794
+ retrainFrames,
44795
+ anyPresent: media.present || retrainFrames.present,
44796
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44797
+ };
44798
+ } catch (err) {
44799
+ this.deps.logger.warn("event-media seal gate: could not determine whether any row is unstamped — a migration must treat this as unknown, never as sealed", { meta: { error: String(err) } });
44800
+ return null;
44801
+ }
44420
44802
  }
44421
- async countUnstampedIn(collection, orderField) {
44422
- let unstamped = 0;
44423
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
44424
- return unstamped;
44803
+ /**
44804
+ * One collection's unstamped population: existence, then size.
44805
+ *
44806
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44807
+ * of a million matching rows still walks a million index entries, while the
44808
+ * probe stops at the first one. Absence is proved by the same seek finding
44809
+ * nothing, which is where the index earns its keep — after a seal, the range
44810
+ * is empty and the answer is one b-tree descent.
44811
+ */
44812
+ async countUnstampedIn(collection) {
44813
+ if ((await this.deps.store.query.query({
44814
+ collection,
44815
+ filter: {
44816
+ where: { [LOCATION_ID_FIELD]: null },
44817
+ limit: 1
44818
+ },
44819
+ columns: [LOCATION_ID_FIELD]
44820
+ })).length === 0) return {
44821
+ present: false,
44822
+ rows: 0
44823
+ };
44824
+ try {
44825
+ return {
44826
+ present: true,
44827
+ rows: await this.deps.store.count.query({
44828
+ collection,
44829
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44830
+ })
44831
+ };
44832
+ } catch (err) {
44833
+ this.deps.logger.warn("event-media seal gate: unstamped rows exist but could not be counted — reporting the population as unknown, not as empty", { meta: {
44834
+ collection,
44835
+ error: String(err)
44836
+ } });
44837
+ return {
44838
+ present: true,
44839
+ rows: null
44840
+ };
44841
+ }
44425
44842
  }
44426
44843
  start(input) {
44427
44844
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -47178,6 +47595,70 @@ var PackageDropDetector = class {
47178
47595
  }
47179
47596
  };
47180
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
47181
47662
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
47182
47663
  /**
47183
47664
  * stationary-evidence — how a parked object EARNS a registry entry.
@@ -49709,83 +50190,6 @@ function buildRecordedStillPorts(input) {
49709
50190
  })
49710
50191
  };
49711
50192
  }
49712
- function errorMessage(err) {
49713
- return err instanceof Error ? err.message : String(err);
49714
- }
49715
- async function reconcileGallerySamples(deps, options) {
49716
- let checked = 0;
49717
- let reclaimed = 0;
49718
- let keyless = 0;
49719
- let skippedGrace = 0;
49720
- let stoppedByCeiling = false;
49721
- const identities = await deps.listIdentities();
49722
- for (const identity of identities) {
49723
- const samples = await deps.listSamples(identity.id);
49724
- for (const sample of samples) {
49725
- if (sample.mediaKey === void 0) {
49726
- keyless += 1;
49727
- continue;
49728
- }
49729
- if (options.now - sample.addedAt < options.graceMs) {
49730
- skippedGrace += 1;
49731
- continue;
49732
- }
49733
- if (reclaimed >= options.maxReclaimPerRun) {
49734
- stoppedByCeiling = true;
49735
- deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
49736
- return {
49737
- checked,
49738
- reclaimed,
49739
- keyless,
49740
- skippedGrace,
49741
- stoppedByCeiling
49742
- };
49743
- }
49744
- let state;
49745
- try {
49746
- state = await deps.blobState(sample.mediaKey);
49747
- } catch (err) {
49748
- const reason = errorMessage(err);
49749
- deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
49750
- sampleId: sample.id,
49751
- identity: identity.name,
49752
- error: reason
49753
- } });
49754
- return {
49755
- checked,
49756
- reclaimed,
49757
- keyless,
49758
- skippedGrace,
49759
- stoppedByCeiling,
49760
- aborted: reason
49761
- };
49762
- }
49763
- checked += 1;
49764
- if (state === "exists") continue;
49765
- await deps.removeSample(identity.id, sample.id);
49766
- reclaimed += 1;
49767
- deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
49768
- sampleId: sample.id,
49769
- identity: identity.name,
49770
- mediaKey: sample.mediaKey,
49771
- state
49772
- } });
49773
- }
49774
- }
49775
- if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
49776
- checked,
49777
- reclaimed,
49778
- keyless,
49779
- skippedGrace
49780
- } });
49781
- return {
49782
- checked,
49783
- reclaimed,
49784
- keyless,
49785
- skippedGrace,
49786
- stoppedByCeiling
49787
- };
49788
- }
49789
50193
  //#endregion
49790
50194
  //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
49791
50195
  /** The scope id this leg reports and persists its cursor under. */
@@ -49902,6 +50306,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
49902
50306
  nextCursor: Math.max(0, offset)
49903
50307
  };
49904
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
+ }
49905
50386
  /**
49906
50387
  * Where the completion marker lives.
49907
50388
  *
@@ -52964,14 +53445,14 @@ function isAfterCursor(row, cursor) {
52964
53445
  if (row.lastSeen < cursor.lastSeen) return true;
52965
53446
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
52966
53447
  }
52967
- var VEHICLE_CLASSES$1 = new Set([
53448
+ var VEHICLE_CLASSES = new Set([
52968
53449
  "vehicle",
52969
53450
  "car",
52970
53451
  "truck",
52971
53452
  "bus"
52972
53453
  ]);
52973
53454
  function motionlessMaxPxForClass(className) {
52974
- return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
53455
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
52975
53456
  }
52976
53457
  function netDisplacementPx(positions) {
52977
53458
  const first = positions[0];
@@ -56731,70 +57212,6 @@ function hasWrappedDetection(entries) {
56731
57212
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
56732
57213
  }
56733
57214
  //#endregion
56734
- //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
56735
- /**
56736
- * How much of the PERSON box must sit inside a vehicle box to be counted.
56737
- *
56738
- * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
56739
- * routinely pokes a little outside the vehicle box, and requiring total
56740
- * containment would count almost none of them. 0.8 and not 0.5: half a person
56741
- * box overlapping a car is what a pedestrian walking in front of one looks
56742
- * like, and counting those would make the number mean "somebody was near a
56743
- * car", which is not a defect.
56744
- */
56745
- var CONTAINMENT_THRESHOLD = .8;
56746
- /**
56747
- * The classes that count as a vehicle for this test.
56748
- *
56749
- * A local list rather than a shared taxonomy on purpose: it exists to describe
56750
- * ONE measurement, and a shared enum would make a change here silently change
56751
- * detection behaviour somewhere else.
56752
- */
56753
- var VEHICLE_CLASSES = [
56754
- "car",
56755
- "truck",
56756
- "bus",
56757
- "motorcycle",
56758
- "vehicle"
56759
- ];
56760
- /** Area of the intersection of two boxes. `0` when they do not overlap. */
56761
- function intersectionArea(a, b) {
56762
- const left = Math.max(a.x, b.x);
56763
- const right = Math.min(a.x + a.w, b.x + b.w);
56764
- const top = Math.max(a.y, b.y);
56765
- const bottom = Math.min(a.y + a.h, b.y + b.h);
56766
- if (right <= left || bottom <= top) return 0;
56767
- return (right - left) * (bottom - top);
56768
- }
56769
- /**
56770
- * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
56771
- *
56772
- * `0` for a degenerate person box: a zero-area box is inside everything and
56773
- * inside nothing, and answering `1` would count every one of them.
56774
- */
56775
- function containedFraction(person, vehicle) {
56776
- const area = person.w * person.h;
56777
- if (area <= 0) return 0;
56778
- return intersectionArea(person, vehicle) / area;
56779
- }
56780
- /**
56781
- * Is this person box contained in any vehicle box in the same frame?
56782
- *
56783
- * Returns the fraction for the BEST-containing vehicle when it clears
56784
- * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
56785
- * a counter that only ever says "yes" cannot later be re-thresholded against
56786
- * what was actually measured.
56787
- */
56788
- function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
56789
- let best = 0;
56790
- for (const neighbour of neighbours) {
56791
- if (!VEHICLE_CLASSES.includes(neighbour.className)) continue;
56792
- const fraction = containedFraction(person, neighbour.bbox);
56793
- if (fraction > best) best = fraction;
56794
- }
56795
- return best >= threshold ? best : null;
56796
- }
56797
- //#endregion
56798
57215
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
56799
57216
  function createCaptureCrop(deps) {
56800
57217
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -63236,6 +63653,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63236
63653
  * Set once the data-plane is registered; null until then (e.g. no
63237
63654
  * dataPlane facility in the current environment). */
63238
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
+ }
63239
63670
  lastActiveTrackIds = /* @__PURE__ */ new Map();
63240
63671
  /** See `pipeline/suppressed-births.ts` — rejected births must not be
63241
63672
  * re-upserted, and must be forgotten when the tracker drops the id. */
@@ -63486,6 +63917,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
63486
63917
  sensorEventStore: () => this.sensorEventStore,
63487
63918
  opsLog: () => this.eventsOpsLog,
63488
63919
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
63920
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
63489
63921
  logger: () => this.ctx.logger,
63490
63922
  zoneCatalogue: () => this.zoneCatalogue
63491
63923
  });
@@ -66028,6 +66460,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66028
66460
  logger: this.ctx.logger,
66029
66461
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
66030
66462
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66463
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66031
66464
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
66032
66465
  });
66033
66466
  this.faceGalleryProvider = faceGallery;
@@ -66042,6 +66475,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66042
66475
  this.plateRecognizer?.refreshGallery();
66043
66476
  },
66044
66477
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66478
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66045
66479
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
66046
66480
  });
66047
66481
  return [
@@ -69727,6 +70161,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
69727
70161
  async listTrackMedia(input) {
69728
70162
  return this.queryFacade.listTrackMedia(input);
69729
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
+ }
69730
70169
  /**
69731
70170
  * Search object events by text using CLIP cosine similarity.
69732
70171
  *
@@ -71447,31 +71886,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71447
71886
  return media;
71448
71887
  }
71449
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
+ */
71450
71901
  async readEventThumbnail(id, preferKind) {
71451
- 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);
71452
71912
  if (preferKind !== void 0 && preferKind.length > 0) {
71453
71913
  const layers = await collectCropForcedMedia({
71454
- listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
71455
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
71914
+ listByOwner: listInfo,
71915
+ getTrackIdForEvent
71456
71916
  }, id);
71457
- const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
71458
- if (!chosen) return null;
71459
- return {
71460
- bytes: Buffer.from(chosen.base64, "base64"),
71461
- key: chosen.key
71462
- };
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);
71463
71919
  }
71464
- const chosen = await resolveDefaultEventMedia({
71920
+ const { candidates, pick } = await collectDefaultEventMedia({
71465
71921
  eventId: id,
71466
71922
  eventFiles,
71467
- listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
71468
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
71923
+ listTrackMedia: (trackId) => listInfo("track", trackId),
71924
+ getTrackIdForEvent
71469
71925
  });
71470
- if (!chosen) return null;
71471
- return {
71472
- bytes: Buffer.from(chosen.base64, "base64"),
71473
- key: chosen.key
71474
- };
71926
+ return await readWinningBlob(candidates, pick, readBlob);
71475
71927
  }
71476
71928
  globalSettingsSchema(cap) {
71477
71929
  return this.schema(buildGlobalSettingsSchema(cap));