@camstack/addon-post-analysis 1.2.151 → 1.2.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-DpQno5NK.js");
5
+ const require_dist = require("../dist-DeMgxNFC.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.
@@ -6295,25 +6295,46 @@ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
6295
6295
  /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
6296
6296
  var NODE_UPDATE_KINDS = new Set(["server-update-available"]);
6297
6297
  /**
6298
- * Kinds whose action is "go and look at why this camera stopped producing".
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.
6299
6308
  *
6300
- * The legacy `camera-offline` is here for the same reason it is still in the
6301
- * enum: outbox and history rows enqueued before the rename carry it, and an
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
6302
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.
6303
6316
  */
6304
- var DEVICE_DOWN_KINDS = new Set([
6317
+ var DEVICE_STREAM_KINDS = new Set([
6305
6318
  "device-offline",
6319
+ "device-online",
6306
6320
  "stream-offline",
6307
- "camera-offline"
6321
+ "stream-online",
6322
+ "camera-offline",
6323
+ "camera-online"
6308
6324
  ]);
6309
6325
  /**
6310
6326
  * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
6311
6327
  *
6312
- * A stream belongs to a camera and nothing else. The legacy `camera-offline`
6313
- * is one too — that name is precisely the claim it made, and it is why the
6314
- * kind was split into `device-offline` + a `deviceTypes` condition.
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.
6315
6331
  */
6316
- var CAMERA_BY_CONSTRUCTION_KINDS = new Set(["stream-offline", "camera-offline"]);
6332
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
6333
+ "stream-offline",
6334
+ "stream-online",
6335
+ "camera-offline",
6336
+ "camera-online"
6337
+ ]);
6317
6338
  function withParam(path, key, value) {
6318
6339
  if (value === void 0 || value.length === 0) return path;
6319
6340
  return `${path}?${key}=${encodeURIComponent(value)}`;
@@ -6328,7 +6349,7 @@ function withParam(path, key, value) {
6328
6349
  function systemEventTapPath(subject) {
6329
6350
  if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
6330
6351
  if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
6331
- if (DEVICE_DOWN_KINDS.has(subject.kind)) {
6352
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
6332
6353
  const deviceId = subject.deviceId;
6333
6354
  if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
6334
6355
  const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
@@ -10730,16 +10751,26 @@ function incomingFromUpdateAvailable(facts, data) {
10730
10751
  ...nodeIds.length > 0 ? { nodeIds } : {}
10731
10752
  });
10732
10753
  }
10733
- /** 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
+ */
10734
10765
  function parseAddonUpdateComplete(data) {
10735
10766
  const packageName = stringField(data, "packageName");
10736
10767
  const fromVersion = stringField(data, "fromVersion");
10737
10768
  const toVersion = stringField(data, "toVersion");
10738
- if (packageName === void 0 || fromVersion === void 0 || toVersion === void 0) return null;
10769
+ if (packageName === void 0 || toVersion === void 0) return null;
10739
10770
  const nodeId = stringField(data, "agent") ?? stringField(data, "nodeId");
10740
10771
  return {
10741
10772
  packageName,
10742
- fromVersion,
10773
+ ...fromVersion !== void 0 ? { fromVersion } : {},
10743
10774
  toVersion,
10744
10775
  ...nodeId !== void 0 ? { nodeId } : {}
10745
10776
  };
@@ -10749,7 +10780,10 @@ function incomingFromAddonUpdates(facts, updates) {
10749
10780
  const packageNames = unique(updates.map((update) => update.packageName));
10750
10781
  const nodeIds = unique(updates.map((update) => update.nodeId).filter((id) => id !== void 0));
10751
10782
  const signature = updates.map((update) => `${update.nodeId ?? ""}:${update.packageName}@${update.toVersion}`).toSorted().join(",");
10752
- 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");
10753
10787
  const head = updates[0];
10754
10788
  return systemSubject({
10755
10789
  facts,
@@ -23586,17 +23620,17 @@ function planRuleTest(input) {
23586
23620
  deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
23587
23621
  nodeId: systemScope.nodeId ?? "hub",
23588
23622
  packageName: systemScope.packageName ?? "@camstack/server",
23589
- currentVersion: "0.0.0",
23590
- latestVersion: "0.0.0",
23623
+ currentVersion: "1.0.0",
23624
+ latestVersion: "1.0.1",
23591
23625
  profile: "low",
23592
23626
  seconds: "30",
23593
23627
  devices: "cuda:0",
23594
23628
  minutes: "15",
23595
23629
  addons: TEST_CRASHED_RUNNER,
23596
- list: "@camstack/addon-pipeline 1.0.0 (current 0.0.0)",
23630
+ list: "@camstack/addon-pipeline 1.0.1 (current 1.0.0)",
23597
23631
  nodes: "hub",
23598
- fromVersion: "0.0.0",
23599
- toVersion: "0.0.1",
23632
+ fromVersion: "1.0.0",
23633
+ toVersion: "1.0.1",
23600
23634
  title: "Export",
23601
23635
  count: "1"
23602
23636
  },
@@ -25660,6 +25694,67 @@ var MEDIA_INDEXES = [
25660
25694
  {
25661
25695
  name: "idx_media_location",
25662
25696
  columns: ["locationId"]
25697
+ }),
25698
+ (
25699
+ /**
25700
+ * The events-management footprint — `GROUP BY deviceId` and
25701
+ * `SUM(sizeBytes) WHERE deviceId = ?`. See {@link MediaStore.footprintByDevice}.
25702
+ *
25703
+ * **Why not `idx_media_device_ts`.** That index already leads with
25704
+ * `deviceId`, so it can find the range — but it does not carry `sizeBytes`,
25705
+ * so the sum has to fetch the table row behind every index entry it visits.
25706
+ * Over the whole collection that is one scattered read of the entire ~1.25 M
25707
+ * row table (`path` alone is ~80 characters per row) on the volume the
25708
+ * recorder is writing to. With `sizeBytes` in the index the planner never
25709
+ * touches the table: `SCAN … USING COVERING INDEX idx_media_device_size`.
25710
+ *
25711
+ * It has to be a NEW index rather than a third column on the existing one.
25712
+ * `declareCollection` emits `CREATE INDEX IF NOT EXISTS` keyed on the index
25713
+ * NAME, so widening `idx_media_device_ts` in place would be a silent no-op on
25714
+ * every hub that already has it — the declaration would read as done and the
25715
+ * query would still hit the table.
25716
+ *
25717
+ * **What it costs.** ~19 bytes of index per row (two integers plus the
25718
+ * rowid), so ~24 MB at 1.25 M rows, plus one more b-tree maintained by every
25719
+ * insert into a table taking ~92 k rows/day. At ~1 insert/s that maintenance
25720
+ * is noise beside the JPEG write the same call performs. The cost that is not
25721
+ * noise is the one-off: the first `declareCollection` after this ships builds
25722
+ * the index over every existing row. Do not deploy it beside a running drain
25723
+ * or seal — the same caveat `idx_media_location` carries, for the same
25724
+ * reason.
25725
+ */
25726
+ {
25727
+ name: "idx_media_device_size",
25728
+ columns: ["deviceId", "sizeBytes"]
25729
+ }),
25730
+ (
25731
+ /**
25732
+ * The per-KIND footprint — `COUNT(*)`/`SUM(sizeBytes) WHERE kind = ?`, and
25733
+ * the same scoped to one camera. See {@link MediaStore.footprintByKind}.
25734
+ *
25735
+ * `kind` leads no existing index, so without this each of the thirteen
25736
+ * per-kind questions is a full table scan and the breakdown costs thirteen of
25737
+ * them. With it, each kind is a contiguous covering range and the thirteen
25738
+ * together scan the index once — the cost of the whole breakdown is one pass
25739
+ * over ~36 MB of index instead of thirteen passes over the table.
25740
+ *
25741
+ * `deviceId` sits between `kind` and `sizeBytes` so the per-camera variant
25742
+ * (`WHERE kind = ? AND deviceId = ?`) stays covering too, for ~3 bytes a row
25743
+ * more than `(kind, sizeBytes)` would cost. Without it that query finds its
25744
+ * range from the index and then reads the table for every row in it.
25745
+ *
25746
+ * ~29 bytes of index per row, so ~36 MB at 1.25 M rows, and one more b-tree
25747
+ * on every insert. Same one-off caveat as the two indexes above: the first
25748
+ * declare builds it over every existing row, so do not ship it beside a
25749
+ * running drain or seal.
25750
+ */
25751
+ {
25752
+ name: "idx_media_kind_device_size",
25753
+ columns: [
25754
+ "kind",
25755
+ "deviceId",
25756
+ "sizeBytes"
25757
+ ]
25663
25758
  })
25664
25759
  ];
25665
25760
  /** The storage location of a media row/record: its stamped `locationId`, or
@@ -25746,7 +25841,7 @@ var MediaStore = class {
25746
25841
  });
25747
25842
  }
25748
25843
  /** Persist a media blob and register its metadata row. Returns the
25749
- * collection key (MediaFile.key) that callers store as reference. */
25844
+ * collection key (MediaFileRef.key) that callers store as reference. */
25750
25845
  async put(params) {
25751
25846
  if (this.writeGate) return this.writeGate.run(() => this.putUnchecked(params));
25752
25847
  return this.putUnchecked(params);
@@ -26068,8 +26163,20 @@ var MediaStore = class {
26068
26163
  * its device at write time, so scoping the query by it makes a media read
26069
26164
  * for a device the caller cannot name return nothing — the DB itself
26070
26165
  * enforces the boundary, no post-hoc ownership check to forget.
26166
+ *
26167
+ * `kinds` is the WORK filter, and it belongs here rather than on the answer.
26168
+ * Every row this returns has had its blob read off disk and base64-encoded,
26169
+ * so a caller that filters afterwards has already paid for every kind it is
26170
+ * about to discard: measured on the live hub, one track's six kinds are
26171
+ * 3 597 219 bytes and `thumbnailSmall` is 31 155 of them, and asking for the
26172
+ * small one alone cost exactly as much as asking for all six. `kind` is a
26173
+ * real column (see {@link MEDIA_COLUMNS}), so the narrowing happens in the
26174
+ * query and the blobs of the other kinds are never opened.
26175
+ *
26176
+ * Absent (or empty) means every kind — the back-compat default, and the
26177
+ * honest answer for a caller that has not decided what it needs.
26071
26178
  */
26072
- async listByOwner(ownerKind, ownerId, deviceId) {
26179
+ async listByOwner(ownerKind, ownerId, deviceId, kinds) {
26073
26180
  const rows = await this.store.query.query({
26074
26181
  collection: MEDIA_COLLECTION,
26075
26182
  filter: {
@@ -26081,6 +26188,7 @@ var MediaStore = class {
26081
26188
  ownerId,
26082
26189
  deviceId
26083
26190
  },
26191
+ ...kinds !== void 0 && kinds.length > 0 ? { whereIn: { kind: [...kinds] } } : {},
26084
26192
  orderBy: {
26085
26193
  field: "timestamp",
26086
26194
  direction: "asc"
@@ -26301,47 +26409,136 @@ var MediaStore = class {
26301
26409
  };
26302
26410
  }
26303
26411
  /**
26304
- * On-demand footprint aggregation for the events-management UI: sum
26305
- * `sizeBytes` and count rows per device across the whole media collection.
26306
- * Pages by `id` (offset-based) so a large collection never loads at once.
26307
- * Metadata rows are small (no blob) — the image bytes live on the storage
26308
- * provider and are counted via the persisted `sizeBytes` column.
26412
+ * On-demand footprint aggregation for the events-management UI: rows and
26413
+ * summed `sizeBytes` per camera, across the whole media collection.
26414
+ *
26415
+ * ## Why this is a histogram plus one aggregate per camera
26416
+ *
26417
+ * It used to page the ENTIRE collection — `ORDER BY id LIMIT 1000 OFFSET n`,
26418
+ * no predicate — and add `sizeBytes` up in JS. SQLite honours an OFFSET by
26419
+ * stepping over `n` entries, so the walk is quadratic in the table: at the
26420
+ * live hub's 1.25 M rows that is ~1 250 round trips and ~780 M index steps,
26421
+ * with every row serialised across an RPC boundary and rebuilt in V8 to be
26422
+ * added to a number. It timed out at 60 s on every call it ever made, and it
26423
+ * is the only thing that can size the surplus-media deletion it gates.
26424
+ *
26425
+ * The answer is at most a few dozen `(deviceId, rows, bytes)` triples.
26426
+ *
26427
+ * - The DEVICE SET comes from a histogram on `deviceId` with `bucketSize: 1`
26428
+ * — `GROUP BY deviceId`, served from `idx_media_device_size` as a covering
26429
+ * scan, the same trick `TrackStore.listDeviceIds` already uses (D56).
26430
+ * `bucket` IS the deviceId: the column is INTEGER and the bucket
26431
+ * expression is `CAST((deviceId - 0) / 1 AS INTEGER)`.
26432
+ * - The NUMBERS come from one `aggregate` per camera — `COUNT(*)` and
26433
+ * `SUM(sizeBytes)` in a single statement over an indexed `deviceId` range.
26434
+ *
26435
+ * Both figures for a camera come from the SAME statement deliberately. Taking
26436
+ * `rows` from the histogram and `bytes` from the aggregate would let a write
26437
+ * landing between the two produce a footprint whose row count and byte count
26438
+ * describe different populations — a disagreement an operator sizing a
26439
+ * deletion has no way to detect.
26440
+ *
26441
+ * A read that fails THROWS. It must never degrade to a zero: "this camera
26442
+ * holds nothing" is precisely the claim a failed measurement must not be
26443
+ * allowed to make to something about to delete (D295, D312).
26309
26444
  */
26310
26445
  async footprintByDevice() {
26311
- const PAGE = 1e3;
26446
+ const buckets = await this.store.histogram.query({
26447
+ collection: MEDIA_COLLECTION,
26448
+ field: "deviceId",
26449
+ bucketSize: 1,
26450
+ origin: 0
26451
+ });
26312
26452
  const out = /* @__PURE__ */ new Map();
26313
- let offset = 0;
26314
- for (;;) {
26315
- const rows = await this.store.query.query({
26453
+ for (const bucket of buckets) {
26454
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
26455
+ const deviceId = bucket.bucket;
26456
+ const agg = await this.store.aggregate.query({
26316
26457
  collection: MEDIA_COLLECTION,
26317
- filter: {
26318
- orderBy: {
26319
- field: "id",
26320
- direction: "asc"
26321
- },
26322
- limit: PAGE,
26323
- offset
26324
- }
26458
+ fields: [{
26459
+ as: "bytes",
26460
+ field: "sizeBytes",
26461
+ op: "sum"
26462
+ }],
26463
+ filter: { where: { deviceId } }
26464
+ });
26465
+ if (agg.count === 0) continue;
26466
+ const bytes = agg.values["bytes"];
26467
+ out.set(deviceId, {
26468
+ bytes: bytes ?? 0,
26469
+ rows: agg.count
26325
26470
  });
26326
- if (rows.length === 0) break;
26327
- for (const row of rows) {
26328
- const data = row.data;
26329
- const deviceId = Number(data["deviceId"]);
26330
- const sizeBytes = Number(data["sizeBytes"]);
26331
- if (!Number.isFinite(deviceId)) continue;
26332
- const acc = out.get(deviceId) ?? {
26333
- bytes: 0,
26334
- rows: 0
26335
- };
26336
- acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
26337
- acc.rows += 1;
26338
- out.set(deviceId, acc);
26339
- }
26340
- if (rows.length < PAGE) break;
26341
- offset += PAGE;
26342
26471
  }
26343
26472
  return out;
26344
26473
  }
26474
+ /**
26475
+ * The media footprint broken down by KIND — fleet-wide, or for one camera.
26476
+ *
26477
+ * `footprintByDevice` answers "how much is where"; this answers "how much is
26478
+ * WHAT", which is the axis a deletion decision turns on. The deletable set
26479
+ * (the periodic `snapshot` filmstrip, the surplus per-edge motion stills) and
26480
+ * the keep set (`firstFrame`, rolling `lastFrame`, `thumbnail` /
26481
+ * `thumbnailSmall`, `keyFrame` / `keyFrameSmall`, the face and plate buffers,
26482
+ * gallery media, the CLIP `crop`) are told apart by `kind` and by nothing
26483
+ * else. A byte total cannot size a deletion; this can.
26484
+ *
26485
+ * One `aggregate` per kind, each an indexed covering range on
26486
+ * `idx_media_kind_device_size`, plus ONE unfiltered aggregate for the totals.
26487
+ *
26488
+ * That last statement is the point of the design and not a spare round trip.
26489
+ * `MediaFileKindEnum.options` is the closed set the writers use, but the
26490
+ * TABLE is not closed: a row written by a retired path, or by a version that
26491
+ * knew a kind this one does not, matches no per-kind query. Adding the kinds
26492
+ * up to produce the total would make such a row invisible and the denominator
26493
+ * quietly too small — the failure mode that matters most here, because the
26494
+ * caller is about to delete against it. So the total is measured
26495
+ * independently and the difference is REPORTED as `unaccounted*` rather than
26496
+ * being absorbed.
26497
+ */
26498
+ async footprintByKind(deviceId) {
26499
+ const scope = deviceId === void 0 ? void 0 : { where: { deviceId } };
26500
+ const fields = [{
26501
+ as: "bytes",
26502
+ field: "sizeBytes",
26503
+ op: "sum"
26504
+ }];
26505
+ const totals = await this.store.aggregate.query({
26506
+ collection: MEDIA_COLLECTION,
26507
+ fields: [...fields],
26508
+ ...scope === void 0 ? {} : { filter: scope }
26509
+ });
26510
+ const kinds = [];
26511
+ let countedRows = 0;
26512
+ let countedBytes = 0;
26513
+ for (const kind of require_dist.MediaFileKindEnum.options) {
26514
+ const agg = await this.store.aggregate.query({
26515
+ collection: MEDIA_COLLECTION,
26516
+ fields: [...fields],
26517
+ filter: { where: {
26518
+ kind,
26519
+ ...deviceId === void 0 ? {} : { deviceId }
26520
+ } }
26521
+ });
26522
+ if (agg.count === 0) continue;
26523
+ const bytes = agg.values["bytes"] ?? 0;
26524
+ countedRows += agg.count;
26525
+ countedBytes += bytes;
26526
+ kinds.push({
26527
+ kind,
26528
+ rows: agg.count,
26529
+ bytes
26530
+ });
26531
+ }
26532
+ kinds.sort((a, b) => b.bytes - a.bytes);
26533
+ const totalBytes = totals.values["bytes"] ?? 0;
26534
+ return {
26535
+ totalRows: totals.count,
26536
+ totalBytes,
26537
+ kinds,
26538
+ unaccountedRows: totals.count - countedRows,
26539
+ unaccountedBytes: totalBytes - countedBytes
26540
+ };
26541
+ }
26345
26542
  /** Move a single media entry from one owner to another.
26346
26543
  * The new copy is written before the old one is removed.
26347
26544
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -27997,6 +28194,63 @@ var embeddingActions = require_dist.defineCustomActions({
27997
28194
  "embedding.reembedFacesStatus": require_dist.customAction(NoInputSchema$1, ReembedFacesStatusSchema)
27998
28195
  });
27999
28196
  //#endregion
28197
+ //#region src/pipeline-analytics/media-file-url.ts
28198
+ /**
28199
+ * The plane path for one stored media key.
28200
+ *
28201
+ * `prefix` is `/addon/<addonId>/event-media` — a pure function of the addon
28202
+ * id, so it exists whether or not `dataPlane.serve` succeeded. A row that
28203
+ * named no address at all would be the silent drop this repo keeps paying for:
28204
+ * a client cannot tell "no bytes" from "no plane", and renders neither.
28205
+ */
28206
+ function mediaFileUrl(prefix, key) {
28207
+ return `${prefix}/${encodeURIComponent(key)}`;
28208
+ }
28209
+ /**
28210
+ * A stored row as the wire sees it: the ref, the address, and — transitionally
28211
+ * — the bytes.
28212
+ *
28213
+ * `base64` is copied through ONLY because the deployed viewer's track-detail
28214
+ * hero still requires the field to parse a row at all. See the `MediaFile`
28215
+ * docblock in `pipeline-analytics.cap.ts`; deleting the field is a one-line
28216
+ * change here and one in the schema.
28217
+ *
28218
+ * `provenance` is deliberately NOT spread: it is store-internal, and the
28219
+ * previous `{ ...row, kind }` shape published it by accident.
28220
+ */
28221
+ function toWireMediaFile(row, prefix) {
28222
+ return {
28223
+ key: row.key,
28224
+ kind: row.kind,
28225
+ sizeBytes: row.sizeBytes,
28226
+ timestamp: row.timestamp,
28227
+ url: mediaFileUrl(prefix, row.key),
28228
+ base64: row.base64
28229
+ };
28230
+ }
28231
+ /** The same row without its bytes — the manifest shape. Carries the SAME
28232
+ * `url` as the byte read, so a client never builds the plane path itself. */
28233
+ function toWireMediaFileInfo(row, prefix) {
28234
+ return {
28235
+ key: row.key,
28236
+ kind: row.kind,
28237
+ sizeBytes: row.sizeBytes,
28238
+ timestamp: row.timestamp,
28239
+ url: mediaFileUrl(prefix, row.key)
28240
+ };
28241
+ }
28242
+ //#endregion
28243
+ //#region src/pipeline-analytics/gallery-media-row.ts
28244
+ function toGalleryMediaRow(row, prefix) {
28245
+ return {
28246
+ key: row.key,
28247
+ kind: row.kind,
28248
+ url: mediaFileUrl(prefix, row.key),
28249
+ sizeBytes: row.sizeBytes,
28250
+ timestamp: row.timestamp
28251
+ };
28252
+ }
28253
+ //#endregion
28000
28254
  //#region src/pipeline-analytics/include-crops-default.ts
28001
28255
  function resolveIncludeCrops(input) {
28002
28256
  return input?.includeCrops === true;
@@ -29656,6 +29910,7 @@ var FaceGalleryProvider = class {
29656
29910
  logger;
29657
29911
  refreshGallery;
29658
29912
  eventMediaBaseUrl;
29913
+ eventMediaPathPrefix;
29659
29914
  emitFaceGalleryChanged;
29660
29915
  constructor(deps) {
29661
29916
  this.identityStore = deps.identityStore;
@@ -29666,6 +29921,7 @@ var FaceGalleryProvider = class {
29666
29921
  this.logger = deps.logger;
29667
29922
  this.refreshGallery = deps.refreshGallery;
29668
29923
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
29924
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
29669
29925
  this.emitFaceGalleryChanged = deps.emitFaceGalleryChanged;
29670
29926
  }
29671
29927
  /** Best-effort emit — never allowed to fail the mutation it follows. */
@@ -29853,13 +30109,7 @@ var FaceGalleryProvider = class {
29853
30109
  * the enrolled identity copies — those live under identity owner).
29854
30110
  */
29855
30111
  async getFaceMedia(input) {
29856
- return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => ({
29857
- key: m.key,
29858
- kind: m.kind,
29859
- base64: m.base64,
29860
- sizeBytes: m.sizeBytes,
29861
- timestamp: m.timestamp
29862
- }));
30112
+ return (await this.mediaStore.listByOwner("face", input.faceId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
29863
30113
  }
29864
30114
  /**
29865
30115
  * Assign a buffered face to an identity:
@@ -32572,50 +32822,136 @@ var FrameProcessor = class {
32572
32822
  };
32573
32823
  }
32574
32824
  };
32575
- //#endregion
32576
- //#region src/pipeline-analytics/replay/frame-processor-pass.ts
32825
+ /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32826
+ * evicts the oldest record. */
32827
+ var PendingPreviewUpgrades = class {
32828
+ entries = /* @__PURE__ */ new Map();
32829
+ ttlMs;
32830
+ maxEntries;
32831
+ now;
32832
+ constructor(options = {}) {
32833
+ this.ttlMs = options.ttlMs ?? 9e5;
32834
+ this.maxEntries = Math.max(1, options.maxEntries ?? 512);
32835
+ this.now = options.now ?? (() => Date.now());
32836
+ }
32837
+ /** Record (or replace) a closed track's pending upgrade. Prunes expired
32838
+ * entries first, then evicts the oldest while over the cap. */
32839
+ record(entry) {
32840
+ this.prune();
32841
+ this.entries.delete(entry.trackId);
32842
+ this.entries.set(entry.trackId, entry);
32843
+ while (this.entries.size > this.maxEntries) {
32844
+ const oldest = this.entries.keys().next();
32845
+ if (oldest.done === true) break;
32846
+ this.entries.delete(oldest.value);
32847
+ }
32848
+ }
32849
+ /** Claim a track's pending upgrade — removes it, so a second landing (or a
32850
+ * concurrent racer) finds nothing and the tile is rewritten at most once. */
32851
+ take(trackId) {
32852
+ this.prune();
32853
+ const entry = this.entries.get(trackId);
32854
+ if (entry === void 0) return void 0;
32855
+ this.entries.delete(trackId);
32856
+ return entry;
32857
+ }
32858
+ get size() {
32859
+ return this.entries.size;
32860
+ }
32861
+ prune() {
32862
+ const cutoff = this.now() - this.ttlMs;
32863
+ for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
32864
+ }
32865
+ };
32577
32866
  /**
32578
- * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
32579
- * return the tracks it produced.
32580
- *
32581
- * No `TrackStore`, no bus, no media — a track here is a summary built
32582
- * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
32867
+ * Redeem a closed track's pending preview off a keyFrame that landed late.
32868
+ * Every non-`upgraded` outcome that had a pending entry logs itself — a
32869
+ * branch that leaves the stand-in in place must say so.
32583
32870
  */
32584
- function runFrameProcessorPass(input) {
32585
- const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
32586
- if (input.zones !== void 0) processor.setZones(input.zones);
32587
- if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
32588
- if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
32589
- const accum = /* @__PURE__ */ new Map();
32590
- for (const { timestamp, frame } of input.frames) {
32591
- const result = processor.process({
32592
- timestamp,
32593
- frame
32871
+ async function runPreviewUpgrade(deps, input) {
32872
+ const { deviceId, trackId } = input;
32873
+ const pending = deps.takePending(trackId);
32874
+ if (pending === void 0) return "no-pending";
32875
+ const held = await deps.heldThumbnailProvenance(trackId);
32876
+ if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
32877
+ deps.logger.info("late keyFrame: preview already native — no upgrade", {
32878
+ tags: { deviceId },
32879
+ meta: { trackId }
32594
32880
  });
32595
- for (const td of result.tracked) {
32596
- const existing = accum.get(td.trackId);
32597
- const matched = td.matchedThisFrame ? 1 : 0;
32598
- if (existing === void 0) accum.set(td.trackId, {
32599
- className: td.className,
32600
- firstSeenMs: timestamp,
32601
- lastSeenMs: timestamp,
32602
- bbox: { ...td.bbox },
32603
- framesMatched: matched
32604
- });
32605
- else {
32606
- existing.className = td.className;
32607
- existing.lastSeenMs = timestamp;
32608
- existing.framesMatched += matched;
32881
+ return "held-native";
32882
+ }
32883
+ if (pending.bestSeen === void 0) {
32884
+ deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
32885
+ tags: { deviceId },
32886
+ meta: {
32887
+ trackId,
32888
+ heldProvenance: held
32609
32889
  }
32610
- }
32890
+ });
32891
+ return "no-bbox";
32611
32892
  }
32612
- return { tracks: [...accum.values()].map((t) => ({
32613
- className: t.className,
32614
- firstSeenMs: t.firstSeenMs,
32615
- lastSeenMs: t.lastSeenMs,
32616
- bbox: t.bbox,
32617
- framesMatched: t.framesMatched
32618
- })) };
32893
+ if (pending.bestSeen.timestamp === void 0) {
32894
+ deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32895
+ tags: { deviceId },
32896
+ meta: {
32897
+ trackId,
32898
+ keyFrameKey: input.keyFrameKey,
32899
+ heldProvenance: held
32900
+ }
32901
+ });
32902
+ return "no-bbox";
32903
+ }
32904
+ const derived = await deps.derive({
32905
+ deviceId,
32906
+ mediaKey: input.keyFrameKey,
32907
+ bbox: {
32908
+ x: pending.bestSeen.x,
32909
+ y: pending.bestSeen.y,
32910
+ w: pending.bestSeen.w,
32911
+ h: pending.bestSeen.h
32912
+ },
32913
+ frameWidth: pending.bestSeen.frameWidth,
32914
+ frameHeight: pending.bestSeen.frameHeight,
32915
+ timestamp: pending.bestSeen.timestamp
32916
+ });
32917
+ if (derived === null) {
32918
+ deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
32919
+ tags: { deviceId },
32920
+ meta: {
32921
+ trackId,
32922
+ keyFrameKey: input.keyFrameKey,
32923
+ heldProvenance: held
32924
+ }
32925
+ });
32926
+ return "derive-miss";
32927
+ }
32928
+ await deps.put({
32929
+ deviceId,
32930
+ trackId,
32931
+ kind: "thumbnail",
32932
+ timestamp: input.timestamp,
32933
+ data: derived.thumbnail,
32934
+ provenance: "native"
32935
+ });
32936
+ await deps.put({
32937
+ deviceId,
32938
+ trackId,
32939
+ kind: "thumbnailSmall",
32940
+ timestamp: input.timestamp,
32941
+ data: derived.thumbnailSmall,
32942
+ provenance: "native"
32943
+ });
32944
+ await deps.clearPreviewMiss(deviceId, trackId);
32945
+ deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
32946
+ tags: { deviceId },
32947
+ meta: {
32948
+ trackId,
32949
+ keyFrameKey: input.keyFrameKey,
32950
+ replaced: held ?? "none",
32951
+ skewMs: derived.skewMs
32952
+ }
32953
+ });
32954
+ return "upgraded";
32619
32955
  }
32620
32956
  function isPlausibleThumbnailBox(bbox, frameWidth, frameHeight, score) {
32621
32957
  if (frameWidth <= 0 || frameHeight <= 0) return true;
@@ -32775,137 +33111,6 @@ function promoteSemanticBestFrame(deps, input) {
32775
33111
  captured: true
32776
33112
  };
32777
33113
  }
32778
- /** Bounded trackId → pending-upgrade map. Insertion-ordered, so the cap
32779
- * evicts the oldest record. */
32780
- var PendingPreviewUpgrades = class {
32781
- entries = /* @__PURE__ */ new Map();
32782
- ttlMs;
32783
- maxEntries;
32784
- now;
32785
- constructor(options = {}) {
32786
- this.ttlMs = options.ttlMs ?? 9e5;
32787
- this.maxEntries = Math.max(1, options.maxEntries ?? 512);
32788
- this.now = options.now ?? (() => Date.now());
32789
- }
32790
- /** Record (or replace) a closed track's pending upgrade. Prunes expired
32791
- * entries first, then evicts the oldest while over the cap. */
32792
- record(entry) {
32793
- this.prune();
32794
- this.entries.delete(entry.trackId);
32795
- this.entries.set(entry.trackId, entry);
32796
- while (this.entries.size > this.maxEntries) {
32797
- const oldest = this.entries.keys().next();
32798
- if (oldest.done === true) break;
32799
- this.entries.delete(oldest.value);
32800
- }
32801
- }
32802
- /** Claim a track's pending upgrade — removes it, so a second landing (or a
32803
- * concurrent racer) finds nothing and the tile is rewritten at most once. */
32804
- take(trackId) {
32805
- this.prune();
32806
- const entry = this.entries.get(trackId);
32807
- if (entry === void 0) return void 0;
32808
- this.entries.delete(trackId);
32809
- return entry;
32810
- }
32811
- get size() {
32812
- return this.entries.size;
32813
- }
32814
- prune() {
32815
- const cutoff = this.now() - this.ttlMs;
32816
- for (const [trackId, entry] of this.entries) if (entry.recordedAtMs < cutoff) this.entries.delete(trackId);
32817
- }
32818
- };
32819
- /**
32820
- * Redeem a closed track's pending preview off a keyFrame that landed late.
32821
- * Every non-`upgraded` outcome that had a pending entry logs itself — a
32822
- * branch that leaves the stand-in in place must say so.
32823
- */
32824
- async function runPreviewUpgrade(deps, input) {
32825
- const { deviceId, trackId } = input;
32826
- const pending = deps.takePending(trackId);
32827
- if (pending === void 0) return "no-pending";
32828
- const held = await deps.heldThumbnailProvenance(trackId);
32829
- if (held !== null && mediaProvenanceRank(held) >= mediaProvenanceRank("native")) {
32830
- deps.logger.info("late keyFrame: preview already native — no upgrade", {
32831
- tags: { deviceId },
32832
- meta: { trackId }
32833
- });
32834
- return "held-native";
32835
- }
32836
- if (pending.bestSeen === void 0) {
32837
- deps.logger.info("late keyFrame landed but no subject bbox was ever seen — tile kept as-is", {
32838
- tags: { deviceId },
32839
- meta: {
32840
- trackId,
32841
- heldProvenance: held
32842
- }
32843
- });
32844
- return "no-bbox";
32845
- }
32846
- if (pending.bestSeen.timestamp === void 0) {
32847
- deps.logger.info("late keyFrame: pending entry predates the motion guard — tile kept as-is", {
32848
- tags: { deviceId },
32849
- meta: {
32850
- trackId,
32851
- keyFrameKey: input.keyFrameKey,
32852
- heldProvenance: held
32853
- }
32854
- });
32855
- return "no-bbox";
32856
- }
32857
- const derived = await deps.derive({
32858
- deviceId,
32859
- mediaKey: input.keyFrameKey,
32860
- bbox: {
32861
- x: pending.bestSeen.x,
32862
- y: pending.bestSeen.y,
32863
- w: pending.bestSeen.w,
32864
- h: pending.bestSeen.h
32865
- },
32866
- frameWidth: pending.bestSeen.frameWidth,
32867
- frameHeight: pending.bestSeen.frameHeight,
32868
- timestamp: pending.bestSeen.timestamp
32869
- });
32870
- if (derived === null) {
32871
- deps.logger.info("late keyFrame preview upgrade failed (derive miss) — tile kept as-is", {
32872
- tags: { deviceId },
32873
- meta: {
32874
- trackId,
32875
- keyFrameKey: input.keyFrameKey,
32876
- heldProvenance: held
32877
- }
32878
- });
32879
- return "derive-miss";
32880
- }
32881
- await deps.put({
32882
- deviceId,
32883
- trackId,
32884
- kind: "thumbnail",
32885
- timestamp: input.timestamp,
32886
- data: derived.thumbnail,
32887
- provenance: "native"
32888
- });
32889
- await deps.put({
32890
- deviceId,
32891
- trackId,
32892
- kind: "thumbnailSmall",
32893
- timestamp: input.timestamp,
32894
- data: derived.thumbnailSmall,
32895
- provenance: "native"
32896
- });
32897
- await deps.clearPreviewMiss(deviceId, trackId);
32898
- deps.logger.info("best-shot upgraded from late keyFrame (stand-in replaced)", {
32899
- tags: { deviceId },
32900
- meta: {
32901
- trackId,
32902
- keyFrameKey: input.keyFrameKey,
32903
- replaced: held ?? "none",
32904
- skewMs: derived.skewMs
32905
- }
32906
- });
32907
- return "upgraded";
32908
- }
32909
33114
  //#endregion
32910
33115
  //#region src/pipeline-analytics/store/zone-geometry.ts
32911
33116
  /**
@@ -34644,6 +34849,7 @@ var PlateGalleryProvider = class {
34644
34849
  refreshGallery;
34645
34850
  emitPlateGalleryChanged;
34646
34851
  eventMediaBaseUrl;
34852
+ eventMediaPathPrefix;
34647
34853
  constructor(deps) {
34648
34854
  this.plateStore = deps.plateStore;
34649
34855
  this.vehicleStore = deps.vehicleStore;
@@ -34654,6 +34860,7 @@ var PlateGalleryProvider = class {
34654
34860
  this.refreshGallery = deps.refreshGallery;
34655
34861
  this.emitPlateGalleryChanged = deps.emitPlateGalleryChanged;
34656
34862
  this.eventMediaBaseUrl = deps.eventMediaBaseUrl;
34863
+ this.eventMediaPathPrefix = deps.eventMediaPathPrefix;
34657
34864
  }
34658
34865
  /** Best-effort emit — never allowed to fail the mutation it follows. */
34659
34866
  safeEmitPlateGalleryChanged(payload) {
@@ -34724,13 +34931,7 @@ var PlateGalleryProvider = class {
34724
34931
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34725
34932
  }
34726
34933
  async getPlateMedia(input) {
34727
- return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => ({
34728
- key: m.key,
34729
- kind: m.kind,
34730
- base64: m.base64,
34731
- sizeBytes: m.sizeBytes,
34732
- timestamp: m.timestamp
34733
- }));
34934
+ return (await this.mediaStore.listByOwner("plate", input.plateId)).map((m) => toGalleryMediaRow(m, this.eventMediaPathPrefix()));
34734
34935
  }
34735
34936
  async searchPlates(input) {
34736
34937
  const maxDistance = input.maxDistance ?? 1;
@@ -34973,6 +35174,51 @@ var PlateGalleryProvider = class {
34973
35174
  }
34974
35175
  };
34975
35176
  //#endregion
35177
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
35178
+ /**
35179
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
35180
+ * return the tracks it produced.
35181
+ *
35182
+ * No `TrackStore`, no bus, no media — a track here is a summary built
35183
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
35184
+ */
35185
+ function runFrameProcessorPass(input) {
35186
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
35187
+ if (input.zones !== void 0) processor.setZones(input.zones);
35188
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
35189
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
35190
+ const accum = /* @__PURE__ */ new Map();
35191
+ for (const { timestamp, frame } of input.frames) {
35192
+ const result = processor.process({
35193
+ timestamp,
35194
+ frame
35195
+ });
35196
+ for (const td of result.tracked) {
35197
+ const existing = accum.get(td.trackId);
35198
+ const matched = td.matchedThisFrame ? 1 : 0;
35199
+ if (existing === void 0) accum.set(td.trackId, {
35200
+ className: td.className,
35201
+ firstSeenMs: timestamp,
35202
+ lastSeenMs: timestamp,
35203
+ bbox: { ...td.bbox },
35204
+ framesMatched: matched
35205
+ });
35206
+ else {
35207
+ existing.className = td.className;
35208
+ existing.lastSeenMs = timestamp;
35209
+ existing.framesMatched += matched;
35210
+ }
35211
+ }
35212
+ }
35213
+ return { tracks: [...accum.values()].map((t) => ({
35214
+ className: t.className,
35215
+ firstSeenMs: t.firstSeenMs,
35216
+ lastSeenMs: t.lastSeenMs,
35217
+ bbox: t.bbox,
35218
+ framesMatched: t.framesMatched
35219
+ })) };
35220
+ }
35221
+ //#endregion
34976
35222
  //#region src/pipeline-analytics/retention/disk-reconcile.ts
34977
35223
  /**
34978
35224
  * Disk-wins reconcile for pipeline analytics.
@@ -35037,93 +35283,6 @@ async function reconcileDeviceFromDisk(deps, deviceId) {
35037
35283
  };
35038
35284
  }
35039
35285
  //#endregion
35040
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35041
- /**
35042
- * The operator button for the debug-media reclaim.
35043
- *
35044
- * The mechanism ships armed with nothing: **arming it is the operator's
35045
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35046
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35047
- * that has to be typed.
35048
- *
35049
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35050
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35051
- * report you can read instead of a log line you have to find.
35052
- */
35053
- /**
35054
- * `report` counts; `reclaim` deletes.
35055
- *
35056
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
35057
- * button with no fields, and the safe reading of an unstated intent is the one
35058
- * that destroys nothing.
35059
- */
35060
- var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
35061
- var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
35062
- var MediaReclaimActionInputSchema = require_dist.object({
35063
- mode: MediaReclaimActionModeSchema,
35064
- /** Which populations to walk. Omitted = both. */
35065
- scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
35066
- /** Restrict to these cameras. Omitted = every camera in retention scope. */
35067
- deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
35068
- /** Start every device from the top instead of resuming its cursor. */
35069
- restart: require_dist.boolean().optional(),
35070
- /** Rows read per statement. Smaller = more yields = a gentler pass. */
35071
- pageSize: require_dist.number().int().min(50).max(5e3).optional(),
35072
- /** Rows EXAMINED per camera in this pass — the pacing bound. */
35073
- maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35074
- /** Rows DELETED per camera in this pass — the blast-radius bound. */
35075
- maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35076
- /** Bytes released across the whole run — the disk-contention bound. */
35077
- maxBytesPerRun: require_dist.number().int().min(1).optional(),
35078
- /** Wall-clock ceiling for the run. */
35079
- budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
35080
- /**
35081
- * Bytes per second the pass may release. The recordings volume sustains
35082
- * ~4 MB/s and the archive needs it more than this does; the default is half
35083
- * of that.
35084
- */
35085
- throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
35086
- /**
35087
- * Minutes of immunity for a recent row. Protective ONLY — raising it can
35088
- * exclusively save rows, never select them.
35089
- */
35090
- graceMinutes: require_dist.number().int().min(1).max(10080).optional()
35091
- });
35092
- var MediaReclaimDeviceReportSchema = require_dist.object({
35093
- deviceId: require_dist.number(),
35094
- scope: MediaReclaimScopeSchema,
35095
- examined: require_dist.number(),
35096
- eligible: require_dist.number(),
35097
- reclaimed: require_dist.number(),
35098
- bytesReclaimed: require_dist.number(),
35099
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35100
- held: require_dist.number(),
35101
- /** Rows kept because the owner could not be shown to be a motion event. */
35102
- unresolved: require_dist.number(),
35103
- withinGrace: require_dist.number(),
35104
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
35105
- * The row was kept and the cursor did not pass it. */
35106
- refused: require_dist.number(),
35107
- stoppedBy: require_dist.string(),
35108
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35109
- nextCursor: require_dist.number()
35110
- });
35111
- var MediaReclaimActionResultSchema = require_dist.object({
35112
- /** `false` when a pass was already in flight — nothing was done. */
35113
- ran: require_dist.boolean(),
35114
- mode: require_dist._enum(["report", "reclaim"]),
35115
- devices: require_dist.array(MediaReclaimDeviceReportSchema),
35116
- totalExamined: require_dist.number(),
35117
- totalEligible: require_dist.number(),
35118
- totalReclaimed: require_dist.number(),
35119
- totalBytesReclaimed: require_dist.number(),
35120
- totalRefused: require_dist.number(),
35121
- durationMs: require_dist.number(),
35122
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
35123
- complete: require_dist.boolean()
35124
- });
35125
- var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
35126
- //#endregion
35127
35286
  //#region src/pipeline-analytics/retention/media-reclaim.ts
35128
35287
  var MEDIA_RECLAIM_SCOPES = ["motion-still", "track-filmstrip"];
35129
35288
  var DEFAULT_MEDIA_RECLAIM_LIMITS = {
@@ -35335,6 +35494,93 @@ async function runMediaReclaim(input) {
35335
35494
  };
35336
35495
  }
35337
35496
  //#endregion
35497
+ //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35498
+ /**
35499
+ * The operator button for the debug-media reclaim.
35500
+ *
35501
+ * The mechanism ships armed with nothing: **arming it is the operator's
35502
+ * call**. `mode` omitted means `report` — it counts, logs one line per camera
35503
+ * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35504
+ * that has to be typed.
35505
+ *
35506
+ * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35507
+ * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35508
+ * report you can read instead of a log line you have to find.
35509
+ */
35510
+ /**
35511
+ * `report` counts; `reclaim` deletes.
35512
+ *
35513
+ * `.default('report')` is load-bearing: the settings form sends `{}` for a
35514
+ * button with no fields, and the safe reading of an unstated intent is the one
35515
+ * that destroys nothing.
35516
+ */
35517
+ var MediaReclaimActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
35518
+ var MediaReclaimScopeSchema = require_dist._enum(["motion-still", "track-filmstrip"]);
35519
+ var MediaReclaimActionInputSchema = require_dist.object({
35520
+ mode: MediaReclaimActionModeSchema,
35521
+ /** Which populations to walk. Omitted = both. */
35522
+ scopes: require_dist.array(MediaReclaimScopeSchema).min(1).optional(),
35523
+ /** Restrict to these cameras. Omitted = every camera in retention scope. */
35524
+ deviceIds: require_dist.array(require_dist.number().int()).min(1).optional(),
35525
+ /** Start every device from the top instead of resuming its cursor. */
35526
+ restart: require_dist.boolean().optional(),
35527
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
35528
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
35529
+ /** Rows EXAMINED per camera in this pass — the pacing bound. */
35530
+ maxRowsPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35531
+ /** Rows DELETED per camera in this pass — the blast-radius bound. */
35532
+ maxReclaimPerDevice: require_dist.number().int().min(1).max(5e5).optional(),
35533
+ /** Bytes released across the whole run — the disk-contention bound. */
35534
+ maxBytesPerRun: require_dist.number().int().min(1).optional(),
35535
+ /** Wall-clock ceiling for the run. */
35536
+ budgetMinutes: require_dist.number().int().min(1).max(720).optional(),
35537
+ /**
35538
+ * Bytes per second the pass may release. The recordings volume sustains
35539
+ * ~4 MB/s and the archive needs it more than this does; the default is half
35540
+ * of that.
35541
+ */
35542
+ throttleBytesPerSec: require_dist.number().int().min(64 * 1024).optional(),
35543
+ /**
35544
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
35545
+ * exclusively save rows, never select them.
35546
+ */
35547
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
35548
+ });
35549
+ var MediaReclaimDeviceReportSchema = require_dist.object({
35550
+ deviceId: require_dist.number(),
35551
+ scope: MediaReclaimScopeSchema,
35552
+ examined: require_dist.number(),
35553
+ eligible: require_dist.number(),
35554
+ reclaimed: require_dist.number(),
35555
+ bytesReclaimed: require_dist.number(),
35556
+ /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35557
+ held: require_dist.number(),
35558
+ /** Rows kept because the owner could not be shown to be a motion event. */
35559
+ unresolved: require_dist.number(),
35560
+ withinGrace: require_dist.number(),
35561
+ /** Deletes that REFUSED — the blob survived or the volume could not answer.
35562
+ * The row was kept and the cursor did not pass it. */
35563
+ refused: require_dist.number(),
35564
+ stoppedBy: require_dist.string(),
35565
+ /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35566
+ nextCursor: require_dist.number()
35567
+ });
35568
+ var MediaReclaimActionResultSchema = require_dist.object({
35569
+ /** `false` when a pass was already in flight — nothing was done. */
35570
+ ran: require_dist.boolean(),
35571
+ mode: require_dist._enum(["report", "reclaim"]),
35572
+ devices: require_dist.array(MediaReclaimDeviceReportSchema),
35573
+ totalExamined: require_dist.number(),
35574
+ totalEligible: require_dist.number(),
35575
+ totalReclaimed: require_dist.number(),
35576
+ totalBytesReclaimed: require_dist.number(),
35577
+ totalRefused: require_dist.number(),
35578
+ durationMs: require_dist.number(),
35579
+ /** Every device+scope reported `exhausted` — there is nothing left to do. */
35580
+ complete: require_dist.boolean()
35581
+ });
35582
+ var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
35583
+ //#endregion
35338
35584
  //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
35339
35585
  /**
35340
35586
  * The operator button for the orphan audit.
@@ -36691,6 +36937,28 @@ function trackMediaScopesForKinds(trackId, kinds) {
36691
36937
  return kinds.some((kind) => DETAIL_CROP_KINDS.includes(kind)) ? scopes : scopes.filter((scope) => scope.publishCropAs === null);
36692
36938
  }
36693
36939
  /**
36940
+ * The STORED kinds worth reading from one scope for a `kinds` request, or
36941
+ * `null` when the scope cannot contribute to it at all.
36942
+ *
36943
+ * This is the same vocabulary translation {@link collectTrackMedia} performs on
36944
+ * the way out, moved to the way IN so it can narrow the query instead of the
36945
+ * answer. A caller naming `faceCrop` must ask the face owner for its stored
36946
+ * `crop` — asking it for `faceCrop` matches nothing, and asking it for
36947
+ * everything opens blobs the answer will discard.
36948
+ *
36949
+ * `undefined` means "every kind": the honest translation of an unfiltered
36950
+ * request, and the only safe answer for the open-ended track scope.
36951
+ *
36952
+ * `null` (skip) is only ever returned for a DETAIL owner whose one publishable
36953
+ * kind was not asked for. The track scope is never skipped — its kinds are
36954
+ * open-ended, and dropping it on a guess is how a listing starts lying.
36955
+ */
36956
+ function storedKindsForScope(scope, kinds) {
36957
+ if (kinds === void 0 || kinds.length === 0) return void 0;
36958
+ if (scope.publishCropAs === null) return kinds;
36959
+ return kinds.includes(scope.publishCropAs) ? [STORED_DETAIL_CROP_KIND] : null;
36960
+ }
36961
+ /**
36694
36962
  * Fold every scope's rows into ONE track media listing: detail crops
36695
36963
  * relabelled, everything filtered to `kinds`, ordered oldest first.
36696
36964
  *
@@ -36827,8 +37095,10 @@ var AnalyticsQueryFacade = class {
36827
37095
  return this.withMediaUrl(events);
36828
37096
  }
36829
37097
  async getEventMedia(input) {
36830
- const all = await (this.deps.mediaStore()?.listByOwner("event", input.eventId, input.deviceId) ?? Promise.resolve([]));
36831
- return input.kind ? all.filter((m) => m.kind === input.kind) : all;
37098
+ const store = this.deps.mediaStore();
37099
+ if (store === null) return [];
37100
+ const prefix = this.deps.eventMediaPathPrefix();
37101
+ return (await store.listByOwner("event", input.eventId, input.deviceId, input.kind === void 0 ? void 0 : [input.kind])).map((row) => toWireMediaFile(row, prefix));
36832
37102
  }
36833
37103
  /**
36834
37104
  * A track's media WITH the bytes, restricted to `kinds` when given.
@@ -36841,14 +37111,36 @@ var AnalyticsQueryFacade = class {
36841
37111
  async getTrackMedia(input) {
36842
37112
  const store = this.deps.mediaStore();
36843
37113
  if (store === null) return [];
37114
+ const prefix = this.deps.eventMediaPathPrefix();
36844
37115
  const scopes = trackMediaScopesForKinds(input.trackId, input.kinds);
36845
- return collectTrackMedia(await Promise.all(scopes.map(async (scope) => ({
36846
- scope,
36847
- rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36848
- }))), (row, kind) => ({
37116
+ return collectTrackMedia(await Promise.all(scopes.map(async (scope) => {
37117
+ const stored = storedKindsForScope(scope, input.kinds);
37118
+ if (stored === null) return {
37119
+ scope,
37120
+ rows: []
37121
+ };
37122
+ return {
37123
+ scope,
37124
+ rows: await store.listByOwner(scope.ownerKind, scope.ownerId, input.deviceId, stored)
37125
+ };
37126
+ })), (row, kind) => ({
36849
37127
  ...row,
36850
37128
  kind
36851
- }), input.kinds);
37129
+ }), input.kinds).map((row) => toWireMediaFile(row, prefix));
37130
+ }
37131
+ /**
37132
+ * An event's media WITHOUT the bytes.
37133
+ *
37134
+ * Index rows only — it never opens a blob. The track detail modal unions
37135
+ * this with {@link listTrackMedia} to build its filmstrip and then fetches
37136
+ * each tile from the `event-media` plane by key, so the bytes this used to
37137
+ * carry were read, base64'd, shipped and discarded.
37138
+ */
37139
+ async listEventMedia(input) {
37140
+ const store = this.deps.mediaStore();
37141
+ if (store === null) return [];
37142
+ const prefix = this.deps.eventMediaPathPrefix();
37143
+ return (await store.listInfoByOwner("event", input.eventId, input.deviceId)).map((row) => toWireMediaFileInfo(row, prefix));
36852
37144
  }
36853
37145
  /**
36854
37146
  * A track's media WITHOUT the bytes.
@@ -36866,13 +37158,14 @@ var AnalyticsQueryFacade = class {
36866
37158
  async listTrackMedia(input) {
36867
37159
  const store = this.deps.mediaStore();
36868
37160
  if (store === null) return [];
37161
+ const prefix = this.deps.eventMediaPathPrefix();
36869
37162
  return collectTrackMedia(await Promise.all(trackMediaOwnerScopes(input.trackId).map(async (scope) => ({
36870
37163
  scope,
36871
37164
  rows: await store.listInfoByOwner(scope.ownerKind, scope.ownerId, input.deviceId)
36872
37165
  }))), (row, kind) => ({
36873
37166
  ...row,
36874
37167
  kind
36875
- }), void 0);
37168
+ }), void 0).map((row) => toWireMediaFileInfo(row, prefix));
36876
37169
  }
36877
37170
  async pruneEventsBefore(input) {
36878
37171
  const eventStore = this.deps.eventStore();
@@ -36899,10 +37192,34 @@ var AnalyticsQueryFacade = class {
36899
37192
  }
36900
37193
  /**
36901
37194
  * Durable event-store footprint for the events-management UI: event ROWS
36902
- * (motion + object + audio) counted per camera (indexed `count`) + total, and
36903
- * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
36904
- * The device set is the union of cameras that have media OR persisted tracks;
36905
- * cameras with neither rows nor bytes are omitted.
37195
+ * (motion + object + audio) per camera + total, and event-owned media BYTES
37196
+ * on disk per camera + total. Cameras with neither rows nor bytes are
37197
+ * omitted; the rest come back ordered by bytes, descending, which is the
37198
+ * order the operator's table renders.
37199
+ *
37200
+ * ## The device set is a union of THREE sources
37201
+ *
37202
+ * It used to be `media ∪ tracks`, and a camera holding events but no media
37203
+ * row and no persisted track was therefore invisible — not listed, and its
37204
+ * events missing from `totalRows`. That combination is not exotic: media
37205
+ * retention, a media deletion and `pruneTracksBefore` all run on clocks of
37206
+ * their own, and every one of them can outlive the event rows. The number
37207
+ * this method exists to produce is the denominator for a deletion, so an
37208
+ * undercount is the one failure mode that matters, and it was silent.
37209
+ *
37210
+ * Events now contribute their own device set, so a camera is present if ANY
37211
+ * of the three sources has ever heard of it.
37212
+ *
37213
+ * ## The cost is the answer, not the table
37214
+ *
37215
+ * Three grouped reads for the event rows (one per event collection, never
37216
+ * three per camera), one grouped read plus one aggregate per camera for the
37217
+ * media bytes, one for the track device set. The retired shape paid ~1 250
37218
+ * round trips for the media half alone and timed out at 60 s on every call.
37219
+ * See {@link MediaStore.footprintByDevice} and {@link EventStore.countByDevice}.
37220
+ *
37221
+ * A store that cannot answer THROWS rather than returning zeroes — see the
37222
+ * same note on both.
36906
37223
  */
36907
37224
  async getEventStoreFootprint() {
36908
37225
  const eventStore = this.deps.eventStore();
@@ -36912,15 +37229,16 @@ var AnalyticsQueryFacade = class {
36912
37229
  totalBytes: 0,
36913
37230
  devices: []
36914
37231
  };
36915
- const mediaByDevice = await mediaStore.footprintByDevice();
37232
+ const [mediaByDevice, eventRowsByDevice] = await Promise.all([mediaStore.footprintByDevice(), eventStore.countByDevice()]);
36916
37233
  const deviceIds = new Set(mediaByDevice.keys());
37234
+ for (const id of eventRowsByDevice.keys()) deviceIds.add(id);
36917
37235
  const trackStore = this.deps.trackStore();
36918
37236
  if (trackStore) for (const id of await trackStore.listDeviceIds()) deviceIds.add(id);
36919
37237
  const devices = [];
36920
37238
  let totalRows = 0;
36921
37239
  let totalBytes = 0;
36922
37240
  for (const deviceId of deviceIds) {
36923
- const rows = await eventStore.countForDevice(deviceId);
37241
+ const rows = eventRowsByDevice.get(deviceId) ?? 0;
36924
37242
  const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
36925
37243
  if (rows === 0 && bytes === 0) continue;
36926
37244
  totalRows += rows;
@@ -36939,6 +37257,26 @@ var AnalyticsQueryFacade = class {
36939
37257
  };
36940
37258
  }
36941
37259
  /**
37260
+ * The media footprint by KIND — the axis that says what is safe to delete.
37261
+ * See {@link MediaStore.footprintByKind}.
37262
+ *
37263
+ * An absent media store is an empty breakdown, not a refusal: it is the same
37264
+ * "the addon has not finished booting" branch every other method here takes,
37265
+ * and the zeroes are accompanied by an empty `kinds` list, which no reader
37266
+ * can mistake for a measured "there is nothing".
37267
+ */
37268
+ async getEventMediaFootprintByKind(input) {
37269
+ const mediaStore = this.deps.mediaStore();
37270
+ if (!mediaStore) return {
37271
+ totalRows: 0,
37272
+ totalBytes: 0,
37273
+ kinds: [],
37274
+ unaccountedRows: 0,
37275
+ unaccountedBytes: 0
37276
+ };
37277
+ return mediaStore.footprintByKind(input.deviceId);
37278
+ }
37279
+ /**
36942
37280
  * Cluster-wide prune of events with `timestamp < olderThanMs` across every
36943
37281
  * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
36944
37282
  * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
@@ -39317,6 +39655,31 @@ function createEventMediaHandler(deps) {
39317
39655
  }
39318
39656
  //#endregion
39319
39657
  //#region src/pipeline-analytics/event-thumbnail-resolver.ts
39658
+ /**
39659
+ * Rank `candidates` with `pick`, then open ONE blob — the winner's.
39660
+ *
39661
+ * The ladders decide on `kind` alone, so the bytes are dead weight until the
39662
+ * choice is made. Handing them pre-read rows meant a tile that serves the
39663
+ * 31 KB `thumbnailSmall` first read the whole 3 597 219-byte set, including a
39664
+ * 2.8 MB `keyFrame` no list view has ever rendered.
39665
+ *
39666
+ * A winner whose blob is GONE is dropped and the ladder re-run on the rest.
39667
+ * That fall-through is not an extra: `listByOwner` used to provide it for free
39668
+ * by skipping unreadable rows before the ladder ever saw them, and without it
39669
+ * an index row that outlived its file would 404 a tile that used to render.
39670
+ * Bounded by the candidate count — each attempt removes one row.
39671
+ */
39672
+ async function readWinningBlob(candidates, pick, readBlob) {
39673
+ let remaining = candidates;
39674
+ while (remaining.length > 0) {
39675
+ const winner = pick(remaining);
39676
+ if (winner === void 0) return null;
39677
+ const bytes = await readBlob(winner.key);
39678
+ if (bytes !== null) return bytes;
39679
+ remaining = remaining.filter((row) => row.key !== winner.key);
39680
+ }
39681
+ return null;
39682
+ }
39320
39683
  /** Pick an event-OWNED media blob: the tight `crop`, else the native-res boxed
39321
39684
  * `fullFrameBoxed`, else any available file. Both preferred kinds are RETIRED
39322
39685
  * for new tracks (served only for old stored rows). Undefined when the event
@@ -39367,15 +39730,24 @@ async function collectCropForcedMedia(source, id) {
39367
39730
  };
39368
39731
  }
39369
39732
  /**
39370
- * Resolve the default-path media for an event id: the event's own media when it
39371
- * has any, else the owning track's fallback media. Returns `null` when neither
39372
- * yields a blob (caller 404s → the surface shows an icon).
39733
+ * Which rows the default path should serve from, and how to rank them: the
39734
+ * event's OWN media when it has any, else the owning track's.
39735
+ *
39736
+ * It yields the candidate SET rather than a winner so the caller can open one
39737
+ * blob and, if that blob has gone, re-rank the rest ({@link readWinningBlob}).
39738
+ * Returning a single row would force the caller to read it blind and 404 a tile
39739
+ * whose index row outlived its file.
39373
39740
  */
39374
- async function resolveDefaultEventMedia(deps) {
39375
- const owned = pickEventOwnedMedia(deps.eventFiles);
39376
- if (owned) return owned;
39741
+ async function collectDefaultEventMedia(deps) {
39742
+ if (deps.eventFiles.length > 0) return {
39743
+ candidates: deps.eventFiles,
39744
+ pick: pickEventOwnedMedia
39745
+ };
39377
39746
  const trackId = await deps.getTrackIdForEvent(deps.eventId) ?? deps.eventId;
39378
- return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
39747
+ return {
39748
+ candidates: await deps.listTrackMedia(trackId),
39749
+ pick: pickTrackFallbackMedia
39750
+ };
39379
39751
  }
39380
39752
  //#endregion
39381
39753
  //#region src/pipeline-analytics/face-settings.ts
@@ -44573,6 +44945,15 @@ function snapshot(j) {
44573
44945
  */
44574
44946
  var LOCATION_ID_FIELD = "locationId";
44575
44947
  /**
44948
+ * Unstamped rows claimed per bulk seal statement.
44949
+ *
44950
+ * Bounds two things at once: the number of bound parameters in the `IN` list,
44951
+ * and how long one `UPDATE` holds the write lock on the database every process
44952
+ * in the cluster reads configuration through. Deliberately well under SQLite's
44953
+ * variable ceiling, and small enough that a cancel lands within one statement.
44954
+ */
44955
+ var SEAL_BATCH_SIZE = 500;
44956
+ /**
44576
44957
  * True when the row names NO location — the population `seal` exists for.
44577
44958
  *
44578
44959
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44804,6 +45185,17 @@ var MediaRelocateEngine = class {
44804
45185
  }
44805
45186
  async countSelected(mode, toLocationId, stop) {
44806
45187
  try {
45188
+ if (mode === "seal") {
45189
+ let sealable = 0;
45190
+ for (const collection of collectionsFor(mode)) {
45191
+ if (stop()) return null;
45192
+ sealable += await this.deps.store.count.query({
45193
+ collection: collection.name,
45194
+ filter: { where: { [LOCATION_ID_FIELD]: null } }
45195
+ });
45196
+ }
45197
+ return { rows: sealable };
45198
+ }
44807
45199
  let rows = 0;
44808
45200
  for (const collection of collectionsFor(mode)) {
44809
45201
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44835,6 +45227,14 @@ var MediaRelocateEngine = class {
44835
45227
  * gets believed. */
44836
45228
  let pathlessRows = 0;
44837
45229
  job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45230
+ if (job.mode === "seal") {
45231
+ for (const collection of collectionsFor(job.mode)) {
45232
+ if (job.cancelRequested) break;
45233
+ await this.sealCollection(collection.name, job);
45234
+ }
45235
+ job.state = job.cancelRequested ? "cancelled" : "done";
45236
+ return;
45237
+ }
44838
45238
  for (const collection of collectionsFor(job.mode)) {
44839
45239
  if (job.cancelRequested) break;
44840
45240
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
@@ -44842,20 +45242,6 @@ var MediaRelocateEngine = class {
44842
45242
  if (job.cancelRequested) break;
44843
45243
  const data = row.data;
44844
45244
  if (!selectsRow(job.mode, job.toLocationId, data, isRetrain)) continue;
44845
- if (job.mode === "seal") {
44846
- try {
44847
- await this.stamp(collection.name, row.id, data, job.toLocationId);
44848
- job.filesMoved++;
44849
- } catch (err) {
44850
- failedRows++;
44851
- this.deps.logger.debug("event-media seal row failed", { meta: {
44852
- collection: collection.name,
44853
- key: row.id,
44854
- error: String(err)
44855
- } });
44856
- }
44857
- continue;
44858
- }
44859
45245
  const from = mediaRowLocation(data);
44860
45246
  const relativePath = String(data["path"] ?? "");
44861
45247
  if (relativePath.length === 0) {
@@ -44918,6 +45304,73 @@ var MediaRelocateEngine = class {
44918
45304
  this.deps.onFinished?.(snapshot(job));
44919
45305
  }
44920
45306
  }
45307
+ /**
45308
+ * Stamp every unstamped row of one collection with the job's target, in
45309
+ * bounded bulk statements.
45310
+ *
45311
+ * ## What this replaced
45312
+ *
45313
+ * A per-row loop: page the whole collection through {@link pageRows}, then
45314
+ * one `store.set.mutate` per selected row — a full-row UPSERT rewriting every
45315
+ * column to change one field, one RPC each, plus a second full walk before it
45316
+ * to produce the denominator. Measured on the live hub against the 464 902
45317
+ * unstamped rows: **2.3 rows/s, falling to 0.7 rows/s under a competing
45318
+ * drain**, i.e. over two and a half days for a metadata stamp that reads no
45319
+ * file and moves no byte. It was cancelled. This is the write-side twin of
45320
+ * the read-side walk {@link countUnstamped} retired, and the fourth instance
45321
+ * of the same pattern found in one day — work looped over the door instead of
45322
+ * pushed into the statement.
45323
+ *
45324
+ * ## The shape
45325
+ *
45326
+ * Probe the indexed `IS NULL` range for a batch of ids, then ONE
45327
+ * `updateWhere` over those ids. Two round trips per {@link SEAL_BATCH_SIZE}
45328
+ * rows instead of one write per row.
45329
+ *
45330
+ * The probe carries no cursor and needs none: the predicate CONSUMES itself.
45331
+ * A stamped row leaves the range, so the next probe returns the next batch,
45332
+ * and a run that is interrupted anywhere simply resumes on what is left. That
45333
+ * is also why the seal is idempotent (a second run probes an empty range and
45334
+ * stamps nothing) and why it cannot repoint a row that already names a
45335
+ * different location — `IS NULL` never selects one.
45336
+ *
45337
+ * A single unbounded `UPDATE … WHERE locationId IS NULL` would be one
45338
+ * statement instead of ~1 860, and is not used: half a million row rewrites,
45339
+ * each maintaining four indexes, is an unbounded statement holding the write
45340
+ * lock on a database the whole cluster reads configuration through, under an
45341
+ * RPC deadline that must not be raised. Batching bounds the statement, keeps
45342
+ * `cancel` responsive between batches, and keeps `filesMoved` honest.
45343
+ *
45344
+ * ## The termination guard
45345
+ *
45346
+ * If the probe finds rows and the update reports **0**, this THROWS. That is
45347
+ * not defensive noise: it is precisely what a backend compiling
45348
+ * `locationId = NULL` does — matches nothing, reports success (D312) — and
45349
+ * against it a self-consuming loop never terminates and a non-looping one
45350
+ * reports a completed seal having stamped nothing. Zero unstamped rows is the
45351
+ * single value the migration gate reads as "safe to cut over".
45352
+ */
45353
+ async sealCollection(collection, job) {
45354
+ for (;;) {
45355
+ if (job.cancelRequested) return;
45356
+ const batch = await this.deps.store.query.query({
45357
+ collection,
45358
+ filter: {
45359
+ where: { [LOCATION_ID_FIELD]: null },
45360
+ limit: SEAL_BATCH_SIZE
45361
+ },
45362
+ columns: [LOCATION_ID_FIELD]
45363
+ });
45364
+ if (batch.length === 0) return;
45365
+ const { updated } = await this.deps.store.updateWhere.mutate({
45366
+ collection,
45367
+ filter: { whereIn: { id: batch.map((row) => row.id) } },
45368
+ data: { [LOCATION_ID_FIELD]: job.toLocationId }
45369
+ });
45370
+ if (updated === 0) throw new Error(`event-media seal stamped 0 of ${batch.length} unstamped row(s) in "${collection}" — the bulk predicate matched nothing, so the population cannot be sealed`);
45371
+ job.filesMoved += updated;
45372
+ }
45373
+ }
44921
45374
  async stamp(collection, key, data, locationId) {
44922
45375
  await this.deps.store.set.mutate({
44923
45376
  collection,
@@ -47483,6 +47936,70 @@ var PackageDropDetector = class {
47483
47936
  }
47484
47937
  };
47485
47938
  //#endregion
47939
+ //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
47940
+ /**
47941
+ * How much of the PERSON box must sit inside a vehicle box to be counted.
47942
+ *
47943
+ * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
47944
+ * routinely pokes a little outside the vehicle box, and requiring total
47945
+ * containment would count almost none of them. 0.8 and not 0.5: half a person
47946
+ * box overlapping a car is what a pedestrian walking in front of one looks
47947
+ * like, and counting those would make the number mean "somebody was near a
47948
+ * car", which is not a defect.
47949
+ */
47950
+ var CONTAINMENT_THRESHOLD = .8;
47951
+ /**
47952
+ * The classes that count as a vehicle for this test.
47953
+ *
47954
+ * A local list rather than a shared taxonomy on purpose: it exists to describe
47955
+ * ONE measurement, and a shared enum would make a change here silently change
47956
+ * detection behaviour somewhere else.
47957
+ */
47958
+ var VEHICLE_CLASSES$1 = [
47959
+ "car",
47960
+ "truck",
47961
+ "bus",
47962
+ "motorcycle",
47963
+ "vehicle"
47964
+ ];
47965
+ /** Area of the intersection of two boxes. `0` when they do not overlap. */
47966
+ function intersectionArea(a, b) {
47967
+ const left = Math.max(a.x, b.x);
47968
+ const right = Math.min(a.x + a.w, b.x + b.w);
47969
+ const top = Math.max(a.y, b.y);
47970
+ const bottom = Math.min(a.y + a.h, b.y + b.h);
47971
+ if (right <= left || bottom <= top) return 0;
47972
+ return (right - left) * (bottom - top);
47973
+ }
47974
+ /**
47975
+ * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
47976
+ *
47977
+ * `0` for a degenerate person box: a zero-area box is inside everything and
47978
+ * inside nothing, and answering `1` would count every one of them.
47979
+ */
47980
+ function containedFraction(person, vehicle) {
47981
+ const area = person.w * person.h;
47982
+ if (area <= 0) return 0;
47983
+ return intersectionArea(person, vehicle) / area;
47984
+ }
47985
+ /**
47986
+ * Is this person box contained in any vehicle box in the same frame?
47987
+ *
47988
+ * Returns the fraction for the BEST-containing vehicle when it clears
47989
+ * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
47990
+ * a counter that only ever says "yes" cannot later be re-thresholded against
47991
+ * what was actually measured.
47992
+ */
47993
+ function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
47994
+ let best = 0;
47995
+ for (const neighbour of neighbours) {
47996
+ if (!VEHICLE_CLASSES$1.includes(neighbour.className)) continue;
47997
+ const fraction = containedFraction(person, neighbour.bbox);
47998
+ if (fraction > best) best = fraction;
47999
+ }
48000
+ return best >= threshold ? best : null;
48001
+ }
48002
+ //#endregion
47486
48003
  //#region src/pipeline-analytics/pipeline/stationary/stationary-evidence.ts
47487
48004
  /**
47488
48005
  * stationary-evidence — how a parked object EARNS a registry entry.
@@ -50014,83 +50531,6 @@ function buildRecordedStillPorts(input) {
50014
50531
  })
50015
50532
  };
50016
50533
  }
50017
- function errorMessage(err) {
50018
- return err instanceof Error ? err.message : String(err);
50019
- }
50020
- async function reconcileGallerySamples(deps, options) {
50021
- let checked = 0;
50022
- let reclaimed = 0;
50023
- let keyless = 0;
50024
- let skippedGrace = 0;
50025
- let stoppedByCeiling = false;
50026
- const identities = await deps.listIdentities();
50027
- for (const identity of identities) {
50028
- const samples = await deps.listSamples(identity.id);
50029
- for (const sample of samples) {
50030
- if (sample.mediaKey === void 0) {
50031
- keyless += 1;
50032
- continue;
50033
- }
50034
- if (options.now - sample.addedAt < options.graceMs) {
50035
- skippedGrace += 1;
50036
- continue;
50037
- }
50038
- if (reclaimed >= options.maxReclaimPerRun) {
50039
- stoppedByCeiling = true;
50040
- deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
50041
- return {
50042
- checked,
50043
- reclaimed,
50044
- keyless,
50045
- skippedGrace,
50046
- stoppedByCeiling
50047
- };
50048
- }
50049
- let state;
50050
- try {
50051
- state = await deps.blobState(sample.mediaKey);
50052
- } catch (err) {
50053
- const reason = errorMessage(err);
50054
- deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
50055
- sampleId: sample.id,
50056
- identity: identity.name,
50057
- error: reason
50058
- } });
50059
- return {
50060
- checked,
50061
- reclaimed,
50062
- keyless,
50063
- skippedGrace,
50064
- stoppedByCeiling,
50065
- aborted: reason
50066
- };
50067
- }
50068
- checked += 1;
50069
- if (state === "exists") continue;
50070
- await deps.removeSample(identity.id, sample.id);
50071
- reclaimed += 1;
50072
- deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50073
- sampleId: sample.id,
50074
- identity: identity.name,
50075
- mediaKey: sample.mediaKey,
50076
- state
50077
- } });
50078
- }
50079
- }
50080
- if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50081
- checked,
50082
- reclaimed,
50083
- keyless,
50084
- skippedGrace
50085
- } });
50086
- return {
50087
- checked,
50088
- reclaimed,
50089
- keyless,
50090
- skippedGrace,
50091
- stoppedByCeiling
50092
- };
50093
- }
50094
50534
  //#endregion
50095
50535
  //#region src/pipeline-analytics/retention/embedding-orphan-scan.ts
50096
50536
  /** The scope id this leg reports and persists its cursor under. */
@@ -50207,6 +50647,83 @@ async function scanEmbeddingOrphans(deps, limits, cursor) {
50207
50647
  nextCursor: Math.max(0, offset)
50208
50648
  };
50209
50649
  }
50650
+ function errorMessage(err) {
50651
+ return err instanceof Error ? err.message : String(err);
50652
+ }
50653
+ async function reconcileGallerySamples(deps, options) {
50654
+ let checked = 0;
50655
+ let reclaimed = 0;
50656
+ let keyless = 0;
50657
+ let skippedGrace = 0;
50658
+ let stoppedByCeiling = false;
50659
+ const identities = await deps.listIdentities();
50660
+ for (const identity of identities) {
50661
+ const samples = await deps.listSamples(identity.id);
50662
+ for (const sample of samples) {
50663
+ if (sample.mediaKey === void 0) {
50664
+ keyless += 1;
50665
+ continue;
50666
+ }
50667
+ if (options.now - sample.addedAt < options.graceMs) {
50668
+ skippedGrace += 1;
50669
+ continue;
50670
+ }
50671
+ if (reclaimed >= options.maxReclaimPerRun) {
50672
+ stoppedByCeiling = true;
50673
+ deps.logger.warn("gallery reconcile: reclaim ceiling reached — remaining dead references keep until the next run", { meta: { ceiling: options.maxReclaimPerRun } });
50674
+ return {
50675
+ checked,
50676
+ reclaimed,
50677
+ keyless,
50678
+ skippedGrace,
50679
+ stoppedByCeiling
50680
+ };
50681
+ }
50682
+ let state;
50683
+ try {
50684
+ state = await deps.blobState(sample.mediaKey);
50685
+ } catch (err) {
50686
+ const reason = errorMessage(err);
50687
+ deps.logger.warn("gallery reconcile: a blob probe FAILED — aborting the pass, keeping every row", { meta: {
50688
+ sampleId: sample.id,
50689
+ identity: identity.name,
50690
+ error: reason
50691
+ } });
50692
+ return {
50693
+ checked,
50694
+ reclaimed,
50695
+ keyless,
50696
+ skippedGrace,
50697
+ stoppedByCeiling,
50698
+ aborted: reason
50699
+ };
50700
+ }
50701
+ checked += 1;
50702
+ if (state === "exists") continue;
50703
+ await deps.removeSample(identity.id, sample.id);
50704
+ reclaimed += 1;
50705
+ deps.logger.warn("gallery reconcile: sample blob is gone from the filesystem — reference removed", { meta: {
50706
+ sampleId: sample.id,
50707
+ identity: identity.name,
50708
+ mediaKey: sample.mediaKey,
50709
+ state
50710
+ } });
50711
+ }
50712
+ }
50713
+ if (reclaimed > 0) deps.logger.info("gallery reconcile: dead references removed", { meta: {
50714
+ checked,
50715
+ reclaimed,
50716
+ keyless,
50717
+ skippedGrace
50718
+ } });
50719
+ return {
50720
+ checked,
50721
+ reclaimed,
50722
+ keyless,
50723
+ skippedGrace,
50724
+ stoppedByCeiling
50725
+ };
50726
+ }
50210
50727
  /**
50211
50728
  * Where the completion marker lives.
50212
50729
  *
@@ -51585,6 +52102,43 @@ var EventStore = class {
51585
52102
  return motion + object + audio;
51586
52103
  }
51587
52104
  /**
52105
+ * Persisted event rows (motion + object + audio) for EVERY device that has
52106
+ * any — three round trips for the whole fleet.
52107
+ *
52108
+ * {@link countForDevice} answers one camera in three `COUNT(*)`s, which is
52109
+ * right for one camera and wrong in a loop: the events-management footprint
52110
+ * called it once per device, so the cost of "how many events does each camera
52111
+ * hold" grew with the camera count for a result that is a few dozen integers.
52112
+ *
52113
+ * A histogram on `deviceId` with `bucketSize: 1` is `GROUP BY deviceId`, and
52114
+ * every event collection already carries `idx_<kind>_device_ts`, which leads
52115
+ * with `deviceId` — so the planner serves each of these from a covering index
52116
+ * with no table access at all. `bucket` IS the deviceId (the column is
52117
+ * INTEGER, the bucket expression is `CAST((deviceId - 0) / 1 AS INTEGER)`).
52118
+ *
52119
+ * Not best-effort: a failure propagates. The one caller is sizing a deletion,
52120
+ * and a camera silently reported as holding zero events is the answer that
52121
+ * must never come from a read that did not happen.
52122
+ */
52123
+ async countByDevice() {
52124
+ const perCollection = await Promise.all([
52125
+ MOTION_EVENTS_COLLECTION,
52126
+ OBJECT_EVENTS_COLLECTION,
52127
+ AUDIO_EVENTS_COLLECTION
52128
+ ].map((collection) => this.store.histogram.query({
52129
+ collection,
52130
+ field: "deviceId",
52131
+ bucketSize: 1,
52132
+ origin: 0
52133
+ })));
52134
+ const out = /* @__PURE__ */ new Map();
52135
+ for (const buckets of perCollection) for (const bucket of buckets) {
52136
+ if (bucket.count === 0 || !Number.isFinite(bucket.bucket)) continue;
52137
+ out.set(bucket.bucket, (out.get(bucket.bucket) ?? 0) + bucket.count);
52138
+ }
52139
+ return out;
52140
+ }
52141
+ /**
51588
52142
  * Return per-kind event counts in equal-width time buckets.
51589
52143
  *
51590
52144
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -53269,14 +53823,14 @@ function isAfterCursor(row, cursor) {
53269
53823
  if (row.lastSeen < cursor.lastSeen) return true;
53270
53824
  return row.lastSeen === cursor.lastSeen && row.id < cursor.trackId;
53271
53825
  }
53272
- var VEHICLE_CLASSES$1 = new Set([
53826
+ var VEHICLE_CLASSES = new Set([
53273
53827
  "vehicle",
53274
53828
  "car",
53275
53829
  "truck",
53276
53830
  "bus"
53277
53831
  ]);
53278
53832
  function motionlessMaxPxForClass(className) {
53279
- return VEHICLE_CLASSES$1.has(className.toLowerCase()) ? 8 : 40;
53833
+ return VEHICLE_CLASSES.has(className.toLowerCase()) ? 8 : 40;
53280
53834
  }
53281
53835
  function netDisplacementPx(positions) {
53282
53836
  const first = positions[0];
@@ -57036,70 +57590,6 @@ function hasWrappedDetection(entries) {
57036
57590
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
57037
57591
  }
57038
57592
  //#endregion
57039
- //#region src/pipeline-analytics/pipeline/person-over-vehicle.ts
57040
- /**
57041
- * How much of the PERSON box must sit inside a vehicle box to be counted.
57042
- *
57043
- * 0.8 and not 1.0: a phantom raised on a car's window pillar or wing mirror
57044
- * routinely pokes a little outside the vehicle box, and requiring total
57045
- * containment would count almost none of them. 0.8 and not 0.5: half a person
57046
- * box overlapping a car is what a pedestrian walking in front of one looks
57047
- * like, and counting those would make the number mean "somebody was near a
57048
- * car", which is not a defect.
57049
- */
57050
- var CONTAINMENT_THRESHOLD = .8;
57051
- /**
57052
- * The classes that count as a vehicle for this test.
57053
- *
57054
- * A local list rather than a shared taxonomy on purpose: it exists to describe
57055
- * ONE measurement, and a shared enum would make a change here silently change
57056
- * detection behaviour somewhere else.
57057
- */
57058
- var VEHICLE_CLASSES = [
57059
- "car",
57060
- "truck",
57061
- "bus",
57062
- "motorcycle",
57063
- "vehicle"
57064
- ];
57065
- /** Area of the intersection of two boxes. `0` when they do not overlap. */
57066
- function intersectionArea(a, b) {
57067
- const left = Math.max(a.x, b.x);
57068
- const right = Math.min(a.x + a.w, b.x + b.w);
57069
- const top = Math.max(a.y, b.y);
57070
- const bottom = Math.min(a.y + a.h, b.y + b.h);
57071
- if (right <= left || bottom <= top) return 0;
57072
- return (right - left) * (bottom - top);
57073
- }
57074
- /**
57075
- * Fraction of `person` that lies inside `vehicle`, in `[0,1]`.
57076
- *
57077
- * `0` for a degenerate person box: a zero-area box is inside everything and
57078
- * inside nothing, and answering `1` would count every one of them.
57079
- */
57080
- function containedFraction(person, vehicle) {
57081
- const area = person.w * person.h;
57082
- if (area <= 0) return 0;
57083
- return intersectionArea(person, vehicle) / area;
57084
- }
57085
- /**
57086
- * Is this person box contained in any vehicle box in the same frame?
57087
- *
57088
- * Returns the fraction for the BEST-containing vehicle when it clears
57089
- * {@link CONTAINMENT_THRESHOLD}, else `null`. The fraction rides along because
57090
- * a counter that only ever says "yes" cannot later be re-thresholded against
57091
- * what was actually measured.
57092
- */
57093
- function personOverVehicle(person, neighbours, threshold = CONTAINMENT_THRESHOLD) {
57094
- let best = 0;
57095
- for (const neighbour of neighbours) {
57096
- if (!VEHICLE_CLASSES.includes(neighbour.className)) continue;
57097
- const fraction = containedFraction(person, neighbour.bbox);
57098
- if (fraction > best) best = fraction;
57099
- }
57100
- return best >= threshold ? best : null;
57101
- }
57102
- //#endregion
57103
57593
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
57104
57594
  function createCaptureCrop(deps) {
57105
57595
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -63541,6 +64031,20 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63541
64031
  * Set once the data-plane is registered; null until then (e.g. no
63542
64032
  * dataPlane facility in the current environment). */
63543
64033
  eventMediaBaseUrl = null;
64034
+ /**
64035
+ * The SAME path, derived rather than observed — a pure function of the addon
64036
+ * id, so it exists before `serve()` is attempted and survives its failure.
64037
+ *
64038
+ * `eventMediaBaseUrl` above answers "is the plane up?", which is the right
64039
+ * question for the OPTIONAL `mediaUrl` stamp on a slim event projection.
64040
+ * It is the wrong question for a media row's `url`, which is that row's only
64041
+ * address: a row that dropped its address on a boot race would be
64042
+ * indistinguishable from a row with no bytes, and this repo has already paid
64043
+ * for one read that quietly returned less than it was asked for.
64044
+ */
64045
+ eventMediaPathPrefix() {
64046
+ return `/addon/${this.ctx.id}/event-media`;
64047
+ }
63544
64048
  lastActiveTrackIds = /* @__PURE__ */ new Map();
63545
64049
  /** See `pipeline/suppressed-births.ts` — rejected births must not be
63546
64050
  * re-upserted, and must be forgotten when the tracker drops the id. */
@@ -63791,6 +64295,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63791
64295
  sensorEventStore: () => this.sensorEventStore,
63792
64296
  opsLog: () => this.eventsOpsLog,
63793
64297
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
64298
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
63794
64299
  logger: () => this.ctx.logger,
63795
64300
  zoneCatalogue: () => this.zoneCatalogue
63796
64301
  });
@@ -66333,6 +66838,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66333
66838
  logger: this.ctx.logger,
66334
66839
  refreshGallery: () => this.faceRecognizer?.refreshGallery(),
66335
66840
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66841
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66336
66842
  emitFaceGalleryChanged: (payload) => this.emitFaceGalleryChanged(payload)
66337
66843
  });
66338
66844
  this.faceGalleryProvider = faceGallery;
@@ -66347,6 +66853,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66347
66853
  this.plateRecognizer?.refreshGallery();
66348
66854
  },
66349
66855
  eventMediaBaseUrl: () => this.eventMediaBaseUrl,
66856
+ eventMediaPathPrefix: () => this.eventMediaPathPrefix(),
66350
66857
  emitPlateGalleryChanged: (payload) => this.emitPlateGalleryChanged(payload)
66351
66858
  });
66352
66859
  return [
@@ -70032,6 +70539,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70032
70539
  async listTrackMedia(input) {
70033
70540
  return this.queryFacade.listTrackMedia(input);
70034
70541
  }
70542
+ /** What media an EVENT has, without any of it — the same manifest, for the
70543
+ * half of the detail modal's filmstrip that is event-owned. */
70544
+ async listEventMedia(input) {
70545
+ return this.queryFacade.listEventMedia(input);
70546
+ }
70035
70547
  /**
70036
70548
  * Search object events by text using CLIP cosine similarity.
70037
70549
  *
@@ -71350,6 +71862,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71350
71862
  async getEventStoreFootprint() {
71351
71863
  return this.queryFacade.getEventStoreFootprint();
71352
71864
  }
71865
+ async getEventMediaFootprintByKind(input) {
71866
+ return this.queryFacade.getEventMediaFootprintByKind(input);
71867
+ }
71353
71868
  async pruneEvents(input) {
71354
71869
  return this.queryFacade.pruneEvents(input);
71355
71870
  }
@@ -71752,31 +72267,44 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71752
72267
  return media;
71753
72268
  }
71754
72269
  }
72270
+ /**
72271
+ * Resolve an event/track id to ONE tile's bytes.
72272
+ *
72273
+ * Every ladder below ranks rows by `kind` alone, so this walks the INDEX
72274
+ * (`listInfoByOwner`, no blob opened) and opens only the winner. It used to
72275
+ * walk `listByOwner`, which reads and base64-encodes every blob an owner
72276
+ * holds before the first comparison is made: measured on the live hub
72277
+ * 2026-08-30, a track's six kinds are 3 597 219 bytes and the tile serves
72278
+ * `thumbnailSmall` — 31 155 of them. An Events page of forty tiles read
72279
+ * ~144 MB and allocated ~190 MB of base64 to deliver ~1.2 MB, all of it on
72280
+ * the single-threaded post-analysis runner.
72281
+ */
71755
72282
  async readEventThumbnail(id, preferKind) {
71756
- const eventFiles = await (this.mediaStore?.listByOwner("event", id) ?? Promise.resolve([]));
72283
+ const listInfo = (ownerKind, ownerId) => this.mediaStore?.listInfoByOwner(ownerKind, ownerId) ?? Promise.resolve([]);
72284
+ const getTrackIdForEvent = (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null);
72285
+ const readBlob = async (key) => {
72286
+ const file = await (this.mediaStore?.getByKey(key) ?? Promise.resolve(null));
72287
+ return file === null ? null : {
72288
+ bytes: Buffer.from(file.base64, "base64"),
72289
+ key: file.key
72290
+ };
72291
+ };
72292
+ const eventFiles = await listInfo("event", id);
71757
72293
  if (preferKind !== void 0 && preferKind.length > 0) {
71758
72294
  const layers = await collectCropForcedMedia({
71759
- listByOwner: (ownerKind, ownerId) => this.mediaStore?.listByOwner(ownerKind, ownerId) ?? Promise.resolve([]),
71760
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
72295
+ listByOwner: listInfo,
72296
+ getTrackIdForEvent
71761
72297
  }, id);
71762
- const chosen = resolveCropForcedMedia(layers.bodyFiles, (files) => pickCleanMedia(files, preferKind), layers.detailFiles);
71763
- if (!chosen) return null;
71764
- return {
71765
- bytes: Buffer.from(chosen.base64, "base64"),
71766
- key: chosen.key
71767
- };
72298
+ const bodyKeys = new Set(layers.bodyFiles.map((f) => f.key));
72299
+ 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);
71768
72300
  }
71769
- const chosen = await resolveDefaultEventMedia({
72301
+ const { candidates, pick } = await collectDefaultEventMedia({
71770
72302
  eventId: id,
71771
72303
  eventFiles,
71772
- listTrackMedia: (trackId) => this.mediaStore?.listByOwner("track", trackId) ?? Promise.resolve([]),
71773
- getTrackIdForEvent: (eventId) => this.eventStore?.getTrackIdForEvent(eventId) ?? Promise.resolve(null)
72304
+ listTrackMedia: (trackId) => listInfo("track", trackId),
72305
+ getTrackIdForEvent
71774
72306
  });
71775
- if (!chosen) return null;
71776
- return {
71777
- bytes: Buffer.from(chosen.base64, "base64"),
71778
- key: chosen.key
71779
- };
72307
+ return await readWinningBlob(candidates, pick, readBlob);
71780
72308
  }
71781
72309
  globalSettingsSchema(cap) {
71782
72310
  return this.schema(buildGlobalSettingsSchema(cap));