@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BTviHGUC.js");
5
+ const require_dist = require("../dist-Bw6cidiJ.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5413,12 +5413,6 @@ function buildAlarmButtons(input) {
5413
5413
  }];
5414
5414
  }
5415
5415
  //#endregion
5416
- //#region src/notification-center/cap-caller.ts
5417
- function requireCapCaller(method, caller) {
5418
- 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`);
5419
- return caller;
5420
- }
5421
- //#endregion
5422
5416
  //#region src/notification-center/audio-rule-matcher.ts
5423
5417
  /**
5424
5418
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -5661,6 +5655,12 @@ var AudioWatcher = class {
5661
5655
  }
5662
5656
  };
5663
5657
  //#endregion
5658
+ //#region src/notification-center/cap-caller.ts
5659
+ function requireCapCaller(method, caller) {
5660
+ 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`);
5661
+ return caller;
5662
+ }
5663
+ //#endregion
5664
5664
  //#region src/shared/llm-vision/prompt-hygiene.ts
5665
5665
  /**
5666
5666
  * What a PIPELINE VALUE is allowed to look like once it is inside a prompt.
@@ -6270,12 +6270,107 @@ var NcDeviceStateCache = class {
6270
6270
  }
6271
6271
  };
6272
6272
  //#endregion
6273
+ //#region src/notification-center/system-event-tap-url.ts
6274
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
6275
+ var ADMIN_ADDONS_PATH = "/system/addons";
6276
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
6277
+ var ADMIN_DEVICES_PATH = "/devices";
6278
+ /**
6279
+ * The query parameter naming a node.
6280
+ *
6281
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
6282
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
6283
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
6284
+ */
6285
+ var NODE_PARAM = "node";
6286
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
6287
+ var TAB_PARAM = "tab";
6288
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
6289
+ var STREAM_BROKER_TAB = "stream-broker";
6290
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
6291
+ * string map with no device-model dependency; the guard pins it. */
6292
+ var CAMERA_DEVICE_TYPE = "camera";
6293
+ /** Kinds whose action is "go and apply an ADDON update". */
6294
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6295
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6296
+ var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6297
+ /**
6298
+ * Kinds whose action is "go and look at this device's stream".
6299
+ *
6300
+ * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
6301
+ * because "did it really come back, and what did it do while it was gone" is
6302
+ * an action and the page that answers it is the one the down-notification
6303
+ * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
6304
+ * Videocamera sala feste è online" with `tapAction.value: null` — the same
6305
+ * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
6306
+ * the recovery opened nothing, purely because `device-online` had no entry
6307
+ * here.
6308
+ *
6309
+ * The legacy `camera-*` pair is here for the same reason both are still in the
6310
+ * enum: outbox and history rows enqueued before the rename carry them, and an
6311
+ * old row is exactly the one whose action has to keep working.
6312
+ *
6313
+ * Kinds NOT here are kinds whose DOWN direction has no action either
6314
+ * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
6315
+ * recovery does not earn a destination its failure was never given.
6316
+ */
6317
+ var DEVICE_STREAM_KINDS = new Set([
6318
+ "device-offline",
6319
+ "device-online",
6320
+ "stream-offline",
6321
+ "stream-online",
6322
+ "camera-offline",
6323
+ "camera-online"
6324
+ ]);
6325
+ /**
6326
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6327
+ *
6328
+ * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
6329
+ * too — that name is precisely the claim it made, and it is why the kind was
6330
+ * split into `device-*` + a `deviceTypes` condition.
6331
+ */
6332
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
6333
+ "stream-offline",
6334
+ "stream-online",
6335
+ "camera-offline",
6336
+ "camera-online"
6337
+ ]);
6338
+ function withParam(path, key, value) {
6339
+ if (value === void 0 || value.length === 0) return path;
6340
+ return `${path}?${key}=${encodeURIComponent(value)}`;
6341
+ }
6342
+ /**
6343
+ * The admin path (with its query) a system event taps through to, or `null`
6344
+ * when the kind has no action.
6345
+ *
6346
+ * Exported for the cross-package guard and for tests that want the path
6347
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
6348
+ */
6349
+ function systemEventTapPath(subject) {
6350
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6351
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6352
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
6353
+ const deviceId = subject.deviceId;
6354
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6355
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
6356
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
6357
+ }
6358
+ return null;
6359
+ }
6360
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
6361
+ function systemEventTapUrl(subject, baseUrl) {
6362
+ if (baseUrl === null || baseUrl.length === 0) return null;
6363
+ const path = systemEventTapPath(subject);
6364
+ if (path === null) return null;
6365
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
6366
+ }
6367
+ //#endregion
6273
6368
  //#region src/notification-center/default-tap-url.ts
6274
6369
  /** The viewer's hosted base path under the hub. */
6275
6370
  var VIEWER_BASE_PATH = "/viewer/camstack";
6276
6371
  function defaultTapUrl(input) {
6277
- if (input.systemEvent === true) return null;
6278
6372
  if (input.baseUrl === null || input.baseUrl.length === 0) return null;
6373
+ if (input.systemEvent !== void 0) return systemEventTapUrl(input.systemEvent, input.baseUrl);
6279
6374
  if (!Number.isInteger(input.deviceId) || input.deviceId <= 0) return null;
6280
6375
  return `${input.baseUrl.replace(/\/+$/, "")}${VIEWER_BASE_PATH}/camera/${String(input.deviceId)}?at=${String(input.timestampMs)}`;
6281
6376
  }
@@ -8868,13 +8963,14 @@ function trailAt(trail, timestamp) {
8868
8963
  * cannot be asked (no trail, no polygon) or when nobody qualifies.
8869
8964
  */
8870
8965
  function selectTriggerZoneCandidates(input) {
8871
- const { candidates, trail, polygons } = input;
8966
+ const { candidates, trail, polygons, strict } = input;
8872
8967
  if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8873
8968
  const qualifying = candidates.filter((candidate) => {
8874
8969
  const point = trailAt(trail, candidate.timestamp);
8875
8970
  return point !== null && zoneOverlap(point, polygons) > 0;
8876
8971
  });
8877
- return qualifying.length > 0 ? qualifying : candidates;
8972
+ if (qualifying.length > 0) return qualifying;
8973
+ return strict === true ? [] : candidates;
8878
8974
  }
8879
8975
  //#endregion
8880
8976
  //#region src/notification-center/dispatcher.ts
@@ -8912,6 +9008,24 @@ var NC_STILL_WAIT_MS = 3e3;
8912
9008
  * is the instant; the ladder then runs among the frames that are about it.
8913
9009
  */
8914
9010
  var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
9011
+ /**
9012
+ * Whether this row's subject can still be photographed AGAIN after its trigger.
9013
+ *
9014
+ * The one thing that separates a row worth waiting for from a row whose only
9015
+ * frame already exists. A live visual track keeps writing media for as long as
9016
+ * it is open — its first frame, its periodic snapshots, its last frame all land
9017
+ * after the instant a zone condition fired. Every other record is photographed
9018
+ * once: an audio window, a device event and a package event are served by the
9019
+ * still shelf, which D178 makes take ONE picture for a whole burst, and a
9020
+ * `track-end` row is by definition past the last of them.
9021
+ *
9022
+ * Waiting on the wrong side of this line has cost both ways in production — 3 s
9023
+ * of latency on a scream when nothing more was coming, and an empty doorstep on
9024
+ * 'Persona su Uscio' when something was.
9025
+ */
9026
+ function canStillGainMedia(entry) {
9027
+ return entry.recordKind === "object-event";
9028
+ }
8915
9029
  var NcDispatcher = class {
8916
9030
  deps;
8917
9031
  targetCache = null;
@@ -9391,7 +9505,12 @@ var NcDispatcher = class {
9391
9505
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
9392
9506
  timestampMs: subject.timestamp,
9393
9507
  baseUrl: await this.deps.viewerUrlBase().catch(() => null),
9394
- systemEvent: systemEvent !== void 0
9508
+ ...systemEvent !== void 0 ? { systemEvent: {
9509
+ kind: systemEvent.kind,
9510
+ ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
9511
+ ...systemEvent.deviceType !== void 0 ? { deviceType: systemEvent.deviceType } : {},
9512
+ ...systemEvent.nodeId !== void 0 ? { nodeId: systemEvent.nodeId } : {}
9513
+ } } : {}
9395
9514
  }) : null;
9396
9515
  const actions = await this.mintActions(entry, target);
9397
9516
  return {
@@ -9526,7 +9645,7 @@ var NcDispatcher = class {
9526
9645
  const out = [];
9527
9646
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9528
9647
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9529
- const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9648
+ const firstLook = await this.resolveAttachment(entry, stillOverride, canStillGainMedia(entry) ? "trigger-only" : "any");
9530
9649
  const footagePending = this.renderFootage(entry);
9531
9650
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9532
9651
  const footage = await footagePending;
@@ -9770,20 +9889,34 @@ var NcDispatcher = class {
9770
9889
  * throw. And when nothing qualifies, the full list comes back: a real picture
9771
9890
  * beats no picture (the same rule D212 settled for the best shot).
9772
9891
  */
9773
- async narrowToTriggerZone(entry, ordered) {
9892
+ async narrowToTriggerZone(entry, ordered, freshness) {
9774
9893
  const zoneIds = entry.payload.triggerZoneIds;
9775
9894
  const trackId = entry.payload.subject.trackId;
9776
9895
  const getTrail = this.deps.getSubjectTrail;
9777
9896
  const deviceId = entry.payload.subject.deviceId;
9778
- if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9897
+ const strict = freshness === "trigger-only";
9898
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2 && !strict) return ordered;
9779
9899
  try {
9780
9900
  const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9781
9901
  const kept = selectTriggerZoneCandidates({
9782
9902
  candidates: ordered,
9783
9903
  trail,
9784
9904
  polygons,
9785
- triggerAt: entry.payload.subject.timestamp
9905
+ triggerAt: entry.payload.subject.timestamp,
9906
+ strict
9786
9907
  });
9908
+ if (kept.length === 0) {
9909
+ this.deps.logger.debug("no still from the zone that fired the rule — still waiting", {
9910
+ tags: { deviceId },
9911
+ meta: {
9912
+ ruleId: entry.ruleId,
9913
+ trackId,
9914
+ zoneIds: zoneIds.join(","),
9915
+ rejected: ordered.length
9916
+ }
9917
+ });
9918
+ return kept;
9919
+ }
9787
9920
  if (kept.length === ordered.length) return kept;
9788
9921
  this.deps.logger.info("still narrowed to the zone that fired the rule", {
9789
9922
  tags: { deviceId },
@@ -9863,7 +9996,8 @@ var NcDispatcher = class {
9863
9996
  for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9864
9997
  const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9865
9998
  if (inBand.length === 0) continue;
9866
- const shortlist = await this.narrowToTriggerZone(entry, inBand);
9999
+ const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
10000
+ if (shortlist.length === 0) continue;
9867
10001
  for (const file of shortlist) {
9868
10002
  const raw = Buffer.from(file.base64, "base64");
9869
10003
  if (raw.byteLength === 0) continue;
@@ -10617,16 +10751,26 @@ function incomingFromUpdateAvailable(facts, data) {
10617
10751
  ...nodeIds.length > 0 ? { nodeIds } : {}
10618
10752
  });
10619
10753
  }
10620
- /** Version-bearing `addon.updated` only — settings saves carry neither. */
10754
+ /**
10755
+ * Version-bearing `addon.updated` only — a settings save carries no versions
10756
+ * at all (`BaseAddon.updateGlobalSettings`).
10757
+ *
10758
+ * `toVersion` IS the discriminator, and `fromVersion` is not: the emitter
10759
+ * omits the predecessor when it cannot read what was installed (a first
10760
+ * install, an unreadable tree). Requiring both dropped those updates on the
10761
+ * floor entirely — the operator got no notification for an update that
10762
+ * happened — which is the price that was being paid to keep `'0.0.0'`
10763
+ * out. Report the half that is known; say nothing about the half that is not.
10764
+ */
10621
10765
  function parseAddonUpdateComplete(data) {
10622
10766
  const packageName = stringField(data, "packageName");
10623
10767
  const fromVersion = stringField(data, "fromVersion");
10624
10768
  const toVersion = stringField(data, "toVersion");
10625
- if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10769
+ if (packageName === void 0 || toVersion === void 0) return null;
10626
10770
  const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10627
10771
  return {
10628
10772
  packageName,
10629
- fromVersion,
10773
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10630
10774
  toVersion,
10631
10775
  ...nodeId !== void 0 ? { nodeId } : {}
10632
10776
  };
@@ -10636,7 +10780,10 @@ function incomingFromAddonUpdates(facts, updates) {
10636
10780
  const packageNames = unique(updates.map((update) => update.packageName));
10637
10781
  const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10638
10782
  const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10639
- 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");
10783
+ const list = updates.map((update) => {
10784
+ const transition = update.fromVersion !== void 0 ? `${update.fromVersion} → ${update.toVersion}` : `→ ${update.toVersion}`;
10785
+ return update.nodeId !== void 0 ? `${update.packageName} ${transition} (${update.nodeId})` : `${update.packageName} ${transition}`;
10786
+ }).join("\n");
10640
10787
  const head = updates[0];
10641
10788
  return systemSubject({
10642
10789
  facts,
@@ -23473,17 +23620,17 @@ function planRuleTest(input) {
23473
23620
  deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
23474
23621
  nodeId: systemScope.nodeId ?? "hub",
23475
23622
  packageName: systemScope.packageName ?? "@camstack/server",
23476
- currentVersion: "0.0.0",
23477
- latestVersion: "0.0.0",
23623
+ currentVersion: "1.0.0",
23624
+ latestVersion: "1.0.1",
23478
23625
  profile: "low",
23479
23626
  seconds: "30",
23480
23627
  devices: "cuda:0",
23481
23628
  minutes: "15",
23482
23629
  addons: TEST_CRASHED_RUNNER,
23483
- list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
23630
+ list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
23484
23631
  nodes: "hub",
23485
- fromVersion: "0.0.0",
23486
- toVersion: "0.0.1",
23632
+ fromVersion: "1.0.0",
23633
+ toVersion: "1.0.1",
23487
23634
  title: "Export",
23488
23635
  count: "1"
23489
23636
  },
@@ -25509,13 +25656,46 @@ var MEDIA_COLUMNS = [
25509
25656
  type: "TEXT"
25510
25657
  })
25511
25658
  ];
25512
- var MEDIA_INDEXES = [{
25513
- name: "idx_media_owner",
25514
- columns: ["ownerKind", "ownerId"]
25515
- }, {
25516
- name: "idx_media_device_ts",
25517
- columns: ["deviceId", "timestamp"]
25518
- }];
25659
+ var MEDIA_INDEXES = [
25660
+ {
25661
+ name: "idx_media_owner",
25662
+ columns: ["ownerKind", "ownerId"]
25663
+ },
25664
+ {
25665
+ name: "idx_media_device_ts",
25666
+ columns: ["deviceId", "timestamp"]
25667
+ },
25668
+ (
25669
+ /**
25670
+ * The `locationId IS NULL` seal gate — see `media-relocate-engine.ts`.
25671
+ *
25672
+ * NULLs sort FIRST in a SQLite index, so `WHERE locationId IS NULL` is a
25673
+ * contiguous range from the head of the b-tree: its cost is proportional to
25674
+ * the ANSWER, not to the table. That is the property the gate needs. The
25675
+ * question it must answer fastest is "are there none left", and with this
25676
+ * index that is one seek that immediately falls off the end of an empty
25677
+ * range — regardless of how many million stamped rows sit behind it.
25678
+ *
25679
+ * **What it costs.** One extra b-tree maintained by every insert into a
25680
+ * table taking ~92 k rows/day, and roughly 25 bytes of index per row (~30 MB
25681
+ * at 1.25 M rows). At ~1 insert/s sustained that maintenance is noise
25682
+ * against the JPEG write the same call performs. The one-off cost is real
25683
+ * and worth naming: the FIRST declare after this ships builds the index over
25684
+ * every existing row, on a recordings volume that is currently saturated —
25685
+ * expect the analytics addon's boot to stall for tens of seconds to a couple
25686
+ * of minutes, once.
25687
+ *
25688
+ * A PARTIAL index (`WHERE locationId IS NULL`) would be smaller still and
25689
+ * would cost nothing at all once sealed. It is not used because
25690
+ * `CollectionIndexSchema` has no predicate and the schema fingerprint does
25691
+ * not hash one — adding both to serve one query is a bigger change than the
25692
+ * query deserves, and the plain index already makes the gate O(log n).
25693
+ */
25694
+ {
25695
+ name: "idx_media_location",
25696
+ columns: ["locationId"]
25697
+ })
25698
+ ];
25519
25699
  /** The storage location of a media row/record: its stamped `locationId`, or
25520
25700
  * the default `eventMedia` location for NULL (pre-multi-location) rows. */
25521
25701
  var DEFAULT_MEDIA_LOCATION = "eventMedia";
@@ -25600,7 +25780,7 @@ var MediaStore = class {
25600
25780
  });
25601
25781
  }
25602
25782
  /** Persist a media blob and register its metadata row. Returns the
25603
- * collection key (MediaFile.key) that callers store as reference. */
25783
+ * collection key (MediaFileRef.key) that callers store as reference. */
25604
25784
  async put(params) {
25605
25785
  if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
25606
25786
  return this.putUnchecked(params);
@@ -25922,8 +26102,20 @@ var MediaStore = class {
25922
26102
  * its device at write time, so scoping the query by it makes a media read
25923
26103
  * for a device the caller cannot name return nothing — the DB itself
25924
26104
  * enforces the boundary, no post-hoc ownership check to forget.
26105
+ *
26106
+ * `kinds` is the WORK filter, and it belongs here rather than on the answer.
26107
+ * Every row this returns has had its blob read off disk and base64-encoded,
26108
+ * so a caller that filters afterwards has already paid for every kind it is
26109
+ * about to discard: measured on the live hub, one track's six kinds are
26110
+ * 3 597 219 bytes and `thumbnailSmall` is 31 155 of them, and asking for the
26111
+ * small one alone cost exactly as much as asking for all six. `kind` is a
26112
+ * real column (see {@link MEDIA_COLUMNS}), so the narrowing happens in the
26113
+ * query and the blobs of the other kinds are never opened.
26114
+ *
26115
+ * Absent (or empty) means every kind — the back-compat default, and the
26116
+ * honest answer for a caller that has not decided what it needs.
25925
26117
  */
25926
- async listByOwner(ownerKind, ownerId, deviceId) {
26118
+ async listByOwner(ownerKind, ownerId, deviceId, kinds) {
25927
26119
  const rows = await this.store.query.query({
25928
26120
  collection: MEDIA_COLLECTION,
25929
26121
  filter: {
@@ -25935,6 +26127,7 @@ var MediaStore = class {
25935
26127
  ownerId,
25936
26128
  deviceId
25937
26129
  },
26130
+ ...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
25938
26131
  orderBy: {
25939
26132
  field: "timestamp",
25940
26133
  direction: "asc"
@@ -27851,6 +28044,63 @@ var embeddingActions = require_dist.defineCustomActions({
27851
28044
  "embedding.reembedFacesStatus": require_dist.customAction(NoInputSchema$1, ReembedFacesStatusSchema)
27852
28045
  });
27853
28046
  //#endregion
28047
+ //#region src/pipeline-analytics/media-file-url.ts
28048
+ /**
28049
+ * The plane path for one stored media key.
28050
+ *
28051
+ * `prefix` is `/addon/<addonId>/event-media` — a pure function of the addon
28052
+ * id, so it exists whether or not `dataPlane.serve` succeeded. A row that
28053
+ * named no address at all would be the silent drop this repo keeps paying for:
28054
+ * a client cannot tell "no bytes" from "no plane", and renders neither.
28055
+ */
28056
+ function mediaFileUrl(prefix, key) {
28057
+ return `${prefix}/${encodeURIComponent(key)}`;
28058
+ }
28059
+ /**
28060
+ * A stored row as the wire sees it: the ref, the address, and — transitionally
28061
+ * — the bytes.
28062
+ *
28063
+ * `base64` is copied through ONLY because the deployed viewer's track-detail
28064
+ * hero still requires the field to parse a row at all. See the `MediaFile`
28065
+ * docblock in `pipeline-analytics.cap.ts`; deleting the field is a one-line
28066
+ * change here and one in the schema.
28067
+ *
28068
+ * `provenance` is deliberately NOT spread: it is store-internal, and the
28069
+ * previous `{ ...row, kind }` shape published it by accident.
28070
+ */
28071
+ function toWireMediaFile(row, prefix) {
28072
+ return {
28073
+ key: row.key,
28074
+ kind: row.kind,
28075
+ sizeBytes: row.sizeBytes,
28076
+ timestamp: row.timestamp,
28077
+ url: mediaFileUrl(prefix, row.key),
28078
+ base64: row.base64
28079
+ };
28080
+ }
28081
+ /** The same row without its bytes — the manifest shape. Carries the SAME
28082
+ * `url` as the byte read, so a client never builds the plane path itself. */
28083
+ function toWireMediaFileInfo(row, prefix) {
28084
+ return {
28085
+ key: row.key,
28086
+ kind: row.kind,
28087
+ sizeBytes: row.sizeBytes,
28088
+ timestamp: row.timestamp,
28089
+ url: mediaFileUrl(prefix, row.key)
28090
+ };
28091
+ }
28092
+ //#endregion
28093
+ //#region src/pipeline-analytics/gallery-media-row.ts
28094
+ function toGalleryMediaRow(row, prefix) {
28095
+ return {
28096
+ key: row.key,
28097
+ kind: row.kind,
28098
+ url: mediaFileUrl(prefix, row.key),
28099
+ sizeBytes: row.sizeBytes,
28100
+ timestamp: row.timestamp
28101
+ };
28102
+ }
28103
+ //#endregion
27854
28104
  //#region src/pipeline-analytics/include-crops-default.ts
27855
28105
  function resolveIncludeCrops(input) {
27856
28106
  return input?.includeCrops === true;
@@ -29510,6 +29760,7 @@ var FaceGalleryProvider = class {
29510
29760
  logger;
29511
29761
  refreshGallery;
29512
29762
  eventMediaBaseUrl;
29763
+ eventMediaPathPrefix;
29513
29764
  emitFaceGalleryChanged;
29514
29765
  constructor(deps) {
29515
29766
  this.identityStore = deps.identityStore;
@@ -29520,6 +29771,7 @@ var FaceGalleryProvider = class {
29520
29771
  this.logger = deps.logger;
29521
29772
  this.refreshGallery = deps.refreshGallery;
29522
29773
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
29774
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
29523
29775
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
29524
29776
  }
29525
29777
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -29707,13 +29959,7 @@ var FaceGalleryProvider = class {
29707
29959
  * the enrolled identity copies — those live under identity owner).
29708
29960
  */
29709
29961
  async getFaceMedia(input) {
29710
- return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => ({
29711
- key: m.key,
29712
- kind: m.kind,
29713
- base64: m.base64,
29714
- sizeBytes: m.sizeBytes,
29715
- timestamp: m.timestamp
29716
- }));
29962
+ return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
29717
29963
  }
29718
29964
  /**
29719
29965
  * Assign a buffered face to an identity:
@@ -32426,209 +32672,6 @@ var FrameProcessor = class {
32426
32672
  };
32427
32673
  }
32428
32674
  };
32429
- //#endregion
32430
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
32431
- /**
32432
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
32433
- * return the tracks it produced.
32434
- *
32435
- * No `TrackStore`, no bus, no media — a track here is a summary built
32436
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
32437
- */
32438
- function runFrameProcessorPass(input) {
32439
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
32440
- if (input.zones !== void 0) processor.setZones(input.zones);
32441
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
32442
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
32443
- const accum = /* @__PURE__ */ new Map();
32444
- for (const { timestamp, frame } of input.frames) {
32445
- const result = processor.process({
32446
- timestamp,
32447
- frame
32448
- });
32449
- for (const td of result.tracked) {
32450
- const existing = accum.get(td.trackId);
32451
- const matched = td.matchedThisFrame ? 1 : 0;
32452
- if (existing === void 0) accum.set(td.trackId, {
32453
- className: td.className,
32454
- firstSeenMs: timestamp,
32455
- lastSeenMs: timestamp,
32456
- bbox: { ...td.bbox },
32457
- framesMatched: matched
32458
- });
32459
- else {
32460
- existing.className = td.className;
32461
- existing.lastSeenMs = timestamp;
32462
- existing.framesMatched += matched;
32463
- }
32464
- }
32465
- }
32466
- return { tracks: [...accum.values()].map((t) => ({
32467
- className: t.className,
32468
- firstSeenMs: t.firstSeenMs,
32469
- lastSeenMs: t.lastSeenMs,
32470
- bbox: t.bbox,
32471
- framesMatched: t.framesMatched
32472
- })) };
32473
- }
32474
- function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32475
- if (frameWidth <= 0 || frameHeight <= 0) return true;
32476
- if (score !== void 0 && score >= .5) return true;
32477
- if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
32478
- if (bbox.h >= frameHeight * .95) return false;
32479
- if (bbox.w >= frameWidth * .95) return false;
32480
- return true;
32481
- }
32482
- /**
32483
- * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
32484
- * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
32485
- * normal breathing of the tracker box (~2/3 per side) while rejecting the
32486
- * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
32487
- * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
32488
- * "best" thumbnail was a sliver of a distant car).
32489
- */
32490
- var RETRY_MIN_FRACTION_OF_BEST = .45;
32491
- /**
32492
- * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
32493
- * native lease misses the first seconds, so the large-subject captures return
32494
- * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
32495
- * time the lease warms the subject may have shrunk to a sliver; capturing THAT
32496
- * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
32497
- * stops all further retries. Reject a retry whose current bbox area has
32498
- * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
32499
- *
32500
- * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
32501
- * capture is never routed through this gate (the caller bypasses it on
32502
- * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
32503
- * info to reject on → plausible.
32504
- */
32505
- function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
32506
- if (bestSeen === null) return true;
32507
- const bestArea = bestSeen.w * bestSeen.h;
32508
- if (bestArea <= 0) return true;
32509
- return current.w * current.h >= minFractionOfBest * bestArea;
32510
- }
32511
- //#endregion
32512
- //#region src/pipeline-analytics/pipeline/semantic-promotion.ts
32513
- /**
32514
- * Compose the {@link SemanticRecognitionFrame} for a plate read / accepted
32515
- * fine classification. ONE rule, enforced here rather than at the call site:
32516
- * the promoted `bbox` is ALWAYS the SUBJECT box (`frame.bbox`), never the
32517
- * detail box — see {@link DetailAttributionResult.bbox} for what feeding the
32518
- * detail box cost. The face path (`face-recognizer.ts` `onSemanticMatch`)
32519
- * already passes its body box; this keeps the plate/classification path on the
32520
- * same discipline.
32521
- */
32522
- function semanticFrameForDetailAttribution(deviceId, trackId, detail, frame) {
32523
- return {
32524
- deviceId,
32525
- trackId,
32526
- timestamp: frame.timestamp,
32527
- bbox: {
32528
- x: frame.bbox.x,
32529
- y: frame.bbox.y,
32530
- w: frame.bbox.w,
32531
- h: frame.bbox.h
32532
- },
32533
- frameWidth: frame.frameWidth,
32534
- frameHeight: frame.frameHeight,
32535
- kind: detail.className === "plate" ? "plate" : "classification",
32536
- score: detail.score,
32537
- confidence: frame.confidence,
32538
- ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
32539
- };
32540
- }
32541
- /**
32542
- * Offer the recognition frame to the best-frame ranking with the semantic
32543
- * tier. On a win: record the best-seen bbox (plausible boxes only — an
32544
- * envelope-exploded box must not poison the retry reference, mirroring
32545
- * `buildSnapshotTargets`) and re-fire the captures when the pixels are still
32546
- * reachable.
32547
- */
32548
- function promoteSemanticBestFrame(deps, input) {
32549
- const { deviceId, trackId, bbox, frameWidth, frameHeight, timestamp } = input;
32550
- if (!deps.observeBestFrame({
32551
- deviceId,
32552
- trackId,
32553
- confidence: input.confidence,
32554
- timestamp,
32555
- edgeClear: isEdgeClear({
32556
- bbox,
32557
- frameWidth,
32558
- frameHeight
32559
- }),
32560
- centerScore: bboxCenterScore(bbox, frameWidth, frameHeight),
32561
- containment: bboxContainment(bbox, frameWidth, frameHeight),
32562
- sizeScore: bboxSizeScore(bbox, frameWidth, frameHeight),
32563
- squareFraction: centralSquareContainmentFraction(bbox, {
32564
- W: frameWidth,
32565
- H: frameHeight
32566
- }),
32567
- semantic: true
32568
- })) {
32569
- deps.logger.debug("best-shot: semantic frame did not beat the held peak", {
32570
- tags: { deviceId },
32571
- meta: {
32572
- trackId,
32573
- kind: input.kind,
32574
- score: input.score,
32575
- timestamp
32576
- }
32577
- });
32578
- return {
32579
- promoted: false,
32580
- captured: false
32581
- };
32582
- }
32583
- const plausible = isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, input.confidence);
32584
- if (plausible) deps.recordBestSeenBbox(deviceId, trackId, {
32585
- x: bbox.x,
32586
- y: bbox.y,
32587
- w: bbox.w,
32588
- h: bbox.h,
32589
- frameWidth,
32590
- frameHeight,
32591
- timestamp
32592
- });
32593
- if (!plausible || input.frameHandle === void 0) {
32594
- deps.logger.info("best-shot: semantic promotion without capture", {
32595
- tags: { deviceId },
32596
- meta: {
32597
- trackId,
32598
- kind: input.kind,
32599
- score: input.score,
32600
- timestamp,
32601
- reason: plausible ? "no frame handle" : "implausible box"
32602
- }
32603
- });
32604
- return {
32605
- promoted: true,
32606
- captured: false
32607
- };
32608
- }
32609
- deps.captureBestShot({
32610
- deviceId,
32611
- trackId,
32612
- timestamp,
32613
- bbox,
32614
- frameWidth,
32615
- frameHeight,
32616
- frameHandle: input.frameHandle
32617
- });
32618
- deps.logger.info("best-shot: semantic promotion", {
32619
- tags: { deviceId },
32620
- meta: {
32621
- trackId,
32622
- kind: input.kind,
32623
- score: input.score,
32624
- timestamp
32625
- }
32626
- });
32627
- return {
32628
- promoted: true,
32629
- captured: true
32630
- };
32631
- }
32632
32675
  /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32633
32676
  * evicts the oldest record. */
32634
32677
  var PendingPreviewUpgrades = class {
@@ -32760,6 +32803,164 @@ async function runPreviewUpgrade(deps, input) {
32760
32803
  });
32761
32804
  return "upgraded";
32762
32805
  }
32806
+ function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32807
+ if (frameWidth <= 0 || frameHeight <= 0) return true;
32808
+ if (score !== void 0 && score >= .5) return true;
32809
+ if (bbox.w * bbox.h / (frameWidth * frameHeight) >= .85) return false;
32810
+ if (bbox.h >= frameHeight * .95) return false;
32811
+ if (bbox.w >= frameWidth * .95) return false;
32812
+ return true;
32813
+ }
32814
+ /**
32815
+ * Minimum fraction of the track's BEST-SEEN bbox area a retry frame's bbox must
32816
+ * still cover to be an acceptable best-`thumbnail` RETRY subject. 0.45 tolerates
32817
+ * normal breathing of the tracker box (~2/3 per side) while rejecting the
32818
+ * degenerate shrink (observed live 2026-07-22: a car born at 78px retried at
32819
+ * 26px once the native lease warmed — a ~0.11 area fraction — and the landed
32820
+ * "best" thumbnail was a sliver of a distant car).
32821
+ */
32822
+ var RETRY_MIN_FRACTION_OF_BEST = .45;
32823
+ /**
32824
+ * RETRY-ONLY gate (#27-A degenerate-retry fix): on a cold on-motion session the
32825
+ * native lease misses the first seconds, so the large-subject captures return
32826
+ * null and the per-frame retry re-fires with the CURRENT frame's bbox. By the
32827
+ * time the lease warms the subject may have shrunk to a sliver; capturing THAT
32828
+ * frame lands a genuine-native-but-useless thumbnail and `thumbnailLanded`
32829
+ * stops all further retries. Reject a retry whose current bbox area has
32830
+ * collapsed below `minFractionOfBest` of the track's best-seen bbox area.
32831
+ *
32832
+ * Applies ONLY to the `!thumbnailLanded` retry path — a genuine new-best
32833
+ * capture is never routed through this gate (the caller bypasses it on
32834
+ * `isNewBest`). With no best-seen reference (or a degenerate one) there is no
32835
+ * info to reject on → plausible.
32836
+ */
32837
+ function isPlausibleRetryBox(current, bestSeen, minFractionOfBest = RETRY_MIN_FRACTION_OF_BEST) {
32838
+ if (bestSeen === null) return true;
32839
+ const bestArea = bestSeen.w * bestSeen.h;
32840
+ if (bestArea <= 0) return true;
32841
+ return current.w * current.h >= minFractionOfBest * bestArea;
32842
+ }
32843
+ //#endregion
32844
+ //#region src/pipeline-analytics/pipeline/semantic-promotion.ts
32845
+ /**
32846
+ * Compose the {@link SemanticRecognitionFrame} for a plate read / accepted
32847
+ * fine classification. ONE rule, enforced here rather than at the call site:
32848
+ * the promoted `bbox` is ALWAYS the SUBJECT box (`frame.bbox`), never the
32849
+ * detail box — see {@link DetailAttributionResult.bbox} for what feeding the
32850
+ * detail box cost. The face path (`face-recognizer.ts` `onSemanticMatch`)
32851
+ * already passes its body box; this keeps the plate/classification path on the
32852
+ * same discipline.
32853
+ */
32854
+ function semanticFrameForDetailAttribution(deviceId, trackId, detail, frame) {
32855
+ return {
32856
+ deviceId,
32857
+ trackId,
32858
+ timestamp: frame.timestamp,
32859
+ bbox: {
32860
+ x: frame.bbox.x,
32861
+ y: frame.bbox.y,
32862
+ w: frame.bbox.w,
32863
+ h: frame.bbox.h
32864
+ },
32865
+ frameWidth: frame.frameWidth,
32866
+ frameHeight: frame.frameHeight,
32867
+ kind: detail.className === "plate" ? "plate" : "classification",
32868
+ score: detail.score,
32869
+ confidence: frame.confidence,
32870
+ ...frame.frameHandle !== void 0 ? { frameHandle: frame.frameHandle } : {}
32871
+ };
32872
+ }
32873
+ /**
32874
+ * Offer the recognition frame to the best-frame ranking with the semantic
32875
+ * tier. On a win: record the best-seen bbox (plausible boxes only — an
32876
+ * envelope-exploded box must not poison the retry reference, mirroring
32877
+ * `buildSnapshotTargets`) and re-fire the captures when the pixels are still
32878
+ * reachable.
32879
+ */
32880
+ function promoteSemanticBestFrame(deps, input) {
32881
+ const { deviceId, trackId, bbox, frameWidth, frameHeight, timestamp } = input;
32882
+ if (!deps.observeBestFrame({
32883
+ deviceId,
32884
+ trackId,
32885
+ confidence: input.confidence,
32886
+ timestamp,
32887
+ edgeClear: isEdgeClear({
32888
+ bbox,
32889
+ frameWidth,
32890
+ frameHeight
32891
+ }),
32892
+ centerScore: bboxCenterScore(bbox, frameWidth, frameHeight),
32893
+ containment: bboxContainment(bbox, frameWidth, frameHeight),
32894
+ sizeScore: bboxSizeScore(bbox, frameWidth, frameHeight),
32895
+ squareFraction: centralSquareContainmentFraction(bbox, {
32896
+ W: frameWidth,
32897
+ H: frameHeight
32898
+ }),
32899
+ semantic: true
32900
+ })) {
32901
+ deps.logger.debug("best-shot: semantic frame did not beat the held peak", {
32902
+ tags: { deviceId },
32903
+ meta: {
32904
+ trackId,
32905
+ kind: input.kind,
32906
+ score: input.score,
32907
+ timestamp
32908
+ }
32909
+ });
32910
+ return {
32911
+ promoted: false,
32912
+ captured: false
32913
+ };
32914
+ }
32915
+ const plausible = isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, input.confidence);
32916
+ if (plausible) deps.recordBestSeenBbox(deviceId, trackId, {
32917
+ x: bbox.x,
32918
+ y: bbox.y,
32919
+ w: bbox.w,
32920
+ h: bbox.h,
32921
+ frameWidth,
32922
+ frameHeight,
32923
+ timestamp
32924
+ });
32925
+ if (!plausible || input.frameHandle === void 0) {
32926
+ deps.logger.info("best-shot: semantic promotion without capture", {
32927
+ tags: { deviceId },
32928
+ meta: {
32929
+ trackId,
32930
+ kind: input.kind,
32931
+ score: input.score,
32932
+ timestamp,
32933
+ reason: plausible ? "no frame handle" : "implausible box"
32934
+ }
32935
+ });
32936
+ return {
32937
+ promoted: true,
32938
+ captured: false
32939
+ };
32940
+ }
32941
+ deps.captureBestShot({
32942
+ deviceId,
32943
+ trackId,
32944
+ timestamp,
32945
+ bbox,
32946
+ frameWidth,
32947
+ frameHeight,
32948
+ frameHandle: input.frameHandle
32949
+ });
32950
+ deps.logger.info("best-shot: semantic promotion", {
32951
+ tags: { deviceId },
32952
+ meta: {
32953
+ trackId,
32954
+ kind: input.kind,
32955
+ score: input.score,
32956
+ timestamp
32957
+ }
32958
+ });
32959
+ return {
32960
+ promoted: true,
32961
+ captured: true
32962
+ };
32963
+ }
32763
32964
  //#endregion
32764
32965
  //#region src/pipeline-analytics/store/zone-geometry.ts
32765
32966
  /**
@@ -34498,6 +34699,7 @@ var PlateGalleryProvider = class {
34498
34699
  refreshGallery;
34499
34700
  emitPlateGalleryChanged;
34500
34701
  eventMediaBaseUrl;
34702
+ eventMediaPathPrefix;
34501
34703
  constructor(deps) {
34502
34704
  this.plateStore = deps.plateStore;
34503
34705
  this.vehicleStore = deps.vehicleStore;
@@ -34508,6 +34710,7 @@ var PlateGalleryProvider = class {
34508
34710
  this.refreshGallery = deps.refreshGallery;
34509
34711
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
34510
34712
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
34713
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
34511
34714
  }
34512
34715
  /** Best-effort emit — never allowed to fail the mutation it follows. */
34513
34716
  safeEmitPlateGalleryChanged(payload) {
@@ -34578,13 +34781,7 @@ var PlateGalleryProvider = class {
34578
34781
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34579
34782
  }
34580
34783
  async getPlateMedia(input) {
34581
- return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
34582
- key: m.key,
34583
- kind: m.kind,
34584
- base64: m.base64,
34585
- sizeBytes: m.sizeBytes,
34586
- timestamp: m.timestamp
34587
- }));
34784
+ return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
34588
34785
  }
34589
34786
  async searchPlates(input) {
34590
34787
  const maxDistance = input.maxDistance ?? 1;
@@ -34827,6 +35024,51 @@ var PlateGalleryProvider = class {
34827
35024
  }
34828
35025
  };
34829
35026
  //#endregion
35027
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
35028
+ /**
35029
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
35030
+ * return the tracks it produced.
35031
+ *
35032
+ * No `TrackStore`, no bus, no media — a track here is a summary built
35033
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
35034
+ */
35035
+ function runFrameProcessorPass(input) {
35036
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
35037
+ if (input.zones !== void 0) processor.setZones(input.zones);
35038
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
35039
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
35040
+ const accum = /* @__PURE__ */ new Map();
35041
+ for (const { timestamp, frame } of input.frames) {
35042
+ const result = processor.process({
35043
+ timestamp,
35044
+ frame
35045
+ });
35046
+ for (const td of result.tracked) {
35047
+ const existing = accum.get(td.trackId);
35048
+ const matched = td.matchedThisFrame ? 1 : 0;
35049
+ if (existing === void 0) accum.set(td.trackId, {
35050
+ className: td.className,
35051
+ firstSeenMs: timestamp,
35052
+ lastSeenMs: timestamp,
35053
+ bbox: { ...td.bbox },
35054
+ framesMatched: matched
35055
+ });
35056
+ else {
35057
+ existing.className = td.className;
35058
+ existing.lastSeenMs = timestamp;
35059
+ existing.framesMatched += matched;
35060
+ }
35061
+ }
35062
+ }
35063
+ return { tracks: [...accum.values()].map((t) => ({
35064
+ className: t.className,
35065
+ firstSeenMs: t.firstSeenMs,
35066
+ lastSeenMs: t.lastSeenMs,
35067
+ bbox: t.bbox,
35068
+ framesMatched: t.framesMatched
35069
+ })) };
35070
+ }
35071
+ //#endregion
34830
35072
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
34831
35073
  /**
34832
35074
  * Disk-wins reconcile for pipeline analytics.
@@ -34891,93 +35133,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
34891
35133
  };
34892
35134
  }
34893
35135
  //#endregion
34894
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
34895
- /**
34896
- * The operator button for the debug-media reclaim.
34897
- *
34898
- * The mechanism ships armed with nothing: **arming it is the operator's
34899
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
34900
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
34901
- * that has to be typed.
34902
- *
34903
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
34904
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
34905
- * report you can read instead of a log line you have to find.
34906
- */
34907
- /**
34908
- * `report` counts; `reclaim` deletes.
34909
- *
34910
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
34911
- * button with no fields, and the safe reading of an unstated intent is the one
34912
- * that destroys nothing.
34913
- */
34914
- var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
34915
- var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
34916
- var MediaReclaimActionInputSchema = require_dist.object({
34917
- mode: MediaReclaimActionModeSchema,
34918
- /** Which populations to walk. Omitted = both. */
34919
- scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
34920
- /** Restrict to these cameras. Omitted = every camera in retention scope. */
34921
- deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
34922
- /** Start every device from the top instead of resuming its cursor. */
34923
- restart: require_dist.boolean().optional(),
34924
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
34925
- pageSize: require_dist.number().int().min(50).max(5e3).optional(),
34926
- /** Rows EXAMINED per camera in this pass — the pacing bound. */
34927
- maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
34928
- /** Rows DELETED per camera in this pass — the blast-radius bound. */
34929
- maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
34930
- /** Bytes released across the whole run — the disk-contention bound. */
34931
- maxBytesPerRun: require_dist.number().int().min(1).optional(),
34932
- /** Wall-clock ceiling for the run. */
34933
- budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
34934
- /**
34935
- * Bytes per second the pass may release. The recordings volume sustains
34936
- * ~4 MB/s and the archive needs it more than this does; the default is half
34937
- * of that.
34938
- */
34939
- throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
34940
- /**
34941
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
34942
- * exclusively save rows, never select them.
34943
- */
34944
- graceMinutes: require_dist.number().int().min(1).max(10080).optional()
34945
- });
34946
- var MediaReclaimDeviceReportSchema = require_dist.object({
34947
- deviceId: require_dist.number(),
34948
- scope: MediaReclaimScopeSchema,
34949
- examined: require_dist.number(),
34950
- eligible: require_dist.number(),
34951
- reclaimed: require_dist.number(),
34952
- bytesReclaimed: require_dist.number(),
34953
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
34954
- held: require_dist.number(),
34955
- /** Rows kept because the owner could not be shown to be a motion event. */
34956
- unresolved: require_dist.number(),
34957
- withinGrace: require_dist.number(),
34958
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
34959
- * The row was kept and the cursor did not pass it. */
34960
- refused: require_dist.number(),
34961
- stoppedBy: require_dist.string(),
34962
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
34963
- nextCursor: require_dist.number()
34964
- });
34965
- var MediaReclaimActionResultSchema = require_dist.object({
34966
- /** `false` when a pass was already in flight — nothing was done. */
34967
- ran: require_dist.boolean(),
34968
- mode: require_dist._enum(["report", "reclaim"]),
34969
- devices: require_dist.array(MediaReclaimDeviceReportSchema),
34970
- totalExamined: require_dist.number(),
34971
- totalEligible: require_dist.number(),
34972
- totalReclaimed: require_dist.number(),
34973
- totalBytesReclaimed: require_dist.number(),
34974
- totalRefused: require_dist.number(),
34975
- durationMs: require_dist.number(),
34976
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
34977
- complete: require_dist.boolean()
34978
- });
34979
- var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
34980
- //#endregion
34981
35136
  //#region src/pipeline-analytics/retention/media-reclaim.ts
34982
35137
  var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
34983
35138
  var DEFAULT_MEDIA_RECLAIM_LIMITS = {
@@ -35189,6 +35344,93 @@ async function runMediaReclaim(input) {
35189
35344
  };
35190
35345
  }
35191
35346
  //#endregion
35347
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35348
+ /**
35349
+ * The operator button for the debug-media reclaim.
35350
+ *
35351
+ * The mechanism ships armed with nothing: **arming it is the operator's
35352
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35353
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35354
+ * that has to be typed.
35355
+ *
35356
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35357
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35358
+ * report you can read instead of a log line you have to find.
35359
+ */
35360
+ /**
35361
+ * `report` counts; `reclaim` deletes.
35362
+ *
35363
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35364
+ * button with no fields, and the safe reading of an unstated intent is the one
35365
+ * that destroys nothing.
35366
+ */
35367
+ var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
35368
+ var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
35369
+ var MediaReclaimActionInputSchema = require_dist.object({
35370
+ mode: MediaReclaimActionModeSchema,
35371
+ /** Which populations to walk. Omitted = both. */
35372
+ scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
35373
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35374
+ deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
35375
+ /** Start every device from the top instead of resuming its cursor. */
35376
+ restart: require_dist.boolean().optional(),
35377
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35378
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
35379
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35380
+ maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35381
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35382
+ maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35383
+ /** Bytes released across the whole run — the disk-contention bound. */
35384
+ maxBytesPerRun: require_dist.number().int().min(1).optional(),
35385
+ /** Wall-clock ceiling for the run. */
35386
+ budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
35387
+ /**
35388
+ * Bytes per second the pass may release. The recordings volume sustains
35389
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35390
+ * of that.
35391
+ */
35392
+ throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
35393
+ /**
35394
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35395
+ * exclusively save rows, never select them.
35396
+ */
35397
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
35398
+ });
35399
+ var MediaReclaimDeviceReportSchema = require_dist.object({
35400
+ deviceId: require_dist.number(),
35401
+ scope: MediaReclaimScopeSchema,
35402
+ examined: require_dist.number(),
35403
+ eligible: require_dist.number(),
35404
+ reclaimed: require_dist.number(),
35405
+ bytesReclaimed: require_dist.number(),
35406
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35407
+ held: require_dist.number(),
35408
+ /** Rows kept because the owner could not be shown to be a motion event. */
35409
+ unresolved: require_dist.number(),
35410
+ withinGrace: require_dist.number(),
35411
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35412
+ * The row was kept and the cursor did not pass it. */
35413
+ refused: require_dist.number(),
35414
+ stoppedBy: require_dist.string(),
35415
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35416
+ nextCursor: require_dist.number()
35417
+ });
35418
+ var MediaReclaimActionResultSchema = require_dist.object({
35419
+ /** `false` when a pass was already in flight — nothing was done. */
35420
+ ran: require_dist.boolean(),
35421
+ mode: require_dist._enum(["report", "reclaim"]),
35422
+ devices: require_dist.array(MediaReclaimDeviceReportSchema),
35423
+ totalExamined: require_dist.number(),
35424
+ totalEligible: require_dist.number(),
35425
+ totalReclaimed: require_dist.number(),
35426
+ totalBytesReclaimed: require_dist.number(),
35427
+ totalRefused: require_dist.number(),
35428
+ durationMs: require_dist.number(),
35429
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35430
+ complete: require_dist.boolean()
35431
+ });
35432
+ var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
35433
+ //#endregion
35192
35434
  //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
35193
35435
  /**
35194
35436
  * The operator button for the orphan audit.
@@ -36545,6 +36787,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
36545
36787
  return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
36546
36788
  }
36547
36789
  /**
36790
+ * The STORED kinds worth reading from one scope for a `kinds` request, or
36791
+ * `null` when the scope cannot contribute to it at all.
36792
+ *
36793
+ * This is the same vocabulary translation {@link collectTrackMedia} performs on
36794
+ * the way out, moved to the way IN so it can narrow the query instead of the
36795
+ * answer. A caller naming `faceCrop` must ask the face owner for its stored
36796
+ * `crop` — asking it for `faceCrop` matches nothing, and asking it for
36797
+ * everything opens blobs the answer will discard.
36798
+ *
36799
+ * `undefined` means "every kind": the honest translation of an unfiltered
36800
+ * request, and the only safe answer for the open-ended track scope.
36801
+ *
36802
+ * `null` (skip) is only ever returned for a DETAIL owner whose one publishable
36803
+ * kind was not asked for. The track scope is never skipped — its kinds are
36804
+ * open-ended, and dropping it on a guess is how a listing starts lying.
36805
+ */
36806
+ function storedKindsForScope(scope, kinds) {
36807
+ if (kinds === void 0 || kinds.length === 0) return void 0;
36808
+ if (scope.publishCropAs === null) return kinds;
36809
+ return kinds.includes(scope.publishCropAs) ? [STORED_DETAIL_CROP_KIND] : null;
36810
+ }
36811
+ /**
36548
36812
  * Fold every scope's rows into ONE track media listing: detail crops
36549
36813
  * relabelled, everything filtered to `kinds`, ordered oldest first.
36550
36814
  *
@@ -36681,8 +36945,10 @@ var AnalyticsQueryFacade = class {
36681
36945
  return this.withMediaUrl(events);
36682
36946
  }
36683
36947
  async getEventMedia(input) {
36684
- const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
36685
- return input.kind ? all.filter((m) => m.kind === input.kind) : all;
36948
+ const store = this.deps.mediaStore();
36949
+ if (store === null) return [];
36950
+ const prefix = this.deps.eventMediaPathPrefix();
36951
+ return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
36686
36952
  }
36687
36953
  /**
36688
36954
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -36695,14 +36961,36 @@ var AnalyticsQueryFacade = class {
36695
36961
  async getTrackMedia(input) {
36696
36962
  const store = this.deps.mediaStore();
36697
36963
  if (store === null) return [];
36964
+ const prefix = this.deps.eventMediaPathPrefix();
36698
36965
  const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
36699
- return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
36700
- scope,
36701
- rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36702
- }))), (row, kind) => ({
36966
+ return collectTrackMedia(await Promise.all(scopes.map(async (scope) => {
36967
+ const stored = storedKindsForScope(scope, input.kinds);
36968
+ if (stored === null) return {
36969
+ scope,
36970
+ rows: []
36971
+ };
36972
+ return {
36973
+ scope,
36974
+ rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId, stored)
36975
+ };
36976
+ })), (row, kind) => ({
36703
36977
  ...row,
36704
36978
  kind
36705
- }), input.kinds);
36979
+ }), input.kinds).map((row) => toWireMediaFile(row, prefix));
36980
+ }
36981
+ /**
36982
+ * An event's media WITHOUT the bytes.
36983
+ *
36984
+ * Index rows only — it never opens a blob. The track detail modal unions
36985
+ * this with {@link listTrackMedia} to build its filmstrip and then fetches
36986
+ * each tile from the `event-media` plane by key, so the bytes this used to
36987
+ * carry were read, base64'd, shipped and discarded.
36988
+ */
36989
+ async listEventMedia(input) {
36990
+ const store = this.deps.mediaStore();
36991
+ if (store === null) return [];
36992
+ const prefix = this.deps.eventMediaPathPrefix();
36993
+ return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
36706
36994
  }
36707
36995
  /**
36708
36996
  * A track's media WITHOUT the bytes.
@@ -36720,13 +37008,14 @@ var AnalyticsQueryFacade = class {
36720
37008
  async listTrackMedia(input) {
36721
37009
  const store = this.deps.mediaStore();
36722
37010
  if (store === null) return [];
37011
+ const prefix = this.deps.eventMediaPathPrefix();
36723
37012
  return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
36724
37013
  scope,
36725
37014
  rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36726
37015
  }))), (row, kind) => ({
36727
37016
  ...row,
36728
37017
  kind
36729
- }), void 0);
37018
+ }), void 0).map((row) => toWireMediaFileInfo(row, prefix));
36730
37019
  }
36731
37020
  async pruneEventsBefore(input) {
36732
37021
  const eventStore = this.deps.eventStore();
@@ -39171,6 +39460,31 @@ function createEventMediaHandler(deps) {
39171
39460
  }
39172
39461
  //#endregion
39173
39462
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
39463
+ /**
39464
+ * Rank `candidates` with `pick`, then open ONE blob — the winner's.
39465
+ *
39466
+ * The ladders decide on `kind` alone, so the bytes are dead weight until the
39467
+ * choice is made. Handing them pre-read rows meant a tile that serves the
39468
+ * 31 KB `thumbnailSmall` first read the whole 3 597 219-byte set, including a
39469
+ * 2.8 MB `keyFrame` no list view has ever rendered.
39470
+ *
39471
+ * A winner whose blob is GONE is dropped and the ladder re-run on the rest.
39472
+ * That fall-through is not an extra: `listByOwner` used to provide it for free
39473
+ * by skipping unreadable rows before the ladder ever saw them, and without it
39474
+ * an index row that outlived its file would 404 a tile that used to render.
39475
+ * Bounded by the candidate count — each attempt removes one row.
39476
+ */
39477
+ async function readWinningBlob(candidates, pick, readBlob) {
39478
+ let remaining = candidates;
39479
+ while (remaining.length > 0) {
39480
+ const winner = pick(remaining);
39481
+ if (winner === void 0) return null;
39482
+ const bytes = await readBlob(winner.key);
39483
+ if (bytes !== null) return bytes;
39484
+ remaining = remaining.filter((row) => row.key !== winner.key);
39485
+ }
39486
+ return null;
39487
+ }
39174
39488
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
39175
39489
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
39176
39490
  * for new tracks (served only for old stored rows). Undefined when the event
@@ -39221,15 +39535,24 @@ async function collectCropForcedMedia(source, id) {
39221
39535
  };
39222
39536
  }
39223
39537
  /**
39224
- * Resolve the default-path media for an event id: the event's own media when it
39225
- * has any, else the owning track's fallback media. Returns `null` when neither
39226
- * yields a blob (caller 404s → the surface shows an icon).
39538
+ * Which rows the default path should serve from, and how to rank them: the
39539
+ * event's OWN media when it has any, else the owning track's.
39540
+ *
39541
+ * It yields the candidate SET rather than a winner so the caller can open one
39542
+ * blob and, if that blob has gone, re-rank the rest ({@link readWinningBlob}).
39543
+ * Returning a single row would force the caller to read it blind and 404 a tile
39544
+ * whose index row outlived its file.
39227
39545
  */
39228
- async function resolveDefaultEventMedia(deps) {
39229
- const owned = pickEventOwnedMedia(deps.eventFiles);
39230
- if (owned) return owned;
39546
+ async function collectDefaultEventMedia(deps) {
39547
+ if (deps.eventFiles.length > 0) return {
39548
+ candidates: deps.eventFiles,
39549
+ pick: pickEventOwnedMedia
39550
+ };
39231
39551
  const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
39232
- return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
39552
+ return {
39553
+ candidates: await deps.listTrackMedia(trackId),
39554
+ pick: pickTrackFallbackMedia
39555
+ };
39233
39556
  }
39234
39557
  //#endregion
39235
39558
  //#region src/pipeline-analytics/face-settings.ts
@@ -41130,13 +41453,29 @@ var RETRAIN_FRAME_COLUMNS = [
41130
41453
  type: "TEXT"
41131
41454
  })
41132
41455
  ];
41133
- var RETRAIN_FRAME_INDEXES = [{
41134
- name: "idx_retrain_frames_track",
41135
- columns: ["trackId"]
41136
- }, {
41137
- name: "idx_retrain_frames_device",
41138
- columns: ["deviceId", "copiedAt"]
41139
- }];
41456
+ var RETRAIN_FRAME_INDEXES = [
41457
+ {
41458
+ name: "idx_retrain_frames_track",
41459
+ columns: ["trackId"]
41460
+ },
41461
+ {
41462
+ name: "idx_retrain_frames_device",
41463
+ columns: ["deviceId", "copiedAt"]
41464
+ },
41465
+ (
41466
+ /**
41467
+ * The seal gate's other half. This collection is orders of magnitude smaller
41468
+ * than `pipeline-analytics:media` — every row is an operator-staged frame —
41469
+ * so the index buys much less here. It exists anyway because the gate reads
41470
+ * the two lanes with ONE query shape, and a lane that answers by scanning is
41471
+ * a lane that starts timing out the day the dataset grows. See
41472
+ * `MEDIA_INDEXES` for the cost this is a small multiple of.
41473
+ */
41474
+ {
41475
+ name: "idx_retrain_frames_location",
41476
+ columns: ["locationId"]
41477
+ })
41478
+ ];
41140
41479
  /**
41141
41480
  * The copy's id, derived from `(trackId, sourceMediaKey)`.
41142
41481
  *
@@ -44402,14 +44741,27 @@ function snapshot(j) {
44402
44741
  };
44403
44742
  }
44404
44743
  /**
44744
+ * The column both walked collections use for the storage location.
44745
+ *
44746
+ * Named once because it is now a QUERY PREDICATE (`where: { [it]: null }`) as
44747
+ * well as a row field. A predicate naming a column the collection cannot
44748
+ * express is refused by `count` rather than dropped, so a rename that misses
44749
+ * one site fails loudly instead of counting the whole table.
44750
+ */
44751
+ var LOCATION_ID_FIELD = "locationId";
44752
+ /**
44405
44753
  * True when the row names NO location — the population `seal` exists for.
44406
44754
  *
44407
44755
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
44408
44756
  * helper collapses "unstamped" and "stamped with the bare type ref" into one
44409
44757
  * answer, and only the first is a row whose meaning changes under a repoint.
44758
+ *
44759
+ * The in-JS twin of the `IS NULL` predicate {@link MediaRelocateEngine.countUnstamped}
44760
+ * sends. It stays because the PASS still walks rows and must decide per row;
44761
+ * the two must agree, and `unstamped-count-matches-the-pass` asserts they do.
44410
44762
  */
44411
44763
  function rowIsUnstamped(data) {
44412
- const id = data["locationId"];
44764
+ const id = data[LOCATION_ID_FIELD];
44413
44765
  return !(typeof id === "string" && id.length > 0);
44414
44766
  }
44415
44767
  /** True for the enrolled-gallery rows `move` skips and `gallery` selects. */
@@ -44467,26 +44819,91 @@ var MediaRelocateEngine = class {
44467
44819
  return true;
44468
44820
  }
44469
44821
  /**
44470
- * How many rows still carry no `locationId`, per collection.
44822
+ * Rows that still carry no `locationId`, per collection.
44471
44823
  *
44472
- * Read-only, and the ONLY number the migration planner's non-blocking gate
44473
- * is allowed to trust: it is counted here, against the rows themselves,
44824
+ * Read-only, and the ONLY answer the migration planner's non-blocking gate
44825
+ * is allowed to trust: it is measured here, against the rows themselves,
44474
44826
  * rather than inferred from "a seal job reported done" — a seal that failed
44475
44827
  * some rows still reports a terminal state.
44828
+ *
44829
+ * ── Why this is not the walk the rest of this file uses ──────────────────
44830
+ *
44831
+ * It used to be. It paged both collections through {@link pageRows} — 200
44832
+ * rows per RPC, ordered by `timestamp`, a column that leads no index — and
44833
+ * counted `rowIsUnstamped` in JS. On the live hub that is ~6 300 round trips
44834
+ * over 1 254 576 rows, each paying its own sort, and it hit the 60 s RPC
44835
+ * deadline **every time it was called since it shipped**. The gate it exists
44836
+ * to serve could therefore never open, and 152 GB of event media could not
44837
+ * be migrated.
44838
+ *
44839
+ * The walk is right for the PASS, which must visit every row to act on it.
44840
+ * It was never right for a question the store can answer with a predicate,
44841
+ * and the predicate is now expressible: `where: { locationId: null }`
44842
+ * compiles to `IS NULL` (it used to compile to `= NULL`, which matches
44843
+ * nothing and reports it as an answer — the number the gate treats as "safe
44844
+ * to cut over"). With `idx_media_location` in place both statements read a
44845
+ * contiguous range from the head of a b-tree.
44846
+ *
44847
+ * The cheap question is asked FIRST and on its own. `present` is a single
44848
+ * seek and decides the gate; `rows` is a second statement and only decorates
44849
+ * the refusal. So a count that cannot be taken still leaves a usable answer
44850
+ * — `{ present: true, rows: null }` refuses just as hard — and `null` never
44851
+ * degrades into `0`.
44476
44852
  */
44477
44853
  async countUnstamped() {
44478
- const media = await this.countUnstampedIn(MEDIA_COLLECTION, "timestamp");
44479
- const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION, "copiedAt");
44480
- return {
44481
- media,
44482
- retrainFrames,
44483
- total: media + retrainFrames
44484
- };
44854
+ try {
44855
+ const media = await this.countUnstampedIn(MEDIA_COLLECTION);
44856
+ const retrainFrames = await this.countUnstampedIn(RETRAIN_FRAMES_COLLECTION);
44857
+ return {
44858
+ media,
44859
+ retrainFrames,
44860
+ anyPresent: media.present || retrainFrames.present,
44861
+ total: media.rows === null || retrainFrames.rows === null ? null : media.rows + retrainFrames.rows
44862
+ };
44863
+ } catch (err) {
44864
+ 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) } });
44865
+ return null;
44866
+ }
44485
44867
  }
44486
- async countUnstampedIn(collection, orderField) {
44487
- let unstamped = 0;
44488
- for await (const row of this.pageRows(collection, orderField)) if (rowIsUnstamped(row.data)) unstamped++;
44489
- return unstamped;
44868
+ /**
44869
+ * One collection's unstamped population: existence, then size.
44870
+ *
44871
+ * The `limit: 1` probe is deliberately a `query` and not a `count`: a count
44872
+ * of a million matching rows still walks a million index entries, while the
44873
+ * probe stops at the first one. Absence is proved by the same seek finding
44874
+ * nothing, which is where the index earns its keep — after a seal, the range
44875
+ * is empty and the answer is one b-tree descent.
44876
+ */
44877
+ async countUnstampedIn(collection) {
44878
+ if ((await this.deps.store.query.query({
44879
+ collection,
44880
+ filter: {
44881
+ where: { [LOCATION_ID_FIELD]: null },
44882
+ limit: 1
44883
+ },
44884
+ columns: [LOCATION_ID_FIELD]
44885
+ })).length === 0) return {
44886
+ present: false,
44887
+ rows: 0
44888
+ };
44889
+ try {
44890
+ return {
44891
+ present: true,
44892
+ rows: await this.deps.store.count.query({
44893
+ collection,
44894
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
44895
+ })
44896
+ };
44897
+ } catch (err) {
44898
+ 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: {
44899
+ collection,
44900
+ error: String(err)
44901
+ } });
44902
+ return {
44903
+ present: true,
44904
+ rows: null
44905
+ };
44906
+ }
44490
44907
  }
44491
44908
  start(input) {
44492
44909
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
@@ -47243,6 +47660,70 @@ var PackageDropDetector = class {
47243
47660
  }
47244
47661
  };
47245
47662
  //#endregion
47663
+ //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
47664
+ /**
47665
+ * How much of the PERSON box must sit inside a vehicle box to be counted.
47666
+ *
47667
+ * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
47668
+ * routinely pokes a little outside the vehicle box, and requiring total
47669
+ * containment would count almost none of them. 0.8 and not 0.5: half a person
47670
+ * box overlapping a car is what a pedestrian walking in front of one looks
47671
+ * like, and counting those would make the number mean "somebody was near a
47672
+ * car", which is not a defect.
47673
+ */
47674
+ var CONTAINMENT_THRESHOLD = .8;
47675
+ /**
47676
+ * The classes that count as a vehicle for this test.
47677
+ *
47678
+ * A local list rather than a shared taxonomy on purpose: it exists to describe
47679
+ * ONE measurement, and a shared enum would make a change here silently change
47680
+ * detection behaviour somewhere else.
47681
+ */
47682
+ var VEHICLE_CLASSES$1 = [
47683
+ "car",
47684
+ "truck",
47685
+ "bus",
47686
+ "motorcycle",
47687
+ "vehicle"
47688
+ ];
47689
+ /** Area of the intersection of two boxes. `0` when they do not overlap. */
47690
+ function intersectionArea(a, b) {
47691
+ const left = Math.max(a.x, b.x);
47692
+ const right = Math.min(a.x + a.w, b.x + b.w);
47693
+ const top = Math.max(a.y, b.y);
47694
+ const bottom = Math.min(a.y + a.h, b.y + b.h);
47695
+ if (right <= left || bottom <= top) return 0;
47696
+ return (right - left) * (bottom - top);
47697
+ }
47698
+ /**
47699
+ * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
47700
+ *
47701
+ * `0` for a degenerate person box: a zero-area box is inside everything and
47702
+ * inside nothing, and answering `1` would count every one of them.
47703
+ */
47704
+ function containedFraction(person, vehicle) {
47705
+ const area = person.w * person.h;
47706
+ if (area <= 0) return 0;
47707
+ return intersectionArea(person, vehicle) / area;
47708
+ }
47709
+ /**
47710
+ * Is this person box contained in any vehicle box in the same frame?
47711
+ *
47712
+ * Returns the fraction for the BEST-containing vehicle when it clears
47713
+ * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
47714
+ * a counter that only ever says "yes" cannot later be re-thresholded against
47715
+ * what was actually measured.
47716
+ */
47717
+ function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
47718
+ let best = 0;
47719
+ for (const neighbour of neighbours) {
47720
+ if (!VEHICLE_CLASSES$1.includes(neighbour.className)) continue;
47721
+ const fraction = containedFraction(person, neighbour.bbox);
47722
+ if (fraction > best) best = fraction;
47723
+ }
47724
+ return best >= threshold ? best : null;
47725
+ }
47726
+ //#endregion
47246
47727
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
47247
47728
  /**
47248
47729
  * stationary-evidence — how a parked object EARNS a registry entry.
@@ -49774,83 +50255,6 @@ function buildRecordedStillPorts(input) {
49774
50255
  })
49775
50256
  };
49776
50257
  }
49777
- function errorMessage(err) {
49778
- return err instanceof Error ? err.message : String(err);
49779
- }
49780
- async function reconcileGallerySamples(deps, options) {
49781
- let checked = 0;
49782
- let reclaimed = 0;
49783
- let keyless = 0;
49784
- let skippedGrace = 0;
49785
- let stoppedByCeiling = false;
49786
- const identities = await deps.listIdentities();
49787
- for (const identity of identities) {
49788
- const samples = await deps.listSamples(identity.id);
49789
- for (const sample of samples) {
49790
- if (sample.mediaKey === void 0) {
49791
- keyless += 1;
49792
- continue;
49793
- }
49794
- if (options.now - sample.addedAt < options.graceMs) {
49795
- skippedGrace += 1;
49796
- continue;
49797
- }
49798
- if (reclaimed >= options.maxReclaimPerRun) {
49799
- stoppedByCeiling = true;
49800
- deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
49801
- return {
49802
- checked,
49803
- reclaimed,
49804
- keyless,
49805
- skippedGrace,
49806
- stoppedByCeiling
49807
- };
49808
- }
49809
- let state;
49810
- try {
49811
- state = await deps.blobState(sample.mediaKey);
49812
- } catch (err) {
49813
- const reason = errorMessage(err);
49814
- deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
49815
- sampleId: sample.id,
49816
- identity: identity.name,
49817
- error: reason
49818
- } });
49819
- return {
49820
- checked,
49821
- reclaimed,
49822
- keyless,
49823
- skippedGrace,
49824
- stoppedByCeiling,
49825
- aborted: reason
49826
- };
49827
- }
49828
- checked += 1;
49829
- if (state === "exists") continue;
49830
- await deps.removeSample(identity.id, sample.id);
49831
- reclaimed += 1;
49832
- deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
49833
- sampleId: sample.id,
49834
- identity: identity.name,
49835
- mediaKey: sample.mediaKey,
49836
- state
49837
- } });
49838
- }
49839
- }
49840
- if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
49841
- checked,
49842
- reclaimed,
49843
- keyless,
49844
- skippedGrace
49845
- } });
49846
- return {
49847
- checked,
49848
- reclaimed,
49849
- keyless,
49850
- skippedGrace,
49851
- stoppedByCeiling
49852
- };
49853
- }
49854
50258
  //#endregion
49855
50259
  //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
49856
50260
  /** The scope id this leg reports and persists its cursor under. */
@@ -49967,6 +50371,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
49967
50371
  nextCursor: Math.max(0, offset)
49968
50372
  };
49969
50373
  }
50374
+ function errorMessage(err) {
50375
+ return err instanceof Error ? err.message : String(err);
50376
+ }
50377
+ async function reconcileGallerySamples(deps, options) {
50378
+ let checked = 0;
50379
+ let reclaimed = 0;
50380
+ let keyless = 0;
50381
+ let skippedGrace = 0;
50382
+ let stoppedByCeiling = false;
50383
+ const identities = await deps.listIdentities();
50384
+ for (const identity of identities) {
50385
+ const samples = await deps.listSamples(identity.id);
50386
+ for (const sample of samples) {
50387
+ if (sample.mediaKey === void 0) {
50388
+ keyless += 1;
50389
+ continue;
50390
+ }
50391
+ if (options.now - sample.addedAt < options.graceMs) {
50392
+ skippedGrace += 1;
50393
+ continue;
50394
+ }
50395
+ if (reclaimed >= options.maxReclaimPerRun) {
50396
+ stoppedByCeiling = true;
50397
+ deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
50398
+ return {
50399
+ checked,
50400
+ reclaimed,
50401
+ keyless,
50402
+ skippedGrace,
50403
+ stoppedByCeiling
50404
+ };
50405
+ }
50406
+ let state;
50407
+ try {
50408
+ state = await deps.blobState(sample.mediaKey);
50409
+ } catch (err) {
50410
+ const reason = errorMessage(err);
50411
+ deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
50412
+ sampleId: sample.id,
50413
+ identity: identity.name,
50414
+ error: reason
50415
+ } });
50416
+ return {
50417
+ checked,
50418
+ reclaimed,
50419
+ keyless,
50420
+ skippedGrace,
50421
+ stoppedByCeiling,
50422
+ aborted: reason
50423
+ };
50424
+ }
50425
+ checked += 1;
50426
+ if (state === "exists") continue;
50427
+ await deps.removeSample(identity.id, sample.id);
50428
+ reclaimed += 1;
50429
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50430
+ sampleId: sample.id,
50431
+ identity: identity.name,
50432
+ mediaKey: sample.mediaKey,
50433
+ state
50434
+ } });
50435
+ }
50436
+ }
50437
+ if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50438
+ checked,
50439
+ reclaimed,
50440
+ keyless,
50441
+ skippedGrace
50442
+ } });
50443
+ return {
50444
+ checked,
50445
+ reclaimed,
50446
+ keyless,
50447
+ skippedGrace,
50448
+ stoppedByCeiling
50449
+ };
50450
+ }
49970
50451
  /**
49971
50452
  * Where the completion marker lives.
49972
50453
  *
@@ -53029,14 +53510,14 @@ function isAfterCursor(row, cursor) {
53029
53510
  if (row.lastSeen < cursor.lastSeen) return true;
53030
53511
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
53031
53512
  }
53032
- var VEHICLE_CLASSES$1 = new Set([
53513
+ var VEHICLE_CLASSES = new Set([
53033
53514
  "vehicle",
53034
53515
  "car",
53035
53516
  "truck",
53036
53517
  "bus"
53037
53518
  ]);
53038
53519
  function motionlessMaxPxForClass(className) {
53039
- return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
53520
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
53040
53521
  }
53041
53522
  function netDisplacementPx(positions) {
53042
53523
  const first = positions[0];
@@ -56796,70 +57277,6 @@ function hasWrappedDetection(entries) {
56796
57277
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
56797
57278
  }
56798
57279
  //#endregion
56799
- //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
56800
- /**
56801
- * How much of the PERSON box must sit inside a vehicle box to be counted.
56802
- *
56803
- * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
56804
- * routinely pokes a little outside the vehicle box, and requiring total
56805
- * containment would count almost none of them. 0.8 and not 0.5: half a person
56806
- * box overlapping a car is what a pedestrian walking in front of one looks
56807
- * like, and counting those would make the number mean "somebody was near a
56808
- * car", which is not a defect.
56809
- */
56810
- var CONTAINMENT_THRESHOLD = .8;
56811
- /**
56812
- * The classes that count as a vehicle for this test.
56813
- *
56814
- * A local list rather than a shared taxonomy on purpose: it exists to describe
56815
- * ONE measurement, and a shared enum would make a change here silently change
56816
- * detection behaviour somewhere else.
56817
- */
56818
- var VEHICLE_CLASSES = [
56819
- "car",
56820
- "truck",
56821
- "bus",
56822
- "motorcycle",
56823
- "vehicle"
56824
- ];
56825
- /** Area of the intersection of two boxes. `0` when they do not overlap. */
56826
- function intersectionArea(a, b) {
56827
- const left = Math.max(a.x, b.x);
56828
- const right = Math.min(a.x + a.w, b.x + b.w);
56829
- const top = Math.max(a.y, b.y);
56830
- const bottom = Math.min(a.y + a.h, b.y + b.h);
56831
- if (right <= left || bottom <= top) return 0;
56832
- return (right - left) * (bottom - top);
56833
- }
56834
- /**
56835
- * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
56836
- *
56837
- * `0` for a degenerate person box: a zero-area box is inside everything and
56838
- * inside nothing, and answering `1` would count every one of them.
56839
- */
56840
- function containedFraction(person, vehicle) {
56841
- const area = person.w * person.h;
56842
- if (area <= 0) return 0;
56843
- return intersectionArea(person, vehicle) / area;
56844
- }
56845
- /**
56846
- * Is this person box contained in any vehicle box in the same frame?
56847
- *
56848
- * Returns the fraction for the BEST-containing vehicle when it clears
56849
- * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
56850
- * a counter that only ever says "yes" cannot later be re-thresholded against
56851
- * what was actually measured.
56852
- */
56853
- function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
56854
- let best = 0;
56855
- for (const neighbour of neighbours) {
56856
- if (!VEHICLE_CLASSES.includes(neighbour.className)) continue;
56857
- const fraction = containedFraction(person, neighbour.bbox);
56858
- if (fraction > best) best = fraction;
56859
- }
56860
- return best >= threshold ? best : null;
56861
- }
56862
- //#endregion
56863
57280
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
56864
57281
  function createCaptureCrop(deps) {
56865
57282
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -63301,6 +63718,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63301
63718
  * Set once the data-plane is registered; null until then (e.g. no
63302
63719
  * dataPlane facility in the current environment). */
63303
63720
  eventMediaBaseUrl = null;
63721
+ /**
63722
+ * The SAME path, derived rather than observed — a pure function of the addon
63723
+ * id, so it exists before `serve()` is attempted and survives its failure.
63724
+ *
63725
+ * `eventMediaBaseUrl` above answers "is the plane up?", which is the right
63726
+ * question for the OPTIONAL `mediaUrl` stamp on a slim event projection.
63727
+ * It is the wrong question for a media row's `url`, which is that row's only
63728
+ * address: a row that dropped its address on a boot race would be
63729
+ * indistinguishable from a row with no bytes, and this repo has already paid
63730
+ * for one read that quietly returned less than it was asked for.
63731
+ */
63732
+ eventMediaPathPrefix() {
63733
+ return `/addon/${this.ctx.id}/event-media`;
63734
+ }
63304
63735
  lastActiveTrackIds = /* @__PURE__ */ new Map();
63305
63736
  /** See `pipeline/suppressed-births.ts` — rejected births must not be
63306
63737
  * re-upserted, and must be forgotten when the tracker drops the id. */
@@ -63551,6 +63982,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63551
63982
  sensorEventStore: () => this.sensorEventStore,
63552
63983
  opsLog: () => this.eventsOpsLog,
63553
63984
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
63985
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
63554
63986
  logger: () => this.ctx.logger,
63555
63987
  zoneCatalogue: () => this.zoneCatalogue
63556
63988
  });
@@ -66093,6 +66525,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66093
66525
  logger: this.ctx.logger,
66094
66526
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
66095
66527
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66528
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66096
66529
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
66097
66530
  });
66098
66531
  this.faceGalleryProvider = faceGallery;
@@ -66107,6 +66540,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66107
66540
  this.plateRecognizer?.refreshGallery();
66108
66541
  },
66109
66542
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66543
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66110
66544
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
66111
66545
  });
66112
66546
  return [
@@ -69792,6 +70226,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
69792
70226
  async listTrackMedia(input) {
69793
70227
  return this.queryFacade.listTrackMedia(input);
69794
70228
  }
70229
+ /** What media an EVENT has, without any of it — the same manifest, for the
70230
+ * half of the detail modal's filmstrip that is event-owned. */
70231
+ async listEventMedia(input) {
70232
+ return this.queryFacade.listEventMedia(input);
70233
+ }
69795
70234
  /**
69796
70235
  * Search object events by text using CLIP cosine similarity.
69797
70236
  *
@@ -71512,31 +71951,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71512
71951
  return media;
71513
71952
  }
71514
71953
  }
71954
+ /**
71955
+ * Resolve an event/track id to ONE tile's bytes.
71956
+ *
71957
+ * Every ladder below ranks rows by `kind` alone, so this walks the INDEX
71958
+ * (`listInfoByOwner`, no blob opened) and opens only the winner. It used to
71959
+ * walk `listByOwner`, which reads and base64-encodes every blob an owner
71960
+ * holds before the first comparison is made: measured on the live hub
71961
+ * 2026-08-30, a track's six kinds are 3 597 219 bytes and the tile serves
71962
+ * `thumbnailSmall` — 31 155 of them. An Events page of forty tiles read
71963
+ * ~144 MB and allocated ~190 MB of base64 to deliver ~1.2 MB, all of it on
71964
+ * the single-threaded post-analysis runner.
71965
+ */
71515
71966
  async readEventThumbnail(id, preferKind) {
71516
- const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
71967
+ const listInfo = (ownerKind, ownerId) => this.mediaStore?.listInfoByOwner(ownerKind, ownerId) ?? Promise.resolve([]);
71968
+ const getTrackIdForEvent = (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null);
71969
+ const readBlob = async (key) => {
71970
+ const file = await (this.mediaStore?.getByKey(key) ?? Promise.resolve(null));
71971
+ return file === null ? null : {
71972
+ bytes: Buffer.from(file.base64, "base64"),
71973
+ key: file.key
71974
+ };
71975
+ };
71976
+ const eventFiles = await listInfo("event", id);
71517
71977
  if (preferKind !== void 0 && preferKind.length > 0) {
71518
71978
  const layers = await collectCropForcedMedia({
71519
- listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
71520
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
71979
+ listByOwner: listInfo,
71980
+ getTrackIdForEvent
71521
71981
  }, id);
71522
- const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
71523
- if (!chosen) return null;
71524
- return {
71525
- bytes: Buffer.from(chosen.base64, "base64"),
71526
- key: chosen.key
71527
- };
71982
+ const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
71983
+ 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);
71528
71984
  }
71529
- const chosen = await resolveDefaultEventMedia({
71985
+ const { candidates, pick } = await collectDefaultEventMedia({
71530
71986
  eventId: id,
71531
71987
  eventFiles,
71532
- listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
71533
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
71988
+ listTrackMedia: (trackId) => listInfo("track", trackId),
71989
+ getTrackIdForEvent
71534
71990
  });
71535
- if (!chosen) return null;
71536
- return {
71537
- bytes: Buffer.from(chosen.base64, "base64"),
71538
- key: chosen.key
71539
- };
71991
+ return await readWinningBlob(candidates, pick, readBlob);
71540
71992
  }
71541
71993
  globalSettingsSchema(cap) {
71542
71994
  return this.schema(buildGlobalSettingsSchema(cap));