@camstack/addon-post-analysis 1.2.343 → 1.2.345

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.
Files changed (15) hide show
  1. package/dist/{clip-model-registry-Cd_y9S4U.js → clip-model-registry-Bd8rsIjt.js} +2133 -57
  2. package/dist/{clip-model-registry-DZz3e781.mjs → clip-model-registry-CIbZ6YOF.mjs} +2133 -57
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DrdO3Pxp.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DTenVWiv.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DTOF9hbS.mjs +26 -0
  8. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DUrhEd38.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-DPNnWr-m.mjs → hostInit-BO3gbCue.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +2391 -405
  11. package/dist/pipeline-analytics/index.mjs +2391 -405
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DEq-B6oq.mjs +0 -26
  15. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CiTUMvVf.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-Cd_y9S4U.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-Bd8rsIjt.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5839,16 +5839,16 @@ function ncDetectionSummary(texts, counts) {
5839
5839
  //#region src/notification-center/timelapse/detection-counts.ts
5840
5840
  /** One normalisation, used on both sides of every comparison — a rule that
5841
5841
  * named `Person` must match a track classified `person`. */
5842
- function normalise(name) {
5842
+ function normalise$1(name) {
5843
5843
  return name.trim().toLowerCase();
5844
5844
  }
5845
5845
  /** Count per class over the tracks, honouring the rule's selection. */
5846
5846
  function countDetections(input) {
5847
- const selected = (input.reportClasses ?? []).map(normalise).filter((c) => c.length > 0);
5847
+ const selected = (input.reportClasses ?? []).map(normalise$1).filter((c) => c.length > 0);
5848
5848
  const seen = /* @__PURE__ */ new Map();
5849
5849
  for (const t of input.tracks) {
5850
5850
  if (t.className === void 0) continue;
5851
- const key = normalise(t.className);
5851
+ const key = normalise$1(t.className);
5852
5852
  if (key.length === 0) continue;
5853
5853
  if (selected.length > 0 && !selected.includes(key)) continue;
5854
5854
  seen.set(key, (seen.get(key) ?? 0) + 1);
@@ -12922,7 +12922,7 @@ function stringField(data, key) {
12922
12922
  const value = data[key];
12923
12923
  return typeof value === "string" && value.length > 0 ? value : void 0;
12924
12924
  }
12925
- function stringList(data, key) {
12925
+ function stringList$1(data, key) {
12926
12926
  const raw = data[key];
12927
12927
  if (!Array.isArray(raw)) return [];
12928
12928
  const ids = [];
@@ -13734,7 +13734,7 @@ function incomingFromExportCompleted(facts, data) {
13734
13734
  const deviceId = numberField(data, "deviceId");
13735
13735
  const title = stringField(data, "title");
13736
13736
  const bytes = numberField(data, "fileBytes");
13737
- const notifyTargetIds = stringList(data, "notifyTargetIds");
13737
+ const notifyTargetIds = stringList$1(data, "notifyTargetIds");
13738
13738
  const incoming = systemSubject({
13739
13739
  facts,
13740
13740
  kind: "export-completed",
@@ -70619,10 +70619,12 @@ var TrackStore = class {
70619
70619
  */
70620
70620
  /**
70621
70621
  * @durable class=ledger owner=pipeline-analytics
70622
- * write="one row per frame the operator SELECTS into the retrain dataset. The
70623
- * copy is taken eagerly at selection (copyFrame), not referenced, so the row
70624
- * records a blob the dataset owns outright; re-selecting the same source reuses
70625
- * the existing copy rather than writing a second one."
70622
+ * write="one row per frame the operator SELECTS into the retrain dataset, or adds
70623
+ * from the recording at an instant (storeRecordingStill, origin 'recording', no
70624
+ * track). The copy is taken eagerly at selection (copyFrame), not referenced, so
70625
+ * the row records a blob the dataset owns outright; re-selecting the same source
70626
+ * (or the same camera instant) reuses the existing copy rather than writing a
70627
+ * second one."
70626
70628
  * retention="none — the blobs sit in the 'retrain/' subtree of the artifacts class
70627
70629
  * (D781), which no owner cascade and no age sweep walks, which is exactly what makes a 'trained' track evictable
70628
70630
  * again (D81). Rows go only when the operator deselects the frame (deleteFrame,
@@ -70737,6 +70739,19 @@ var RETRAIN_FRAME_COLUMNS = [
70737
70739
  {
70738
70740
  name: "complete",
70739
70741
  type: "BOOLEAN"
70742
+ }),
70743
+ (
70744
+ /** `recording` for a still taken from the recording at an instant (no
70745
+ * track, no source media row). NULL = a track copy. */
70746
+ {
70747
+ name: "origin",
70748
+ type: "TEXT"
70749
+ }),
70750
+ (
70751
+ /** The recording instant a `recording` still was asked for. */
70752
+ {
70753
+ name: "epochMs",
70754
+ type: "INTEGER"
70740
70755
  })
70741
70756
  ];
70742
70757
  var RETRAIN_FRAME_INDEXES = [
@@ -70762,6 +70777,8 @@ var RETRAIN_FRAME_INDEXES = [
70762
70777
  columns: ["locationId"]
70763
70778
  })
70764
70779
  ];
70780
+ /** Ids per `IN (…)` page in {@link RetrainFrameStore.getMany}. */
70781
+ var GET_MANY_PAGE = 500;
70765
70782
  /**
70766
70783
  * The copy's id, derived from `(trackId, sourceMediaId)`.
70767
70784
  *
@@ -70774,8 +70791,20 @@ var RETRAIN_FRAME_INDEXES = [
70774
70791
  function retrainFrameId(trackId, sourceMediaId) {
70775
70792
  return `retrain:${trackId}:${(0, node_crypto.createHash)("sha1").update(String(sourceMediaId)).digest("hex").slice(0, 16)}`;
70776
70793
  }
70777
- function framePath(deviceId, trackId, frameId) {
70778
- return `${RETRAIN_PATH_PREFIX}/${deviceId}/${trackId}/${frameId.slice(frameId.lastIndexOf(":") + 1)}.jpg`;
70794
+ /**
70795
+ * A recording still's id, derived from `(deviceId, epochMs)` — the same moment
70796
+ * of the same camera is one copy, however many times it is added.
70797
+ */
70798
+ function recordingFrameId(deviceId, epochMs) {
70799
+ const digest = (0, node_crypto.createHash)("sha1").update(String(epochMs)).digest("hex").slice(0, 16);
70800
+ return `retrain:rec:${String(deviceId)}:${digest}`;
70801
+ }
70802
+ /** The directory a recording still sits in, beside the track directories. */
70803
+ var RECORDING_STILL_DIR = "recording";
70804
+ function framePath(frame) {
70805
+ const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
70806
+ const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
70807
+ return `${RETRAIN_PATH_PREFIX}/${frame.deviceId}/${dir}/${leaf}.jpg`;
70779
70808
  }
70780
70809
  /** A stored number, or `undefined` for NULL / absent / junk. */
70781
70810
  function finiteNumber(value) {
@@ -70820,9 +70849,11 @@ var RetrainFrameStore = class {
70820
70849
  });
70821
70850
  }
70822
70851
  rowToFrame(id, data) {
70852
+ const origin = data["origin"] === "recording" ? "recording" : "track";
70823
70853
  return {
70824
70854
  frameId: id,
70825
70855
  deviceId: Number(data["deviceId"] ?? 0),
70856
+ origin,
70826
70857
  trackId: String(data["trackId"] ?? ""),
70827
70858
  sourceMediaId: Number(data["sourceMediaId"] ?? 0),
70828
70859
  sourceKind: String(data["sourceKind"] ?? "keyFrame"),
@@ -70833,9 +70864,54 @@ var RetrainFrameStore = class {
70833
70864
  locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null,
70834
70865
  ...finiteNumber(data["capturedAt"]) !== void 0 ? { capturedAt: finiteNumber(data["capturedAt"]) } : {},
70835
70866
  ...finiteNumber(data["chromaSpread"]) !== void 0 ? { chromaSpread: finiteNumber(data["chromaSpread"]) } : {},
70836
- complete: data["complete"] === true || data["complete"] === 1
70867
+ complete: data["complete"] === true || data["complete"] === 1,
70868
+ ...finiteNumber(data["epochMs"]) !== void 0 ? { epochMs: finiteNumber(data["epochMs"]) } : {}
70837
70869
  };
70838
70870
  }
70871
+ /** The copies with these ids, in the order given; unknown ids are skipped. */
70872
+ async getMany(frameIds) {
70873
+ if (frameIds.length === 0) return [];
70874
+ try {
70875
+ const byId = /* @__PURE__ */ new Map();
70876
+ for (let i = 0; i < frameIds.length; i += GET_MANY_PAGE) {
70877
+ const page = frameIds.slice(i, i + GET_MANY_PAGE);
70878
+ const rows = await this.store.query.query({
70879
+ collection: RETRAIN_FRAMES_COLLECTION,
70880
+ filter: {
70881
+ whereIn: { id: [...page] },
70882
+ limit: page.length
70883
+ }
70884
+ });
70885
+ for (const r of rows) if (typeof r.id === "string") byId.set(r.id, this.rowToFrame(r.id, r.data));
70886
+ }
70887
+ return frameIds.flatMap((id) => {
70888
+ const frame = byId.get(id);
70889
+ return frame !== void 0 ? [frame] : [];
70890
+ });
70891
+ } catch (err) {
70892
+ this.logger.warn("RetrainFrameStore.getMany failed", { meta: {
70893
+ frames: frameIds.length,
70894
+ error: String(err)
70895
+ } });
70896
+ return [];
70897
+ }
70898
+ }
70899
+ /** Every camera the dataset holds a copy of — a recording still can be the
70900
+ * only thing a camera contributes, with no track anywhere. */
70901
+ async listDeviceIds() {
70902
+ try {
70903
+ const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
70904
+ const ids = /* @__PURE__ */ new Set();
70905
+ for (const r of rows) {
70906
+ const id = Number(r.data["deviceId"]);
70907
+ if (Number.isInteger(id) && id > 0) ids.add(id);
70908
+ }
70909
+ return [...ids].toSorted((a, b) => a - b);
70910
+ } catch (err) {
70911
+ this.logger.warn("RetrainFrameStore.listDeviceIds failed", { meta: { error: String(err) } });
70912
+ return [];
70913
+ }
70914
+ }
70839
70915
  /** The copy with this id, or `null`. Index read only — no blob is touched. */
70840
70916
  async get(frameId) {
70841
70917
  try {
@@ -70852,8 +70928,10 @@ var RetrainFrameStore = class {
70852
70928
  return null;
70853
70929
  }
70854
70930
  }
70855
- /** Every copy taken from this track, oldest first. */
70931
+ /** Every copy taken from this track, oldest first. A recording still has
70932
+ * no track and is never answered here. */
70856
70933
  async listByTrack(trackId) {
70934
+ if (trackId === "") return [];
70857
70935
  try {
70858
70936
  return (await this.store.query.query({
70859
70937
  collection: RETRAIN_FRAMES_COLLECTION,
@@ -70927,7 +71005,7 @@ var RetrainFrameStore = class {
70927
71005
  try {
70928
71006
  return await this.storage.read({
70929
71007
  location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
70930
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
71008
+ relativePath: framePath(frame)
70931
71009
  });
70932
71010
  } catch (err) {
70933
71011
  this.logger.warn("retrain frame copy unreadable", {
@@ -70996,12 +71074,17 @@ var RetrainFrameStore = class {
70996
71074
  reason: "unreadable-image"
70997
71075
  };
70998
71076
  }
70999
- const path = framePath(input.deviceId, input.trackId, frameId);
71077
+ const path = framePath({
71078
+ ...input,
71079
+ frameId,
71080
+ origin: "track"
71081
+ });
71000
71082
  const locationId = RETRAIN_MEDIA_LOCATION;
71001
71083
  const chromaSpread = await this.chromaOf(bytes, input.deviceId);
71002
71084
  const frame = {
71003
71085
  frameId,
71004
71086
  deviceId: input.deviceId,
71087
+ origin: "track",
71005
71088
  trackId: input.trackId,
71006
71089
  sourceMediaId: input.sourceMediaId,
71007
71090
  sourceKind: input.sourceKind,
@@ -71076,6 +71159,115 @@ var RetrainFrameStore = class {
71076
71159
  };
71077
71160
  }
71078
71161
  /**
71162
+ * Store a still decoded from the RECORDING at `epochMs` — a frame with no
71163
+ * track and no media row, usually the one the model saw nothing in.
71164
+ *
71165
+ * Same contract as {@link copyFrame}: an existing copy of that moment is
71166
+ * returned untouched; the blob lands before the row; a refusal is named.
71167
+ * `capturedAt` IS the instant — the camera saw this frame then.
71168
+ */
71169
+ async storeRecordingStill(input) {
71170
+ const frameId = recordingFrameId(input.deviceId, input.epochMs);
71171
+ const existing = await this.get(frameId);
71172
+ if (existing !== null) return {
71173
+ kind: "copied",
71174
+ frame: existing,
71175
+ reused: true
71176
+ };
71177
+ const size = readJpegSize(input.bytes);
71178
+ if (size === null) {
71179
+ this.logger.warn("retrain recording still refused — not a readable JPEG", {
71180
+ tags: { deviceId: input.deviceId },
71181
+ meta: {
71182
+ epochMs: input.epochMs,
71183
+ bytes: input.bytes.length
71184
+ }
71185
+ });
71186
+ return {
71187
+ kind: "refused",
71188
+ reason: "unreadable-image"
71189
+ };
71190
+ }
71191
+ const chromaSpread = await this.chromaOf(input.bytes, input.deviceId);
71192
+ const frame = {
71193
+ frameId,
71194
+ deviceId: input.deviceId,
71195
+ origin: "recording",
71196
+ trackId: "",
71197
+ sourceMediaId: 0,
71198
+ sourceKind: "fullFrame",
71199
+ sizeBytes: input.bytes.length,
71200
+ width: size.width,
71201
+ height: size.height,
71202
+ copiedAt: this.now(),
71203
+ locationId: RETRAIN_MEDIA_LOCATION,
71204
+ capturedAt: input.epochMs,
71205
+ ...chromaSpread !== void 0 ? { chromaSpread } : {},
71206
+ complete: false,
71207
+ epochMs: input.epochMs
71208
+ };
71209
+ const path = framePath(frame);
71210
+ const persist = async () => {
71211
+ await this.storage.write({
71212
+ location: RETRAIN_MEDIA_LOCATION,
71213
+ relativePath: path,
71214
+ data: input.bytes
71215
+ });
71216
+ await this.store.set.mutate({
71217
+ collection: RETRAIN_FRAMES_COLLECTION,
71218
+ key: frameId,
71219
+ value: {
71220
+ deviceId: frame.deviceId,
71221
+ trackId: "",
71222
+ sourceMediaId: 0,
71223
+ sourceKind: frame.sourceKind,
71224
+ path,
71225
+ sizeBytes: frame.sizeBytes,
71226
+ width: frame.width,
71227
+ height: frame.height,
71228
+ copiedAt: frame.copiedAt,
71229
+ locationId: RETRAIN_MEDIA_LOCATION,
71230
+ capturedAt: input.epochMs,
71231
+ chromaSpread: frame.chromaSpread ?? null,
71232
+ complete: false,
71233
+ origin: "recording",
71234
+ epochMs: input.epochMs
71235
+ }
71236
+ });
71237
+ };
71238
+ try {
71239
+ if (this.writeGate) await this.writeGate.run(persist);
71240
+ else await persist();
71241
+ } catch (err) {
71242
+ this.logger.error("retrain recording still failed to persist", {
71243
+ tags: { deviceId: input.deviceId },
71244
+ meta: {
71245
+ frameId,
71246
+ epochMs: input.epochMs,
71247
+ error: String(err)
71248
+ }
71249
+ });
71250
+ return {
71251
+ kind: "refused",
71252
+ reason: "write-failed"
71253
+ };
71254
+ }
71255
+ this.logger.info("retrain recording still stored in the dataset", {
71256
+ tags: { deviceId: input.deviceId },
71257
+ meta: {
71258
+ frameId,
71259
+ epochMs: input.epochMs,
71260
+ width: frame.width,
71261
+ height: frame.height
71262
+ }
71263
+ });
71264
+ return {
71265
+ kind: "copied",
71266
+ frame,
71267
+ reused: false
71268
+ };
71269
+ }
71270
+ /**
71079
71271
  * The operator's "every subject in this frame is boxed" (D785 context: an
71080
71272
  * unboxed subject is a false positive to a detector evaluation). Only the
71081
71273
  * operator sets it; nothing derives it from the annotation count, because a
@@ -71108,7 +71300,7 @@ var RetrainFrameStore = class {
71108
71300
  try {
71109
71301
  await this.storage.delete({
71110
71302
  location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
71111
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
71303
+ relativePath: framePath(frame)
71112
71304
  });
71113
71305
  } catch {}
71114
71306
  try {
@@ -71155,7 +71347,7 @@ var RetrainFrameStore = class {
71155
71347
  if (rowIsDurableRetrainCopy(data)) continue;
71156
71348
  const frame = this.rowToFrame(row.id, data);
71157
71349
  const from = frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION;
71158
- const relativePath = framePath(frame.deviceId, frame.trackId, frame.frameId);
71350
+ const relativePath = framePath(frame);
71159
71351
  let bytes;
71160
71352
  try {
71161
71353
  bytes = await this.storage.read({
@@ -74826,7 +75018,7 @@ function describeTapeStatus(input) {
74826
75018
  * `viewer-settings-legacy-adoption.ts`.
74827
75019
  */
74828
75020
  var VIEWER_SETTINGS_SNAPSHOTS_COLLECTION = "pipeline-analytics:viewer-settings-snapshots";
74829
- var COLUMNS = [
75021
+ var COLUMNS$1 = [
74830
75022
  {
74831
75023
  name: "id",
74832
75024
  type: "TEXT",
@@ -74897,7 +75089,7 @@ function requireCaller(caller) {
74897
75089
  if (!caller) throw new Error("viewerSettings: authenticated caller required");
74898
75090
  return caller;
74899
75091
  }
74900
- function asRecord(data) {
75092
+ function asRecord$1(data) {
74901
75093
  return typeof data === "object" && data !== null ? data : {};
74902
75094
  }
74903
75095
  function toSummary(id, data, callerUserId) {
@@ -74923,7 +75115,7 @@ var ViewerSettingsSnapshotStore = class {
74923
75115
  static async declare(store) {
74924
75116
  await store.declareCollection.mutate({
74925
75117
  collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
74926
- columns: [...COLUMNS],
75118
+ columns: [...COLUMNS$1],
74927
75119
  indexes: [{
74928
75120
  name: "idx_pa_viewer_settings_owner",
74929
75121
  columns: ["ownerUserId"]
@@ -74943,7 +75135,7 @@ var ViewerSettingsSnapshotStore = class {
74943
75135
  });
74944
75136
  const out = [];
74945
75137
  for (const row of rows) {
74946
- const summary = toSummary(row.id, asRecord(row.data), caller.userId);
75138
+ const summary = toSummary(row.id, asRecord$1(row.data), caller.userId);
74947
75139
  if (summary) out.push(summary);
74948
75140
  }
74949
75141
  return out;
@@ -74957,7 +75149,7 @@ var ViewerSettingsSnapshotStore = class {
74957
75149
  }
74958
75150
  }))[0];
74959
75151
  if (!row) return null;
74960
- const data = asRecord(row.data);
75152
+ const data = asRecord$1(row.data);
74961
75153
  const summary = toSummary(row.id, data, caller.userId);
74962
75154
  if (!summary) return null;
74963
75155
  const blob = BlobSchema.safeParse(data.blob);
@@ -74980,7 +75172,7 @@ var ViewerSettingsSnapshotStore = class {
74980
75172
  }
74981
75173
  }))[0];
74982
75174
  const id = prior?.id ?? (0, node_crypto.randomUUID)();
74983
- const createdAt = typeof asRecord(prior?.data).createdAt === "number" ? asRecord(prior?.data).createdAt : now;
75175
+ const createdAt = typeof asRecord$1(prior?.data).createdAt === "number" ? asRecord$1(prior?.data).createdAt : now;
74984
75176
  await this.store.set.mutate({
74985
75177
  collection: VIEWER_SETTINGS_SNAPSHOTS_COLLECTION,
74986
75178
  key: id,
@@ -82322,6 +82514,17 @@ var RETRAIN_ANNOTATION_INDEXES = [
82322
82514
  columns: ["deviceId", "createdAt"]
82323
82515
  }
82324
82516
  ];
82517
+ var ANNOTATION_SOURCES = [
82518
+ "operator",
82519
+ "assist",
82520
+ "pipeline",
82521
+ "llm"
82522
+ ];
82523
+ /** The stored source, narrowed. Unknown reads as `operator` — the least
82524
+ * presumptuous claim a row can make about who drew it. */
82525
+ function narrowSource(value) {
82526
+ return ANNOTATION_SOURCES.find((source) => source === value) ?? "operator";
82527
+ }
82325
82528
  function optionalString(value) {
82326
82529
  return typeof value === "string" && value.length > 0 ? value : void 0;
82327
82530
  }
@@ -82329,6 +82532,8 @@ function optionalString(value) {
82329
82532
  function narrowMacroClass(value) {
82330
82533
  return typeof value === "string" && isRetrainMacroClass(value) ? value : "person";
82331
82534
  }
82535
+ /** Frame ids per `IN (…)` page in {@link RetrainAnnotationStore.listByFrames}. */
82536
+ var LIST_BY_FRAMES_PAGE = 500;
82332
82537
  var RetrainAnnotationStore = class {
82333
82538
  store;
82334
82539
  logger;
@@ -82365,7 +82570,7 @@ var RetrainAnnotationStore = class {
82365
82570
  label: optionalString(data["label"]),
82366
82571
  subLabel: optionalString(data["subLabel"]),
82367
82572
  kind: data["kind"] === "model_error" ? "model_error" : "subject",
82368
- source: data["source"] === "assist" ? "assist" : "operator",
82573
+ source: narrowSource(data["source"]),
82369
82574
  assistModelId: optionalString(data["assistModelId"]),
82370
82575
  assistScore: typeof score === "number" ? score : void 0,
82371
82576
  exportedInBatch: optionalString(data["exportedInBatch"]),
@@ -82434,6 +82639,32 @@ var RetrainAnnotationStore = class {
82434
82639
  async listByFrame(mediaKey) {
82435
82640
  return this.queryWhere({ mediaKey }, `frame ${mediaKey}`);
82436
82641
  }
82642
+ /** Every annotation of these frames, oldest first — paged `IN (…)`. */
82643
+ async listByFrames(mediaKeys) {
82644
+ const out = [];
82645
+ for (let i = 0; i < mediaKeys.length; i += LIST_BY_FRAMES_PAGE) {
82646
+ const page = mediaKeys.slice(i, i + LIST_BY_FRAMES_PAGE);
82647
+ try {
82648
+ const rows = await this.store.query.query({
82649
+ collection: RETRAIN_ANNOTATIONS_COLLECTION,
82650
+ filter: {
82651
+ whereIn: { mediaKey: [...page] },
82652
+ orderBy: {
82653
+ field: "createdAt",
82654
+ direction: "asc"
82655
+ }
82656
+ }
82657
+ });
82658
+ for (const r of rows) if (typeof r.id === "string") out.push(this.rowToAnnotation(r.id, r.data));
82659
+ } catch (err) {
82660
+ this.logger.warn("RetrainAnnotationStore.listByFrames failed", { meta: {
82661
+ frames: page.length,
82662
+ error: String(err)
82663
+ } });
82664
+ }
82665
+ }
82666
+ return out;
82667
+ }
82437
82668
  async listByTrack(trackId) {
82438
82669
  return this.queryWhere({ trackId }, `track ${trackId}`);
82439
82670
  }
@@ -82547,13 +82778,41 @@ var RetrainAnnotationStore = class {
82547
82778
  //#endregion
82548
82779
  //#region src/pipeline-analytics/retrain/retrain-collection-store.ts
82549
82780
  /**
82781
+ * RetrainCollectionStore — named sets over the one retrain corpus.
82782
+ *
82783
+ * Collections: `pipeline-analytics:retrain-collections` (the names) and
82784
+ * `pipeline-analytics:retrain-collection-frames` (the membership).
82785
+ *
82786
+ * A collection OWNS NO PIXELS. Every frame — copied from a track or stilled
82787
+ * from the recording — lives once in `retrain-frames`, with its annotations,
82788
+ * its completeness and its export path; a collection is a name the operator
82789
+ * gives to a subset of it ("packages porch night", "false-positive cars"). So
82790
+ * a frame can be in several collections, a collection's count is its
82791
+ * membership, and deleting a collection removes memberships, never a frame
82792
+ * another collection or a track still holds. Which trackless frames are left
82793
+ * with no reason to exist is the caller's question
82794
+ * (`retrain-collection-service.ts`).
82795
+ */
82796
+ /**
82550
82797
  * @durable class=ledger owner=pipeline-analytics
82551
82798
  * write="one row per named training collection the operator creates. Names are
82552
- * unique on nameKey (lower(trim)); empty names never become rows."
82553
- * retention="none — operator-owned corpus metadata. Rows leave only when an
82554
- * explicit delete API lands (not V1)."
82799
+ * unique on nameKey (lower(trim)); empty names never become rows. A rename
82800
+ * rewrites name + nameKey under the same id."
82801
+ * retention="none — operator-owned corpus metadata. A row leaves only when the
82802
+ * operator deletes the collection (deleteRetrainCollection), which removes its
82803
+ * membership rows first."
82555
82804
  */
82556
82805
  var RETRAIN_COLLECTIONS_COLLECTION = "pipeline-analytics:retrain-collections";
82806
+ /**
82807
+ * @durable class=ledger owner=pipeline-analytics
82808
+ * write="one row per (collection, frame) pair the operator adds — a frame added
82809
+ * from the recording, or an existing retrain frame added to a collection. Keyed
82810
+ * by a hash of the pair, so adding twice is one row."
82811
+ * retention="none — rows leave when the operator removes the frame from the
82812
+ * collection, deletes the collection, or deselects the frame itself. Bounded by
82813
+ * operator curation."
82814
+ */
82815
+ var RETRAIN_COLLECTION_FRAMES_COLLECTION = "pipeline-analytics:retrain-collection-frames";
82557
82816
  var RETRAIN_COLLECTION_COLUMNS = [
82558
82817
  {
82559
82818
  name: "id",
@@ -82590,6 +82849,41 @@ var RETRAIN_COLLECTION_INDEXES = [{
82590
82849
  name: "idx_retrain_collections_created",
82591
82850
  columns: ["createdAt"]
82592
82851
  }];
82852
+ var RETRAIN_COLLECTION_FRAME_COLUMNS = [
82853
+ {
82854
+ name: "id",
82855
+ type: "TEXT",
82856
+ primaryKey: true,
82857
+ notNull: true
82858
+ },
82859
+ {
82860
+ name: "collectionId",
82861
+ type: "TEXT",
82862
+ notNull: true
82863
+ },
82864
+ {
82865
+ name: "frameId",
82866
+ type: "TEXT",
82867
+ notNull: true
82868
+ },
82869
+ {
82870
+ name: "deviceId",
82871
+ type: "INTEGER",
82872
+ notNull: true
82873
+ },
82874
+ {
82875
+ name: "addedAt",
82876
+ type: "INTEGER",
82877
+ notNull: true
82878
+ }
82879
+ ];
82880
+ var RETRAIN_COLLECTION_FRAME_INDEXES = [{
82881
+ name: "idx_retrain_coll_frames_collection",
82882
+ columns: ["collectionId", "addedAt"]
82883
+ }, {
82884
+ name: "idx_retrain_coll_frames_frame",
82885
+ columns: ["frameId"]
82886
+ }];
82593
82887
  function normalizeCollectionName(raw) {
82594
82888
  const name = raw.trim();
82595
82889
  if (name.length === 0) return null;
@@ -82598,18 +82892,22 @@ function normalizeCollectionName(raw) {
82598
82892
  nameKey: name.toLowerCase()
82599
82893
  };
82600
82894
  }
82895
+ /** The membership row's key — one row per pair, whatever the ids contain. */
82896
+ function membershipKey(collectionId, frameId) {
82897
+ return (0, node_crypto.createHash)("sha1").update(`${collectionId}\u0000${frameId}`).digest("hex");
82898
+ }
82899
+ /** Ids per `IN (…)` page. */
82900
+ var IN_PAGE = 500;
82601
82901
  var RetrainCollectionStore = class {
82602
82902
  store;
82603
82903
  logger;
82604
82904
  now;
82605
82905
  newId;
82606
- countFrames;
82607
82906
  constructor(deps) {
82608
82907
  this.store = deps.store;
82609
82908
  this.logger = deps.logger;
82610
82909
  this.now = deps.now;
82611
82910
  this.newId = deps.newId;
82612
- this.countFrames = deps.countFrames;
82613
82911
  }
82614
82912
  static async declare(store) {
82615
82913
  await store.declareCollection.mutate({
@@ -82617,6 +82915,11 @@ var RetrainCollectionStore = class {
82617
82915
  columns: [...RETRAIN_COLLECTION_COLUMNS],
82618
82916
  indexes: [...RETRAIN_COLLECTION_INDEXES]
82619
82917
  });
82918
+ await store.declareCollection.mutate({
82919
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82920
+ columns: [...RETRAIN_COLLECTION_FRAME_COLUMNS],
82921
+ indexes: [...RETRAIN_COLLECTION_FRAME_INDEXES]
82922
+ });
82620
82923
  }
82621
82924
  async create(input) {
82622
82925
  const normalized = normalizeCollectionName(input.name);
@@ -82624,7 +82927,7 @@ var RetrainCollectionStore = class {
82624
82927
  kind: "refused",
82625
82928
  reason: "empty-name"
82626
82929
  };
82627
- if (await this.findByNameKey(normalized.nameKey) !== null) return {
82930
+ if (await this.findIdByNameKey(normalized.nameKey) !== null) return {
82628
82931
  kind: "refused",
82629
82932
  reason: "duplicate-name"
82630
82933
  };
@@ -82640,6 +82943,7 @@ var RetrainCollectionStore = class {
82640
82943
  updatedAt: createdAt
82641
82944
  }
82642
82945
  });
82946
+ this.logger.info("retrain collection created", { meta: { collectionId: id } });
82643
82947
  return {
82644
82948
  kind: "created",
82645
82949
  collection: {
@@ -82650,6 +82954,7 @@ var RetrainCollectionStore = class {
82650
82954
  }
82651
82955
  };
82652
82956
  }
82957
+ /** Every collection, newest first, each with its membership count (one query). */
82653
82958
  async list() {
82654
82959
  let rows;
82655
82960
  try {
@@ -82664,34 +82969,161 @@ var RetrainCollectionStore = class {
82664
82969
  this.logger.warn("retrain collections list failed", { meta: { error: String(err) } });
82665
82970
  return [];
82666
82971
  }
82667
- const out = [];
82668
- for (const row of rows) {
82669
- const name = typeof row.data["name"] === "string" ? row.data["name"] : "";
82670
- const createdAt = Number(row.data["createdAt"] ?? 0);
82671
- const frameCount = this.countFrames !== void 0 ? await this.countFrames(row.id) : 0;
82672
- out.push({
82673
- id: row.id,
82674
- name,
82675
- createdAt,
82676
- frameCount
82677
- });
82972
+ const counts = await this.countMembers(rows.map((r) => r.id));
82973
+ return rows.map((row) => this.toCollection(row.id, row.data, counts.get(row.id) ?? 0));
82974
+ }
82975
+ async getById(id) {
82976
+ const data = await this.readRow(id);
82977
+ if (data === null) return null;
82978
+ const counts = await this.countMembers([id]);
82979
+ return this.toCollection(id, data, counts.get(id) ?? 0);
82980
+ }
82981
+ async rename(input) {
82982
+ const normalized = normalizeCollectionName(input.name);
82983
+ if (normalized === null) return {
82984
+ kind: "refused",
82985
+ reason: "empty-name"
82986
+ };
82987
+ const current = await this.readRow(input.id);
82988
+ if (current === null) return {
82989
+ kind: "refused",
82990
+ reason: "unknown-collection"
82991
+ };
82992
+ const holder = await this.findIdByNameKey(normalized.nameKey);
82993
+ if (holder !== null && holder !== input.id) return {
82994
+ kind: "refused",
82995
+ reason: "duplicate-name"
82996
+ };
82997
+ await this.store.update.mutate({
82998
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
82999
+ id: input.id,
83000
+ data: {
83001
+ name: normalized.name,
83002
+ nameKey: normalized.nameKey,
83003
+ updatedAt: this.now()
83004
+ }
83005
+ });
83006
+ this.logger.info("retrain collection renamed", { meta: { collectionId: input.id } });
83007
+ const counts = await this.countMembers([input.id]);
83008
+ return {
83009
+ kind: "renamed",
83010
+ collection: this.toCollection(input.id, {
83011
+ ...current,
83012
+ name: normalized.name
83013
+ }, counts.get(input.id) ?? 0)
83014
+ };
83015
+ }
83016
+ /** Remove the collection and its memberships — members first, so a crash
83017
+ * never leaves memberships pointing at a name that no longer exists. */
83018
+ async delete(id) {
83019
+ if (await this.readRow(id) === null) return {
83020
+ deleted: false,
83021
+ memberFrameIds: []
83022
+ };
83023
+ const members = await this.store.query.query({
83024
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83025
+ filter: { where: { collectionId: id } }
83026
+ });
83027
+ for (const m of members) await this.store.delete.mutate({
83028
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83029
+ key: m.id
83030
+ });
83031
+ await this.store.delete.mutate({
83032
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
83033
+ key: id
83034
+ });
83035
+ this.logger.info("retrain collection deleted", { meta: {
83036
+ collectionId: id,
83037
+ members: members.length
83038
+ } });
83039
+ return {
83040
+ deleted: true,
83041
+ memberFrameIds: members.map((m) => String(m.data["frameId"] ?? ""))
83042
+ };
83043
+ }
83044
+ /** Add a frame. `false` when it was already a member (nothing written). */
83045
+ async addFrame(input) {
83046
+ const key = membershipKey(input.collectionId, input.frameId);
83047
+ const existing = await this.store.get.query({
83048
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83049
+ key
83050
+ });
83051
+ if (existing !== null && existing !== void 0) return false;
83052
+ await this.store.set.mutate({
83053
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83054
+ key,
83055
+ value: {
83056
+ collectionId: input.collectionId,
83057
+ frameId: input.frameId,
83058
+ deviceId: input.deviceId,
83059
+ addedAt: this.now()
83060
+ }
83061
+ });
83062
+ return true;
83063
+ }
83064
+ /** Remove one membership. `false` when it did not exist. */
83065
+ async removeFrame(input) {
83066
+ const key = membershipKey(input.collectionId, input.frameId);
83067
+ const existing = await this.store.get.query({
83068
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83069
+ key
83070
+ });
83071
+ if (existing === null || existing === void 0) return false;
83072
+ await this.store.delete.mutate({
83073
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83074
+ key
83075
+ });
83076
+ return true;
83077
+ }
83078
+ /** Remove a frame from EVERY collection — the frame itself is leaving. */
83079
+ async forgetFrame(frameId) {
83080
+ const rows = await this.membershipRows({ frameId });
83081
+ for (const r of rows) await this.store.delete.mutate({
83082
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83083
+ key: r.id
83084
+ });
83085
+ return rows.length;
83086
+ }
83087
+ /** The frames of a collection, in the order they were added. */
83088
+ async listFrameIds(collectionId) {
83089
+ return (await this.membershipRows({ collectionId })).toSorted((a, b) => Number(a.data["addedAt"] ?? 0) - Number(b.data["addedAt"] ?? 0)).map((r) => String(r.data["frameId"] ?? "")).filter((id) => id !== "");
83090
+ }
83091
+ /** frameId → the collections it belongs to. Frames in none are absent. */
83092
+ async collectionsOf(frameIds) {
83093
+ const out = /* @__PURE__ */ new Map();
83094
+ for (const row of await this.membershipRowsIn("frameId", frameIds)) {
83095
+ const frameId = String(row.data["frameId"] ?? "");
83096
+ const collectionId = String(row.data["collectionId"] ?? "");
83097
+ if (frameId === "" || collectionId === "") continue;
83098
+ out.set(frameId, [...out.get(frameId) ?? [], collectionId]);
82678
83099
  }
83100
+ for (const [frameId, ids] of out) out.set(frameId, ids.toSorted());
82679
83101
  return out;
82680
83102
  }
82681
- async getById(id) {
83103
+ /** The frame ids of several collections, de-duplicated. */
83104
+ async frameIdsOf(collectionIds) {
83105
+ const ids = /* @__PURE__ */ new Set();
83106
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83107
+ const frameId = String(row.data["frameId"] ?? "");
83108
+ if (frameId !== "") ids.add(frameId);
83109
+ }
83110
+ return [...ids];
83111
+ }
83112
+ toCollection(id, data, frameCount) {
83113
+ return {
83114
+ id,
83115
+ name: typeof data["name"] === "string" ? data["name"] : "",
83116
+ createdAt: Number(data["createdAt"] ?? 0),
83117
+ frameCount
83118
+ };
83119
+ }
83120
+ async readRow(id) {
82682
83121
  try {
82683
83122
  const row = await this.store.get.query({
82684
83123
  collection: RETRAIN_COLLECTIONS_COLLECTION,
82685
83124
  key: id
82686
83125
  });
82687
- if (row === null || row === void 0) return null;
82688
- const data = row;
82689
- return {
82690
- id,
82691
- name: typeof data["name"] === "string" ? data["name"] : "",
82692
- createdAt: Number(data["createdAt"] ?? 0),
82693
- frameCount: this.countFrames !== void 0 ? await this.countFrames(id) : 0
82694
- };
83126
+ return row === null || row === void 0 ? null : { ...row };
82695
83127
  } catch (err) {
82696
83128
  this.logger.warn("retrain collection get failed", { meta: {
82697
83129
  collectionId: id,
@@ -82700,14 +83132,125 @@ var RetrainCollectionStore = class {
82700
83132
  return null;
82701
83133
  }
82702
83134
  }
83135
+ async countMembers(collectionIds) {
83136
+ const counts = /* @__PURE__ */ new Map();
83137
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83138
+ const id = String(row.data["collectionId"] ?? "");
83139
+ counts.set(id, (counts.get(id) ?? 0) + 1);
83140
+ }
83141
+ return counts;
83142
+ }
83143
+ async membershipRows(where) {
83144
+ try {
83145
+ return await this.store.query.query({
83146
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83147
+ filter: { where }
83148
+ });
83149
+ } catch (err) {
83150
+ this.logger.warn("retrain collection membership read failed", { meta: {
83151
+ ...where,
83152
+ error: String(err)
83153
+ } });
83154
+ return [];
83155
+ }
83156
+ }
83157
+ async membershipRowsIn(field, values) {
83158
+ const out = [];
83159
+ for (let i = 0; i < values.length; i += IN_PAGE) {
83160
+ const page = values.slice(i, i + IN_PAGE);
83161
+ try {
83162
+ out.push(...await this.store.query.query({
83163
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83164
+ filter: { whereIn: { [field]: [...page] } }
83165
+ }));
83166
+ } catch (err) {
83167
+ this.logger.warn("retrain collection membership read failed", { meta: {
83168
+ field,
83169
+ values: page.length,
83170
+ error: String(err)
83171
+ } });
83172
+ }
83173
+ }
83174
+ return out;
83175
+ }
83176
+ async findIdByNameKey(nameKey) {
83177
+ try {
83178
+ return (await this.store.query.query({
83179
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
83180
+ filter: {
83181
+ where: { nameKey },
83182
+ limit: 1
83183
+ }
83184
+ }))[0]?.id ?? null;
83185
+ } catch (err) {
83186
+ this.logger.warn("retrain collection findByNameKey failed", { meta: {
83187
+ nameKey,
83188
+ error: String(err)
83189
+ } });
83190
+ return null;
83191
+ }
83192
+ }
83193
+ };
83194
+ //#endregion
83195
+ //#region src/pipeline-analytics/retrain/retrain-collection-service.ts
83196
+ /** What each still outcome tells the operator. */
83197
+ var STILL_REFUSAL = {
83198
+ "no-recording": {
83199
+ reason: "still-unavailable",
83200
+ detail: "this camera is not recording, so there is no footage to take a frame from"
83201
+ },
83202
+ "no-footage": {
83203
+ reason: "still-unavailable",
83204
+ detail: "no recorded footage covers that instant (a gap, or already pruned)"
83205
+ },
83206
+ "decode-failed": {
83207
+ reason: "still-unavailable",
83208
+ detail: "the footage is there but the frame could not be decoded"
83209
+ },
83210
+ busy: {
83211
+ reason: "still-busy",
83212
+ detail: "the recorder is already decoding other frames — try again in a moment"
83213
+ }
83214
+ };
83215
+ var RetrainCollectionService = class {
83216
+ deps;
83217
+ constructor(deps) {
83218
+ this.deps = deps;
83219
+ }
83220
+ create(input) {
83221
+ return this.deps.collections.create(input);
83222
+ }
83223
+ list() {
83224
+ return this.deps.collections.list();
83225
+ }
83226
+ rename(input) {
83227
+ return this.deps.collections.rename(input);
83228
+ }
82703
83229
  /**
82704
- * Stub until still ingest lands. Validates the collection exists, then
82705
- * refuses with a named reason so callers can wire UI against a stable shape.
83230
+ * Delete a collection. Its recording stills that no other collection holds
83231
+ * go with it (annotations first); track copies always stay.
82706
83232
  */
82707
- async addRetrainFrameFromRecording(input) {
82708
- if (await this.getById(input.collectionId) === null) {
82709
- this.logger.info("retrain collection addFrame refused: unknown-collection", {
82710
- tags: { deviceId: input.deviceId },
83233
+ async delete(input) {
83234
+ const result = await this.deps.collections.delete(input.id);
83235
+ if (!result.deleted) return {
83236
+ deleted: false,
83237
+ removedFrames: 0
83238
+ };
83239
+ return {
83240
+ deleted: true,
83241
+ removedFrames: await this.dropOrphanStills(result.memberFrameIds)
83242
+ };
83243
+ }
83244
+ /**
83245
+ * Decode the frame at `epochMs` from the camera's recording and add it to
83246
+ * the collection. The moment already in the dataset is reused without a
83247
+ * decode; every refusal is named and writes nothing.
83248
+ */
83249
+ async addFrameFromRecording(input) {
83250
+ const tags = { deviceId: input.deviceId };
83251
+ if (await this.deps.collections.getById(input.collectionId) === null) {
83252
+ this.deps.logger.info("retrain collection add refused: unknown collection", {
83253
+ tags,
82711
83254
  meta: {
82712
83255
  collectionId: input.collectionId,
82713
83256
  epochMs: input.epochMs
@@ -82718,42 +83261,1298 @@ var RetrainCollectionStore = class {
82718
83261
  reason: "unknown-collection"
82719
83262
  };
82720
83263
  }
82721
- this.logger.info("retrain collection addFrame stub: still-capture-unimplemented", {
82722
- tags: { deviceId: input.deviceId },
83264
+ const stored = await this.storeStill(input.deviceId, input.epochMs);
83265
+ if (stored.kind === "refused") {
83266
+ this.deps.logger.info("retrain collection add refused", {
83267
+ tags,
83268
+ meta: {
83269
+ collectionId: input.collectionId,
83270
+ epochMs: input.epochMs,
83271
+ reason: stored.reason
83272
+ }
83273
+ });
83274
+ return stored;
83275
+ }
83276
+ await this.deps.collections.addFrame({
83277
+ collectionId: input.collectionId,
83278
+ frameId: stored.frame.frameId,
83279
+ deviceId: input.deviceId
83280
+ });
83281
+ this.deps.logger.info("retrain frame added to a collection from the recording", {
83282
+ tags,
82723
83283
  meta: {
82724
83284
  collectionId: input.collectionId,
82725
- epochMs: input.epochMs
83285
+ frameId: stored.frame.frameId,
83286
+ epochMs: input.epochMs,
83287
+ reused: stored.reused
82726
83288
  }
82727
83289
  });
83290
+ const [wire] = await this.deps.retrain.toWireFrames([stored.frame]);
83291
+ if (wire === void 0) throw new Error("retrain: a stored still produced no wire frame");
82728
83292
  return {
83293
+ kind: "added",
83294
+ collectionId: input.collectionId,
83295
+ frame: wire,
83296
+ reused: stored.reused
83297
+ };
83298
+ }
83299
+ /** Add frames already in the dataset (track copies or stills) to a collection. */
83300
+ async addFrames(input) {
83301
+ if (await this.deps.collections.getById(input.collectionId) === null) return {
83302
+ added: 0,
83303
+ alreadyMember: 0,
83304
+ unknownFrameIds: [...input.frameIds]
83305
+ };
83306
+ const frames = await this.deps.frames.getMany(input.frameIds);
83307
+ const known = new Set(frames.map((f) => f.frameId));
83308
+ let added = 0;
83309
+ let alreadyMember = 0;
83310
+ for (const frame of frames) if (await this.deps.collections.addFrame({
83311
+ collectionId: input.collectionId,
83312
+ frameId: frame.frameId,
83313
+ deviceId: frame.deviceId
83314
+ })) added += 1;
83315
+ else alreadyMember += 1;
83316
+ return {
83317
+ added,
83318
+ alreadyMember,
83319
+ unknownFrameIds: input.frameIds.filter((id) => !known.has(id))
83320
+ };
83321
+ }
83322
+ /**
83323
+ * Remove a frame from a collection. A recording still left in no collection
83324
+ * is deleted with its annotations; a track copy is never deleted here.
83325
+ */
83326
+ async removeFrame(input) {
83327
+ if (!await this.deps.collections.removeFrame(input)) return {
83328
+ removed: false,
83329
+ deletedFrame: false
83330
+ };
83331
+ return {
83332
+ removed: true,
83333
+ deletedFrame: await this.dropOrphanStills([input.frameId]) > 0
83334
+ };
83335
+ }
83336
+ /** The frames of a collection, as the canvas lists them. */
83337
+ async listFrames(input) {
83338
+ const ids = await this.deps.collections.listFrameIds(input.collectionId);
83339
+ return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
83340
+ }
83341
+ async storeStill(deviceId, epochMs) {
83342
+ const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
83343
+ if (existing !== null) return {
83344
+ kind: "stored",
83345
+ frame: existing,
83346
+ reused: true
83347
+ };
83348
+ const still = await this.deps.captureStill(deviceId, epochMs);
83349
+ if (still.outcome !== "ok" || still.jpeg === void 0) {
83350
+ const refusal = still.outcome === "ok" ? STILL_REFUSAL["decode-failed"] : STILL_REFUSAL[still.outcome];
83351
+ return {
83352
+ kind: "refused",
83353
+ reason: refusal.reason,
83354
+ detail: refusal.detail
83355
+ };
83356
+ }
83357
+ const stored = await this.deps.frames.storeRecordingStill({
83358
+ deviceId,
83359
+ epochMs,
83360
+ bytes: still.jpeg
83361
+ });
83362
+ return stored.kind === "copied" ? {
83363
+ kind: "stored",
83364
+ frame: stored.frame,
83365
+ reused: stored.reused
83366
+ } : {
82729
83367
  kind: "refused",
82730
- reason: "still-capture-unimplemented",
82731
- detail: "Still ingest lands in plan Task 3"
83368
+ reason: stored.reason
82732
83369
  };
82733
83370
  }
82734
- async findByNameKey(nameKey) {
82735
- try {
82736
- const row = (await this.store.query.query({
82737
- collection: RETRAIN_COLLECTIONS_COLLECTION,
82738
- filter: {
82739
- where: { nameKey },
82740
- limit: 1
82741
- }
82742
- }))[0];
82743
- if (row === void 0) return null;
83371
+ /** Delete the recording stills among `frameIds` that no collection holds any more. */
83372
+ async dropOrphanStills(frameIds) {
83373
+ if (frameIds.length === 0) return 0;
83374
+ const memberships = await this.deps.collections.collectionsOf(frameIds);
83375
+ const orphans = (await this.deps.frames.getMany(frameIds)).filter((f) => f.origin === "recording" && (memberships.get(f.frameId)?.length ?? 0) === 0);
83376
+ for (const frame of orphans) await this.deps.retrain.removeFrame(frame);
83377
+ return orphans.length;
83378
+ }
83379
+ };
83380
+ //#endregion
83381
+ //#region src/pipeline-analytics/retrain/retrain-dataset.ts
83382
+ /**
83383
+ * Dataset versions — the counts a version is judged by, and the hash that
83384
+ * freezes what it teaches. Pure.
83385
+ *
83386
+ * Both read the export MANIFEST (`retrain-export.ts`), not the stores: a
83387
+ * version is exactly what an export of its frames would carry, so its counts
83388
+ * and its hash cannot disagree with the archive a trainer receives.
83389
+ */
83390
+ function lightingOf(frame) {
83391
+ if (frame.lighting === null) return "unknown";
83392
+ return frame.lighting.night ? "night" : "day";
83393
+ }
83394
+ /** The counts of a dataset, from the manifest an export of it would carry. */
83395
+ function datasetStats(manifest) {
83396
+ const cells = /* @__PURE__ */ new Map();
83397
+ const cameras = /* @__PURE__ */ new Map();
83398
+ let completeFrames = 0;
83399
+ let subjects = 0;
83400
+ let modelErrors = 0;
83401
+ let hardNegatives = 0;
83402
+ for (const frame of manifest.frames) {
83403
+ const camera = cameras.get(frame.deviceId) ?? {
83404
+ frames: 0,
83405
+ completeFrames: 0
83406
+ };
83407
+ cameras.set(frame.deviceId, {
83408
+ frames: camera.frames + 1,
83409
+ completeFrames: camera.completeFrames + (frame.complete ? 1 : 0)
83410
+ });
83411
+ if (frame.complete) completeFrames += 1;
83412
+ const lighting = lightingOf(frame);
83413
+ let frameSubjects = 0;
83414
+ for (const a of frame.annotations) {
83415
+ if (a.kind === "model_error") {
83416
+ modelErrors += 1;
83417
+ continue;
83418
+ }
83419
+ frameSubjects += 1;
83420
+ const key = `${a.macroClass}|${String(frame.deviceId)}|${lighting}`;
83421
+ const entry = cells.get(key) ?? {
83422
+ cell: {
83423
+ macroClass: a.macroClass,
83424
+ deviceId: frame.deviceId,
83425
+ lighting,
83426
+ subjects: 0,
83427
+ frames: 0
83428
+ },
83429
+ frameIds: /* @__PURE__ */ new Set()
83430
+ };
83431
+ entry.frameIds.add(frame.frameId);
83432
+ cells.set(key, {
83433
+ cell: {
83434
+ ...entry.cell,
83435
+ subjects: entry.cell.subjects + 1,
83436
+ frames: entry.frameIds.size
83437
+ },
83438
+ frameIds: entry.frameIds
83439
+ });
83440
+ }
83441
+ subjects += frameSubjects;
83442
+ if (frame.complete && frameSubjects === 0) hardNegatives += 1;
83443
+ }
83444
+ const frames = manifest.frames.length;
83445
+ return {
83446
+ frames,
83447
+ completeFrames,
83448
+ completeness: frames > 0 ? completeFrames / frames : 0,
83449
+ subjects,
83450
+ modelErrors,
83451
+ hardNegatives,
83452
+ cells: [...cells.values()].map((e) => e.cell).toSorted((a, b) => a.macroClass.localeCompare(b.macroClass) || a.deviceId - b.deviceId || a.lighting.localeCompare(b.lighting)),
83453
+ cameras: [...cameras.entries()].map(([deviceId, c]) => ({
83454
+ deviceId,
83455
+ ...c
83456
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
83457
+ };
83458
+ }
83459
+ /** A coordinate at the precision a box is meaningful to (sub-pixel noise off). */
83460
+ var q = (n) => Math.round(n * 1e4) / 1e4;
83461
+ /**
83462
+ * The sha256 of what the frames TEACH: per frame its completeness and, per
83463
+ * box, the class tiers, the kind and the box. Row ids, timestamps, export
83464
+ * stamps and provenance are left out — they change on a re-save that teaches
83465
+ * the same thing. Order-independent.
83466
+ */
83467
+ function datasetAnnotationHash(frames) {
83468
+ const canonical = frames.map((f) => ({
83469
+ f: f.frameId,
83470
+ c: f.complete,
83471
+ a: f.annotations.map((a) => [
83472
+ a.macroClass,
83473
+ a.label ?? "",
83474
+ a.subLabel ?? "",
83475
+ a.kind,
83476
+ q(a.bbox.x),
83477
+ q(a.bbox.y),
83478
+ q(a.bbox.w),
83479
+ q(a.bbox.h)
83480
+ ].join("|")).toSorted()
83481
+ })).toSorted((a, b) => a.f < b.f ? -1 : a.f > b.f ? 1 : 0);
83482
+ return (0, node_crypto.createHash)("sha256").update(JSON.stringify(canonical)).digest("hex");
83483
+ }
83484
+ /**
83485
+ * The manifest of a version's export, saying which version and whether its
83486
+ * frames still teach what was frozen. Frames of the version missing from the
83487
+ * archive count as empty and incomplete, so their absence changes the hash.
83488
+ */
83489
+ function withDatasetVersion(manifest, version) {
83490
+ const present = new Map(manifest.frames.map((f) => [f.frameId, f]));
83491
+ const annotationHashNow = datasetAnnotationHash(version.frameIds.map((frameId) => present.get(frameId) ?? {
83492
+ frameId,
83493
+ complete: false,
83494
+ annotations: []
83495
+ }));
83496
+ return {
83497
+ ...manifest,
83498
+ datasetVersion: {
83499
+ id: version.id,
83500
+ name: version.name,
83501
+ createdAt: version.createdAt,
83502
+ annotationHash: version.annotationHash,
83503
+ annotationHashNow,
83504
+ matches: annotationHashNow === version.annotationHash,
83505
+ missingFrames: version.frameIds.filter((id) => !present.has(id)).length
83506
+ }
83507
+ };
83508
+ }
83509
+ //#endregion
83510
+ //#region src/pipeline-analytics/retrain/retrain-geometry.ts
83511
+ function clamp01(v) {
83512
+ return v < 0 ? 0 : v > 1 ? 1 : v;
83513
+ }
83514
+ /** Is this a usable rectangle at all? A zero-area box is not a subject. */
83515
+ function isUsableRect(r) {
83516
+ return Number.isFinite(r.x) && Number.isFinite(r.y) && Number.isFinite(r.w) && Number.isFinite(r.h) && r.w > 0 && r.h > 0;
83517
+ }
83518
+ /**
83519
+ * The subject inside a square LETTERBOX of the full frame.
83520
+ *
83521
+ * The frame is scaled to fit the square and centred; the padding bars take the
83522
+ * rest. A wide 16:9 frame therefore occupies the middle 9/16 of the square's
83523
+ * height, and every y coordinate shifts by half the leftover.
83524
+ *
83525
+ * Square-side agnostic on purpose: the answer is normalised against the square,
83526
+ * so it is the same number for a 640 and a 1280 input and no caller has to know
83527
+ * which the model uses.
83528
+ */
83529
+ function deriveLetterboxRect(bbox, frameWidth, frameHeight) {
83530
+ if (!isUsableRect(bbox)) return null;
83531
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
83532
+ const longSide = Math.max(frameWidth, frameHeight);
83533
+ const scaleX = frameWidth / longSide;
83534
+ const scaleY = frameHeight / longSide;
83535
+ const offsetX = (1 - scaleX) / 2;
83536
+ const offsetY = (1 - scaleY) / 2;
83537
+ return {
83538
+ bbox: {
83539
+ x: offsetX + bbox.x * scaleX,
83540
+ y: offsetY + bbox.y * scaleY,
83541
+ w: bbox.w * scaleX,
83542
+ h: bbox.h * scaleY
83543
+ },
83544
+ visibleFraction: 1
83545
+ };
83546
+ }
83547
+ /**
83548
+ * The subject inside a ZONE CROP — the shape a package model is trained on.
83549
+ *
83550
+ * Returns `null` when the subject does not intersect the zone at all. That is
83551
+ * the whole reason the package proposal runs on the crop rather than the frame:
83552
+ * a package detector at 0.35 on a full frame proposes furniture, and a subject
83553
+ * outside the zone is not part of this model's world.
83554
+ */
83555
+ function deriveZoneCropRect(bbox, zone) {
83556
+ if (!isUsableRect(bbox) || !isUsableRect(zone)) return null;
83557
+ const left = Math.max(bbox.x, zone.x);
83558
+ const top = Math.max(bbox.y, zone.y);
83559
+ const right = Math.min(bbox.x + bbox.w, zone.x + zone.w);
83560
+ const bottom = Math.min(bbox.y + bbox.h, zone.y + zone.h);
83561
+ if (!(right > left) || !(bottom > top)) return null;
83562
+ const visibleFraction = (right - left) * (bottom - top) / (bbox.w * bbox.h);
83563
+ return {
83564
+ bbox: {
83565
+ x: (left - zone.x) / zone.w,
83566
+ y: (top - zone.y) / zone.h,
83567
+ w: (right - left) / zone.w,
83568
+ h: (bottom - top) / zone.h
83569
+ },
83570
+ visibleFraction
83571
+ };
83572
+ }
83573
+ /**
83574
+ * The subject inside its OWN padded crop — the shape a classifier is trained on.
83575
+ *
83576
+ * The crop is the box grown by `padding` of its own size on each side and then
83577
+ * clipped to the frame, so the subject sits in the middle and the classifier
83578
+ * still sees a little context. The returned rectangle is where the subject
83579
+ * lands inside that crop, which is NOT `[0,0,1,1]` whenever the padding was
83580
+ * clipped by a frame edge — a subject at the left border is flush left in its
83581
+ * crop, and a caller that assumed the centre would draw the box in the wrong
83582
+ * place.
83583
+ */
83584
+ function deriveSubjectCropRect(bbox, padding) {
83585
+ if (!isUsableRect(bbox)) return null;
83586
+ const pad = Number.isFinite(padding) && padding > 0 ? padding : 0;
83587
+ const cropX = clamp01(bbox.x - bbox.w * pad);
83588
+ const cropY = clamp01(bbox.y - bbox.h * pad);
83589
+ const cropRight = clamp01(bbox.x + bbox.w * (1 + pad));
83590
+ const cropBottom = clamp01(bbox.y + bbox.h * (1 + pad));
83591
+ const cropW = cropRight - cropX;
83592
+ const cropH = cropBottom - cropY;
83593
+ if (!(cropW > 0) || !(cropH > 0)) return null;
83594
+ return {
83595
+ bbox: {
83596
+ x: (bbox.x - cropX) / cropW,
83597
+ y: (bbox.y - cropY) / cropH,
83598
+ w: bbox.w / cropW,
83599
+ h: bbox.h / cropH
83600
+ },
83601
+ visibleFraction: 1
83602
+ };
83603
+ }
83604
+ //#endregion
83605
+ //#region src/pipeline-analytics/retrain/retrain-lighting.ts
83606
+ function isNight(condition) {
83607
+ return condition === "night" || condition === "ir";
83608
+ }
83609
+ function retrainFrameLighting(input) {
83610
+ if (input.capturedAt === void 0) {
83611
+ const measured = resolveConditionDetailed({
83612
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
83613
+ now: /* @__PURE__ */ new Date(0)
83614
+ });
83615
+ return measured.source === "ir-chroma" ? {
83616
+ condition: measured.condition,
83617
+ night: true,
83618
+ source: measured.source
83619
+ } : null;
83620
+ }
83621
+ const resolved = resolveConditionDetailed({
83622
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
83623
+ ...input.coordinates !== null ? { coordinates: input.coordinates } : {},
83624
+ now: new Date(input.capturedAt)
83625
+ });
83626
+ return {
83627
+ ...resolved,
83628
+ night: isNight(resolved.condition)
83629
+ };
83630
+ }
83631
+ //#endregion
83632
+ //#region src/pipeline-analytics/retrain/retrain-export.ts
83633
+ /**
83634
+ * The ANNOTATED dataset export — the retrain page's own archive.
83635
+ *
83636
+ * Sibling of `training-export/`, deliberately not the same thing. That one
83637
+ * streams a marked track's stored media verbatim and knows nothing about what
83638
+ * is in it; this one streams the frames the operator SELECTED (already copied
83639
+ * into retrain storage) plus the ground truth they drew.
83640
+ *
83641
+ * ## The one interesting decision
83642
+ *
83643
+ * `annotations.json` carries, for every subject, the canonical full-frame box
83644
+ * AND the geometry derived for each model shape. **Derived here, at export,
83645
+ * never stored** — one box in, three shapes out. Storing the derived shapes is
83646
+ * how two crops of the same subject end up in one feature space, which is the
83647
+ * failure already recorded for CLIP (D52). A consumer that wants a different
83648
+ * input size or a different padding re-derives from the canonical box; nothing
83649
+ * in the database has to change and nothing can disagree.
83650
+ *
83651
+ * Everything else follows the export next door: index reads to plan, one JPEG
83652
+ * in memory at a time, a yield between files, and NO inference — a training
83653
+ * set's pixels are the pixels the pipeline saw.
83654
+ */
83655
+ var RETRAIN_EXPORT_PREFIX = "retrain-export";
83656
+ /**
83657
+ * Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
83658
+ * padding, read from the one convention `deriveDetailCropRect` cuts with
83659
+ * (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
83660
+ * here while the runtime fed `0.15`, so a classifier trained from this export
83661
+ * learned on more context than it is ever given live.
83662
+ */
83663
+ var CLASSIFIER_CROP_PADDING = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
83664
+ /** The convention a `crops` layout cuts with — the runtime default, recorded
83665
+ * in the manifest so a consumer never has to assume it. */
83666
+ var RETRAIN_CROP_CONVENTION = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION;
83667
+ /** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
83668
+ * the trainer resizes per model, so this only bounds the upload. */
83669
+ var RETRAIN_CROP_MAX_SIDE = 256;
83670
+ /**
83671
+ * Manifest schema id. v2 = capturedAt / complete / lighting / layout.
83672
+ * v3 = `origin` (`track` | `recording`), `epochMs` on a recording still,
83673
+ * `collectionIds`, and `trackId` ABSENT on a recording still, which has none.
83674
+ */
83675
+ var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v3";
83676
+ /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`,
83677
+ * or `frames/<deviceId>/recording/<leaf>.jpg` for a recording still. */
83678
+ function retrainFramePath(frame) {
83679
+ const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
83680
+ const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
83681
+ return `frames/${frame.deviceId}/${dir}/${leaf}.jpg`;
83682
+ }
83683
+ /** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
83684
+ function retrainCropPath(macroClass, annotationId) {
83685
+ return `crops/${macroClass}/${annotationId}.jpg`;
83686
+ }
83687
+ /**
83688
+ * Narrow the planned frames to a filter. Pure. Every field narrows; an empty
83689
+ * filter keeps everything. Order is the input's.
83690
+ */
83691
+ function filterRetrainExport(frames, annotationsByFrame, filter, collectionsByFrame = /* @__PURE__ */ new Map()) {
83692
+ const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
83693
+ const collections = filter.collectionIds !== void 0 && filter.collectionIds.length > 0 ? new Set(filter.collectionIds) : null;
83694
+ const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
83695
+ return frames.filter((frame) => {
83696
+ if (wanted !== null && !wanted.has(frame.frameId)) return false;
83697
+ if (devices !== null && !devices.has(frame.deviceId)) return false;
83698
+ if (filter.completeOnly === true && !frame.complete) return false;
83699
+ if (collections !== null && !(collectionsByFrame.get(frame.frameId) ?? []).some((id) => collections.has(id))) return false;
83700
+ if (filter.batchId !== void 0) {
83701
+ if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
83702
+ }
83703
+ return true;
83704
+ });
83705
+ }
83706
+ /**
83707
+ * Derive every per-model shape for one annotation.
83708
+ *
83709
+ * `zone` is optional and per-frame: only a package annotation has one, and a
83710
+ * `zoneCrop` for a subject the zone does not contain is ABSENT rather than
83711
+ * clamped — a clamped box teaches the model that the subject ends where the
83712
+ * crop ends.
83713
+ */
83714
+ function deriveAll(annotation, frame, zone) {
83715
+ const letterbox = deriveLetterboxRect(annotation.bbox, frame.width, frame.height);
83716
+ const subjectCrop = deriveSubjectCropRect(annotation.bbox, CLASSIFIER_CROP_PADDING);
83717
+ const zoneCrop = zone === null ? null : deriveZoneCropRect(annotation.bbox, zone);
83718
+ return {
83719
+ ...letterbox !== null ? { letterbox } : {},
83720
+ ...zoneCrop !== null ? { zoneCrop } : {},
83721
+ ...subjectCrop !== null ? { subjectCrop } : {}
83722
+ };
83723
+ }
83724
+ /** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
83725
+ function buildRetrainManifest(input) {
83726
+ const layout = input.layout ?? "frames";
83727
+ const coordinates = input.coordinates ?? null;
83728
+ let annotationCount = 0;
83729
+ let modelErrorCount = 0;
83730
+ const frames = input.frames.map((frame) => {
83731
+ const zone = input.zoneForDevice(frame.deviceId);
83732
+ const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
83733
+ annotationCount += 1;
83734
+ if (a.kind === "model_error") modelErrorCount += 1;
83735
+ const derived = deriveAll(a, frame, zone);
82744
83736
  return {
82745
- id: row.id,
82746
- name: typeof row.data["name"] === "string" ? row.data["name"] : "",
82747
- createdAt: Number(row.data["createdAt"] ?? 0),
82748
- frameCount: 0
83737
+ id: a.id,
83738
+ macroClass: a.macroClass,
83739
+ ...a.label !== void 0 ? { label: a.label } : {},
83740
+ ...a.subLabel !== void 0 ? { subLabel: a.subLabel } : {},
83741
+ kind: a.kind,
83742
+ source: a.source,
83743
+ ...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
83744
+ ...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
83745
+ bbox: a.bbox,
83746
+ ...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
83747
+ derived
83748
+ };
83749
+ });
83750
+ return {
83751
+ frameId: frame.frameId,
83752
+ path: retrainFramePath(frame),
83753
+ deviceId: frame.deviceId,
83754
+ origin: frame.origin,
83755
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
83756
+ ...frame.epochMs !== void 0 ? { epochMs: frame.epochMs } : {},
83757
+ collectionIds: input.collectionsByFrame?.get(frame.frameId) ?? [],
83758
+ width: frame.width,
83759
+ height: frame.height,
83760
+ sizeBytes: frame.sizeBytes,
83761
+ sourceKind: frame.sourceKind,
83762
+ copiedAt: frame.copiedAt,
83763
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
83764
+ complete: frame.complete,
83765
+ lighting: retrainFrameLighting({
83766
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
83767
+ ...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
83768
+ coordinates
83769
+ }),
83770
+ ...zone !== null ? { zoneUsed: zone } : {},
83771
+ annotations
83772
+ };
83773
+ });
83774
+ return {
83775
+ schema: RETRAIN_MANIFEST_SCHEMA,
83776
+ layout,
83777
+ detailCropConvention: RETRAIN_CROP_CONVENTION,
83778
+ ...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
83779
+ generatedAt: input.generatedAt,
83780
+ batchId: input.batchId,
83781
+ frameCount: frames.length,
83782
+ annotationCount,
83783
+ modelErrorCount,
83784
+ classifierCropPadding: CLASSIFIER_CROP_PADDING,
83785
+ frames
83786
+ };
83787
+ }
83788
+ //#endregion
83789
+ //#region src/pipeline-analytics/retrain/retrain-dataset-service.ts
83790
+ function filterOf(selection, completeOnly) {
83791
+ return {
83792
+ ...selection.deviceIds !== void 0 && selection.deviceIds.length > 0 ? { deviceIds: [...selection.deviceIds] } : {},
83793
+ ...selection.collectionIds !== void 0 && selection.collectionIds.length > 0 ? { collectionIds: [...selection.collectionIds] } : {},
83794
+ ...selection.frameIds !== void 0 && selection.frameIds.length > 0 ? { frameIds: [...selection.frameIds] } : {},
83795
+ ...completeOnly ? { completeOnly: true } : {}
83796
+ };
83797
+ }
83798
+ function summaryOf(version) {
83799
+ const { frameIds, ...rest } = version;
83800
+ return {
83801
+ ...rest,
83802
+ frameCount: frameIds.length
83803
+ };
83804
+ }
83805
+ var RetrainDatasetService = class {
83806
+ deps;
83807
+ constructor(deps) {
83808
+ this.deps = deps;
83809
+ }
83810
+ /** The counts a selection would freeze, without freezing anything. */
83811
+ async preview(input) {
83812
+ return datasetStats((await this.deps.buildPlan({
83813
+ filter: filterOf(input.selection, input.completeOnly),
83814
+ layout: "frames"
83815
+ })).manifest);
83816
+ }
83817
+ async create(input) {
83818
+ const name = input.name.trim();
83819
+ if (name === "") return {
83820
+ kind: "refused",
83821
+ reason: "empty-name"
83822
+ };
83823
+ if (await this.deps.versions.findIdByName(name) !== null) return {
83824
+ kind: "refused",
83825
+ reason: "duplicate-name"
83826
+ };
83827
+ const plan = await this.deps.buildPlan({
83828
+ filter: filterOf(input.selection, input.completeOnly),
83829
+ layout: input.layout
83830
+ });
83831
+ if (plan.manifest.frames.length === 0) return {
83832
+ kind: "refused",
83833
+ reason: "no-frames"
83834
+ };
83835
+ const version = {
83836
+ id: this.deps.newId(),
83837
+ name,
83838
+ createdAt: this.deps.now(),
83839
+ selection: input.selection,
83840
+ layout: input.layout,
83841
+ completeOnly: input.completeOnly,
83842
+ frameIds: plan.manifest.frames.map((f) => f.frameId).toSorted(),
83843
+ annotationHash: datasetAnnotationHash(plan.manifest.frames),
83844
+ manifestSchema: RETRAIN_MANIFEST_SCHEMA,
83845
+ stats: datasetStats(plan.manifest)
83846
+ };
83847
+ await this.deps.versions.insert(version);
83848
+ this.deps.logger.info("retrain dataset version created", { meta: {
83849
+ versionId: version.id,
83850
+ frames: version.frameIds.length,
83851
+ subjects: version.stats.subjects,
83852
+ completeness: version.stats.completeness,
83853
+ layout: version.layout
83854
+ } });
83855
+ return {
83856
+ kind: "created",
83857
+ version: summaryOf(version)
83858
+ };
83859
+ }
83860
+ async list() {
83861
+ return (await this.deps.versions.list()).map(summaryOf);
83862
+ }
83863
+ async delete(input) {
83864
+ const deleted = await this.deps.versions.remove(input.id);
83865
+ if (deleted) this.deps.logger.info("retrain dataset version deleted", { meta: { versionId: input.id } });
83866
+ return { deleted };
83867
+ }
83868
+ /** Does the corpus still teach what the version froze? */
83869
+ async verify(input) {
83870
+ const version = await this.deps.versions.get(input.id);
83871
+ if (version === null) return {
83872
+ found: false,
83873
+ annotationHash: "",
83874
+ matches: false,
83875
+ missingFrames: 0
83876
+ };
83877
+ const plan = await this.deps.buildPlan({
83878
+ filter: { frameIds: [...version.frameIds] },
83879
+ layout: "frames"
83880
+ });
83881
+ const present = new Map(plan.manifest.frames.map((f) => [f.frameId, f]));
83882
+ const annotationHash = datasetAnnotationHash(version.frameIds.map((frameId) => present.get(frameId) ?? {
83883
+ frameId,
83884
+ complete: false,
83885
+ annotations: []
83886
+ }));
83887
+ return {
83888
+ found: true,
83889
+ annotationHash,
83890
+ matches: annotationHash === version.annotationHash,
83891
+ missingFrames: version.frameIds.filter((id) => !present.has(id)).length
83892
+ };
83893
+ }
83894
+ /**
83895
+ * An export filter with `datasetVersionId` resolved to that version's
83896
+ * frames (and its completeOnly). An unknown version exports NOTHING — never
83897
+ * the whole dataset, which is what dropping the field would do.
83898
+ */
83899
+ async exportFilterFor(filter) {
83900
+ if (filter.datasetVersionId === void 0) return filter;
83901
+ const { datasetVersionId, ...rest } = filter;
83902
+ const version = await this.deps.versions.get(datasetVersionId);
83903
+ if (version === null) {
83904
+ this.deps.logger.warn("retrain export asked for an unknown dataset version", { meta: { versionId: datasetVersionId } });
83905
+ return {
83906
+ ...rest,
83907
+ frameIds: []
82749
83908
  };
83909
+ }
83910
+ const wanted = rest.frameIds !== void 0 ? version.frameIds.filter((id) => rest.frameIds?.includes(id) === true) : version.frameIds;
83911
+ return {
83912
+ ...rest,
83913
+ frameIds: [...wanted],
83914
+ ...version.completeOnly ? { completeOnly: true } : {}
83915
+ };
83916
+ }
83917
+ };
83918
+ //#endregion
83919
+ //#region src/pipeline-analytics/retrain/retrain-dataset-version-store.ts
83920
+ /**
83921
+ * @durable class=ledger owner=pipeline-analytics
83922
+ * write="one row per dataset version the operator creates: a frozen frameIds list,
83923
+ * the sha256 of the annotations those frames carried, the export parameters and
83924
+ * the counts at creation. Never updated after the insert — a different selection
83925
+ * is a new version."
83926
+ * retention="none — rows leave only when the operator deletes the version. The
83927
+ * frames themselves are owned by the retrain corpus; deleting a version deletes no
83928
+ * frame, and deleting a frame does not edit a version (verify reports it missing)."
83929
+ */
83930
+ var RETRAIN_DATASET_VERSIONS_COLLECTION = "pipeline-analytics:retrain-dataset-versions";
83931
+ var COLUMNS = [
83932
+ {
83933
+ name: "id",
83934
+ type: "TEXT",
83935
+ primaryKey: true,
83936
+ notNull: true
83937
+ },
83938
+ {
83939
+ name: "name",
83940
+ type: "TEXT",
83941
+ notNull: true
83942
+ },
83943
+ {
83944
+ name: "nameKey",
83945
+ type: "TEXT",
83946
+ notNull: true
83947
+ },
83948
+ {
83949
+ name: "createdAt",
83950
+ type: "INTEGER",
83951
+ notNull: true
83952
+ },
83953
+ {
83954
+ name: "selection",
83955
+ type: "JSON",
83956
+ notNull: true
83957
+ },
83958
+ {
83959
+ name: "layout",
83960
+ type: "TEXT",
83961
+ notNull: true
83962
+ },
83963
+ {
83964
+ name: "completeOnly",
83965
+ type: "BOOLEAN",
83966
+ notNull: true
83967
+ },
83968
+ {
83969
+ name: "frameIds",
83970
+ type: "JSON",
83971
+ notNull: true
83972
+ },
83973
+ {
83974
+ name: "annotationHash",
83975
+ type: "TEXT",
83976
+ notNull: true
83977
+ },
83978
+ {
83979
+ name: "manifestSchema",
83980
+ type: "TEXT",
83981
+ notNull: true
83982
+ },
83983
+ {
83984
+ name: "stats",
83985
+ type: "JSON",
83986
+ notNull: true
83987
+ }
83988
+ ];
83989
+ var INDEXES = [{
83990
+ name: "idx_retrain_dataset_versions_name_key",
83991
+ columns: ["nameKey"],
83992
+ unique: true
83993
+ }, {
83994
+ name: "idx_retrain_dataset_versions_created",
83995
+ columns: ["createdAt"]
83996
+ }];
83997
+ var nameKeyOf = (name) => name.trim().toLowerCase();
83998
+ function numberList(value) {
83999
+ return Array.isArray(value) ? value.filter((v) => typeof v === "number") : void 0;
84000
+ }
84001
+ function stringList(value) {
84002
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : void 0;
84003
+ }
84004
+ function parseJson(value) {
84005
+ if (typeof value !== "string") return value;
84006
+ try {
84007
+ return JSON.parse(value);
84008
+ } catch {
84009
+ return;
84010
+ }
84011
+ }
84012
+ function asRecord(value) {
84013
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
84014
+ }
84015
+ var EMPTY_STATS = {
84016
+ frames: 0,
84017
+ completeFrames: 0,
84018
+ completeness: 0,
84019
+ subjects: 0,
84020
+ modelErrors: 0,
84021
+ hardNegatives: 0,
84022
+ cells: [],
84023
+ cameras: []
84024
+ };
84025
+ /** A stored stats object, trusted only as far as its shape. */
84026
+ function readStats(value) {
84027
+ const raw = asRecord(parseJson(value));
84028
+ const num = (k) => typeof raw[k] === "number" ? raw[k] : 0;
84029
+ const cells = Array.isArray(raw["cells"]) ? raw["cells"] : [];
84030
+ const cameras = Array.isArray(raw["cameras"]) ? raw["cameras"] : [];
84031
+ return {
84032
+ ...EMPTY_STATS,
84033
+ frames: num("frames"),
84034
+ completeFrames: num("completeFrames"),
84035
+ completeness: num("completeness"),
84036
+ subjects: num("subjects"),
84037
+ modelErrors: num("modelErrors"),
84038
+ hardNegatives: num("hardNegatives"),
84039
+ cells: cells.map((c) => {
84040
+ const r = asRecord(c);
84041
+ const lighting = r["lighting"] === "day" || r["lighting"] === "night" ? r["lighting"] : "unknown";
84042
+ return {
84043
+ macroClass: String(r["macroClass"] ?? ""),
84044
+ deviceId: Number(r["deviceId"] ?? 0),
84045
+ lighting,
84046
+ subjects: Number(r["subjects"] ?? 0),
84047
+ frames: Number(r["frames"] ?? 0)
84048
+ };
84049
+ }),
84050
+ cameras: cameras.map((c) => {
84051
+ const r = asRecord(c);
84052
+ return {
84053
+ deviceId: Number(r["deviceId"] ?? 0),
84054
+ frames: Number(r["frames"] ?? 0),
84055
+ completeFrames: Number(r["completeFrames"] ?? 0)
84056
+ };
84057
+ })
84058
+ };
84059
+ }
84060
+ var RetrainDatasetVersionStore = class {
84061
+ store;
84062
+ logger;
84063
+ constructor(deps) {
84064
+ this.store = deps.store;
84065
+ this.logger = deps.logger;
84066
+ }
84067
+ static async declare(store) {
84068
+ await store.declareCollection.mutate({
84069
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84070
+ columns: [...COLUMNS],
84071
+ indexes: [...INDEXES]
84072
+ });
84073
+ }
84074
+ insert = async (version) => {
84075
+ await this.store.set.mutate({
84076
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84077
+ key: version.id,
84078
+ value: {
84079
+ name: version.name,
84080
+ nameKey: nameKeyOf(version.name),
84081
+ createdAt: version.createdAt,
84082
+ selection: version.selection,
84083
+ layout: version.layout,
84084
+ completeOnly: version.completeOnly,
84085
+ frameIds: version.frameIds,
84086
+ annotationHash: version.annotationHash,
84087
+ manifestSchema: version.manifestSchema,
84088
+ stats: version.stats
84089
+ }
84090
+ });
84091
+ };
84092
+ get = async (id) => {
84093
+ try {
84094
+ const row = await this.store.get.query({
84095
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84096
+ key: id
84097
+ });
84098
+ return row === null || row === void 0 ? null : this.toVersion(id, asRecord(row));
82750
84099
  } catch (err) {
82751
- this.logger.warn("retrain collection findByNameKey failed", { meta: {
82752
- nameKey,
84100
+ this.logger.warn("retrain dataset version read failed", { meta: {
84101
+ versionId: id,
82753
84102
  error: String(err)
82754
84103
  } });
82755
84104
  return null;
82756
84105
  }
84106
+ };
84107
+ list = async () => {
84108
+ try {
84109
+ return (await this.store.query.query({
84110
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84111
+ filter: { orderBy: {
84112
+ field: "createdAt",
84113
+ direction: "desc"
84114
+ } }
84115
+ })).map((r) => this.toVersion(r.id, asRecord(r.data)));
84116
+ } catch (err) {
84117
+ this.logger.warn("retrain dataset versions list failed", { meta: { error: String(err) } });
84118
+ return [];
84119
+ }
84120
+ };
84121
+ findIdByName = async (name) => {
84122
+ return (await this.store.query.query({
84123
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84124
+ filter: {
84125
+ where: { nameKey: nameKeyOf(name) },
84126
+ limit: 1
84127
+ }
84128
+ }))[0]?.id ?? null;
84129
+ };
84130
+ remove = async (id) => {
84131
+ if (await this.get(id) === null) return false;
84132
+ await this.store.delete.mutate({
84133
+ collection: RETRAIN_DATASET_VERSIONS_COLLECTION,
84134
+ key: id
84135
+ });
84136
+ return true;
84137
+ };
84138
+ toVersion(id, data) {
84139
+ const selection = asRecord(parseJson(data["selection"]));
84140
+ const deviceIds = numberList(selection["deviceIds"]);
84141
+ const collectionIds = stringList(selection["collectionIds"]);
84142
+ const frameIdsSel = stringList(selection["frameIds"]);
84143
+ return {
84144
+ id,
84145
+ name: String(data["name"] ?? ""),
84146
+ createdAt: Number(data["createdAt"] ?? 0),
84147
+ selection: {
84148
+ ...deviceIds !== void 0 ? { deviceIds } : {},
84149
+ ...collectionIds !== void 0 ? { collectionIds } : {},
84150
+ ...frameIdsSel !== void 0 ? { frameIds: frameIdsSel } : {}
84151
+ },
84152
+ layout: data["layout"] === "crops" ? "crops" : "frames",
84153
+ completeOnly: data["completeOnly"] === true || data["completeOnly"] === 1,
84154
+ frameIds: stringList(parseJson(data["frameIds"])) ?? [],
84155
+ annotationHash: String(data["annotationHash"] ?? ""),
84156
+ manifestSchema: String(data["manifestSchema"] ?? ""),
84157
+ stats: readStats(data["stats"])
84158
+ };
84159
+ }
84160
+ };
84161
+ //#endregion
84162
+ //#region src/pipeline-analytics/retrain/retrain-llm-assist.ts
84163
+ var ALWAYS_LOCAL_KINDS = new Set(["managed-local", "apple-foundation"]);
84164
+ var ALWAYS_CLOUD_KINDS = new Set([
84165
+ "openai",
84166
+ "anthropic",
84167
+ "google"
84168
+ ]);
84169
+ function isPrivateIpv4(host) {
84170
+ const parts = host.split(".").map((p) => Number.parseInt(p, 10));
84171
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
84172
+ const [a, b] = parts;
84173
+ return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
84174
+ }
84175
+ function isLocalHost(rawHost) {
84176
+ const host = rawHost.replace(/^\[|\]$/g, "").toLowerCase();
84177
+ if (host.includes(":")) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
84178
+ if (host === "localhost" || host.endsWith(".local") || host.endsWith(".lan")) return true;
84179
+ if (isPrivateIpv4(host)) return true;
84180
+ return !host.includes(".") && !host.includes(":");
84181
+ }
84182
+ /** Does sending a frame to this profile send it outside the house? */
84183
+ function llmProfileLeavesHouse(profile) {
84184
+ if (ALWAYS_LOCAL_KINDS.has(profile.kind)) return false;
84185
+ if (ALWAYS_CLOUD_KINDS.has(profile.kind)) return true;
84186
+ if (profile.baseUrl === void 0 || profile.baseUrl === "") return true;
84187
+ try {
84188
+ return !isLocalHost(new URL(profile.baseUrl).hostname);
84189
+ } catch {
84190
+ return true;
84191
+ }
84192
+ }
84193
+ /** At most this many boxes are sent for review — a prompt, not a dump. */
84194
+ var LLM_REVIEW_MAX_BOXES = 50;
84195
+ var VERDICTS = [
84196
+ "confirmed",
84197
+ "wrong-class",
84198
+ "not-an-object",
84199
+ "uncertain"
84200
+ ];
84201
+ var SYSTEM_PROMPT = [
84202
+ "You annotate frames from home security cameras to build an object-detection training set.",
84203
+ "Be precise and conservative: only report what is clearly visible.",
84204
+ "Coordinates are normalised to the full frame: x and y are the top-left corner,",
84205
+ "w and h the width and height, all between 0 and 1.",
84206
+ "Answer with JSON only, exactly in the requested schema, with no other text."
84207
+ ].join(" ");
84208
+ /** The JSON schema asked of the model (and enforced again by the parser). */
84209
+ function answerJsonSchema(classes) {
84210
+ return {
84211
+ type: "object",
84212
+ additionalProperties: false,
84213
+ required: ["objects", "reviews"],
84214
+ properties: {
84215
+ objects: {
84216
+ type: "array",
84217
+ items: {
84218
+ type: "object",
84219
+ additionalProperties: false,
84220
+ required: [
84221
+ "class",
84222
+ "box",
84223
+ "confidence",
84224
+ "reason"
84225
+ ],
84226
+ properties: {
84227
+ class: {
84228
+ type: "string",
84229
+ enum: [...classes]
84230
+ },
84231
+ label: { type: "string" },
84232
+ box: {
84233
+ type: "array",
84234
+ items: { type: "number" },
84235
+ minItems: 4,
84236
+ maxItems: 4
84237
+ },
84238
+ confidence: {
84239
+ type: "number",
84240
+ minimum: 0,
84241
+ maximum: 1
84242
+ },
84243
+ reason: { type: "string" }
84244
+ }
84245
+ }
84246
+ },
84247
+ reviews: {
84248
+ type: "array",
84249
+ items: {
84250
+ type: "object",
84251
+ additionalProperties: false,
84252
+ required: [
84253
+ "ref",
84254
+ "verdict",
84255
+ "reason"
84256
+ ],
84257
+ properties: {
84258
+ ref: { type: "string" },
84259
+ verdict: {
84260
+ type: "string",
84261
+ enum: [...VERDICTS]
84262
+ },
84263
+ suggestedClass: { type: "string" },
84264
+ reason: { type: "string" }
84265
+ }
84266
+ }
84267
+ }
84268
+ }
84269
+ };
84270
+ }
84271
+ var round = (n) => Math.round(n * 1e3) / 1e3;
84272
+ /** Build the request: target classes, the frame size, the boxes to review. */
84273
+ function buildLlmAssistRequest(input) {
84274
+ const boxes = input.boxes.slice(0, LLM_REVIEW_MAX_BOXES).map((b) => JSON.stringify({
84275
+ ref: b.ref,
84276
+ class: b.macroClass,
84277
+ ...b.label !== void 0 ? { label: b.label } : {},
84278
+ box: [
84279
+ round(b.bbox.x),
84280
+ round(b.bbox.y),
84281
+ round(b.bbox.w),
84282
+ round(b.bbox.h)
84283
+ ]
84284
+ }));
84285
+ return {
84286
+ system: SYSTEM_PROMPT,
84287
+ prompt: [
84288
+ `The image is one camera frame, ${String(input.width)}×${String(input.height)} pixels.`,
84289
+ `1. List every object of these classes you can see: ${input.classes.join(", ")}.`,
84290
+ " For each give class, an optional free-text label (e.g. \"van\", \"postman\"),",
84291
+ " box [x, y, w, h] normalised to the frame, confidence 0–1, and a short reason.",
84292
+ " Include small and partly hidden ones; do not include anything else.",
84293
+ boxes.length > 0 ? `2. Review these existing boxes. For each ref say "confirmed", "wrong-class" (with suggestedClass), "not-an-object" or "uncertain", with a short reason:\n${boxes.join("\n")}` : "2. There are no existing boxes to review: answer \"reviews\": [].",
84294
+ "Answer with JSON: {\"objects\": [...], \"reviews\": [...]}."
84295
+ ].join("\n"),
84296
+ jsonSchema: answerJsonSchema(input.classes)
84297
+ };
84298
+ }
84299
+ /** The whole answer: two arrays and nothing else. Items are checked one by one. */
84300
+ var AnswerSchema = require_clip_model_registry.strictObject({
84301
+ objects: require_clip_model_registry.array(require_clip_model_registry.unknown()),
84302
+ reviews: require_clip_model_registry.array(require_clip_model_registry.unknown())
84303
+ });
84304
+ var ObjectSchema = require_clip_model_registry.strictObject({
84305
+ class: require_clip_model_registry.string(),
84306
+ label: require_clip_model_registry.string().optional(),
84307
+ box: require_clip_model_registry.tuple([
84308
+ require_clip_model_registry.number(),
84309
+ require_clip_model_registry.number(),
84310
+ require_clip_model_registry.number(),
84311
+ require_clip_model_registry.number()
84312
+ ]),
84313
+ confidence: require_clip_model_registry.number().min(0).max(1),
84314
+ reason: require_clip_model_registry.string().max(500)
84315
+ });
84316
+ var ReviewSchema = require_clip_model_registry.strictObject({
84317
+ ref: require_clip_model_registry.string(),
84318
+ verdict: require_clip_model_registry._enum(VERDICTS),
84319
+ suggestedClass: require_clip_model_registry.string().optional(),
84320
+ reason: require_clip_model_registry.string().max(500)
84321
+ });
84322
+ function frameBox(box) {
84323
+ const [x, y, w, h] = box;
84324
+ if (![
84325
+ x,
84326
+ y,
84327
+ w,
84328
+ h
84329
+ ].every(Number.isFinite)) return null;
84330
+ if (!(w > 0) || !(h > 0) || x < -.01 || y < -.01) return null;
84331
+ if (x + w > 1.01 || y + h > 1.01) return null;
84332
+ const cx = Math.max(0, x);
84333
+ const cy = Math.max(0, y);
84334
+ return {
84335
+ x: cx,
84336
+ y: cy,
84337
+ w: Math.min(w, 1 - cx),
84338
+ h: Math.min(h, 1 - cy)
84339
+ };
84340
+ }
84341
+ /** Strip a Markdown code fence a model wrapped its JSON in. */
84342
+ function unfence(text) {
84343
+ const trimmed = text.trim();
84344
+ const fence = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
84345
+ return fence?.[1] !== void 0 ? fence[1].trim() : trimmed;
84346
+ }
84347
+ /** A free-text label, never a macro word (the tier rule). */
84348
+ function usableLabel(label) {
84349
+ const trimmed = label?.trim();
84350
+ if (trimmed === void 0 || trimmed === "") return void 0;
84351
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
84352
+ }
84353
+ /** Parse the model's answer. See the module header for the rules. */
84354
+ function parseLlmAssistAnswer(text, expected) {
84355
+ let raw;
84356
+ try {
84357
+ raw = JSON.parse(unfence(text));
84358
+ } catch {
84359
+ return {
84360
+ kind: "malformed",
84361
+ detail: "the answer is not JSON"
84362
+ };
84363
+ }
84364
+ const answer = AnswerSchema.safeParse(raw);
84365
+ if (!answer.success) return {
84366
+ kind: "malformed",
84367
+ detail: "the answer is not {\"objects\": [...], \"reviews\": [...]}"
84368
+ };
84369
+ const asked = new Set(expected.classes);
84370
+ let rejected = 0;
84371
+ const objects = [];
84372
+ for (const item of answer.data.objects) {
84373
+ const parsed = ObjectSchema.safeParse(item);
84374
+ const cls = parsed.success ? parsed.data.class.trim().toLowerCase() : "";
84375
+ const bbox = parsed.success ? frameBox(parsed.data.box) : null;
84376
+ if (!parsed.success || bbox === null || !asked.has(cls) || !isRetrainMacroClass(cls)) {
84377
+ rejected += 1;
84378
+ continue;
84379
+ }
84380
+ const label = usableLabel(parsed.data.label);
84381
+ objects.push({
84382
+ macroClass: cls,
84383
+ ...label !== void 0 ? { label } : {},
84384
+ bbox,
84385
+ confidence: parsed.data.confidence,
84386
+ reason: parsed.data.reason
84387
+ });
84388
+ }
84389
+ const reviews = [];
84390
+ for (const item of answer.data.reviews) {
84391
+ const parsed = ReviewSchema.safeParse(item);
84392
+ if (!parsed.success || !expected.refs.has(parsed.data.ref)) {
84393
+ rejected += 1;
84394
+ continue;
84395
+ }
84396
+ const suggested = parsed.data.suggestedClass?.trim().toLowerCase();
84397
+ reviews.push({
84398
+ ref: parsed.data.ref,
84399
+ verdict: parsed.data.verdict,
84400
+ ...suggested !== void 0 && isRetrainMacroClass(suggested) ? { suggestedClass: suggested } : {},
84401
+ reason: parsed.data.reason
84402
+ });
84403
+ }
84404
+ return {
84405
+ kind: "parsed",
84406
+ objects,
84407
+ reviews,
84408
+ rejected
84409
+ };
84410
+ }
84411
+ //#endregion
84412
+ //#region src/pipeline-analytics/retrain/retrain-llm-service.ts
84413
+ /** The usage tag every AI-assist call is counted under (`llm.getUsage`). */
84414
+ var RETRAIN_LLM_CONSUMER = "retrain-assist";
84415
+ /** One frame, one answer: a cold local vision model can take a minute. */
84416
+ var RETRAIN_LLM_TIMEOUT_MS = 15e4;
84417
+ var RetrainLlmAssistService = class {
84418
+ deps;
84419
+ constructor(deps) {
84420
+ this.deps = deps;
84421
+ }
84422
+ /** The enabled, vision-capable profiles — a text-only one would be refused. */
84423
+ async listProfiles() {
84424
+ return (await this.deps.llm.listProfiles()).filter((p) => p.enabled && p.supportsVision).map((p) => ({
84425
+ id: p.id,
84426
+ name: p.name,
84427
+ model: p.model,
84428
+ kind: p.kind,
84429
+ leavesHouse: llmProfileLeavesHouse(p)
84430
+ }));
84431
+ }
84432
+ async propose(input) {
84433
+ const tags = { deviceId: input.deviceId };
84434
+ const frame = await this.deps.frames.get(input.frameId);
84435
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
84436
+ kind: "refused",
84437
+ reason: "unknown-frame"
84438
+ };
84439
+ const profile = (await this.deps.llm.listProfiles()).find((p) => p.id === input.profileId);
84440
+ if (profile === void 0) return {
84441
+ kind: "refused",
84442
+ reason: "unknown-profile"
84443
+ };
84444
+ if (!profile.enabled) return {
84445
+ kind: "refused",
84446
+ reason: "profile-disabled"
84447
+ };
84448
+ if (!profile.supportsVision) return {
84449
+ kind: "refused",
84450
+ reason: "no-vision"
84451
+ };
84452
+ const leavesHouse = llmProfileLeavesHouse(profile);
84453
+ if (leavesHouse && !input.allowCloud) {
84454
+ this.deps.logger.warn("retrain AI assist refused: a cloud profile without confirmation", {
84455
+ tags,
84456
+ meta: {
84457
+ frameId: input.frameId,
84458
+ profileId: profile.id
84459
+ }
84460
+ });
84461
+ return {
84462
+ kind: "refused",
84463
+ reason: "cloud-not-confirmed",
84464
+ detail: "this profile sends the frame outside the house; confirm the run first"
84465
+ };
84466
+ }
84467
+ const bytes = await this.deps.frames.readFrame(frame);
84468
+ if (bytes === null) return {
84469
+ kind: "refused",
84470
+ reason: "unreadable-frame"
84471
+ };
84472
+ const request = buildLlmAssistRequest({
84473
+ classes: input.classes,
84474
+ width: frame.width,
84475
+ height: frame.height,
84476
+ boxes: input.boxes
84477
+ });
84478
+ const answer = await this.deps.llm.generateVision({
84479
+ profileId: profile.id,
84480
+ system: request.system,
84481
+ prompt: request.prompt,
84482
+ jsonSchema: request.jsonSchema,
84483
+ image: bytes
84484
+ });
84485
+ if (!answer.ok) {
84486
+ this.deps.logger.warn("retrain AI assist: the model call failed", {
84487
+ tags,
84488
+ meta: {
84489
+ frameId: input.frameId,
84490
+ profileId: profile.id,
84491
+ code: answer.code
84492
+ }
84493
+ });
84494
+ return {
84495
+ kind: "refused",
84496
+ reason: "llm-error",
84497
+ detail: `${answer.code}: ${answer.message}`
84498
+ };
84499
+ }
84500
+ const parsed = parseLlmAssistAnswer(answer.text, {
84501
+ classes: input.classes,
84502
+ refs: new Set(input.boxes.map((b) => b.ref))
84503
+ });
84504
+ if (parsed.kind === "malformed") {
84505
+ this.deps.logger.warn("retrain AI assist: the answer was refused as malformed", {
84506
+ tags,
84507
+ meta: {
84508
+ frameId: input.frameId,
84509
+ profileId: profile.id,
84510
+ model: answer.model,
84511
+ detail: parsed.detail,
84512
+ answerChars: answer.text.length
84513
+ }
84514
+ });
84515
+ return {
84516
+ kind: "refused",
84517
+ reason: "malformed-answer",
84518
+ detail: parsed.detail
84519
+ };
84520
+ }
84521
+ this.deps.logger.info("retrain AI assist proposed boxes", {
84522
+ tags,
84523
+ meta: {
84524
+ frameId: input.frameId,
84525
+ profileId: profile.id,
84526
+ model: answer.model,
84527
+ leftHouse: leavesHouse,
84528
+ proposals: parsed.objects.length,
84529
+ reviews: parsed.reviews.length,
84530
+ rejected: parsed.rejected
84531
+ }
84532
+ });
84533
+ return {
84534
+ kind: "proposed",
84535
+ profileId: profile.id,
84536
+ model: answer.model,
84537
+ leftHouse: leavesHouse,
84538
+ proposals: parsed.objects.map((o) => ({
84539
+ bbox: o.bbox,
84540
+ macroClass: o.macroClass,
84541
+ ...o.label !== void 0 ? { label: o.label } : {},
84542
+ kind: "subject",
84543
+ source: "llm",
84544
+ assistModelId: answer.model,
84545
+ assistScore: o.confidence,
84546
+ reason: o.reason
84547
+ })),
84548
+ reviews: parsed.reviews.map((r) => ({
84549
+ ref: r.ref,
84550
+ verdict: r.verdict,
84551
+ ...r.suggestedClass !== void 0 ? { suggestedClass: r.suggestedClass } : {},
84552
+ reason: r.reason
84553
+ })),
84554
+ rejected: parsed.rejected
84555
+ };
82757
84556
  }
82758
84557
  };
82759
84558
  //#endregion
@@ -83073,274 +84872,6 @@ async function cutRetrainCrop(input) {
83073
84872
  return pipeline.jpeg({ quality: 92 }).toBuffer();
83074
84873
  }
83075
84874
  //#endregion
83076
- //#region src/pipeline-analytics/retrain/retrain-geometry.ts
83077
- function clamp01(v) {
83078
- return v < 0 ? 0 : v > 1 ? 1 : v;
83079
- }
83080
- /** Is this a usable rectangle at all? A zero-area box is not a subject. */
83081
- function isUsableRect(r) {
83082
- return Number.isFinite(r.x) && Number.isFinite(r.y) && Number.isFinite(r.w) && Number.isFinite(r.h) && r.w > 0 && r.h > 0;
83083
- }
83084
- /**
83085
- * The subject inside a square LETTERBOX of the full frame.
83086
- *
83087
- * The frame is scaled to fit the square and centred; the padding bars take the
83088
- * rest. A wide 16:9 frame therefore occupies the middle 9/16 of the square's
83089
- * height, and every y coordinate shifts by half the leftover.
83090
- *
83091
- * Square-side agnostic on purpose: the answer is normalised against the square,
83092
- * so it is the same number for a 640 and a 1280 input and no caller has to know
83093
- * which the model uses.
83094
- */
83095
- function deriveLetterboxRect(bbox, frameWidth, frameHeight) {
83096
- if (!isUsableRect(bbox)) return null;
83097
- if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
83098
- const longSide = Math.max(frameWidth, frameHeight);
83099
- const scaleX = frameWidth / longSide;
83100
- const scaleY = frameHeight / longSide;
83101
- const offsetX = (1 - scaleX) / 2;
83102
- const offsetY = (1 - scaleY) / 2;
83103
- return {
83104
- bbox: {
83105
- x: offsetX + bbox.x * scaleX,
83106
- y: offsetY + bbox.y * scaleY,
83107
- w: bbox.w * scaleX,
83108
- h: bbox.h * scaleY
83109
- },
83110
- visibleFraction: 1
83111
- };
83112
- }
83113
- /**
83114
- * The subject inside a ZONE CROP — the shape a package model is trained on.
83115
- *
83116
- * Returns `null` when the subject does not intersect the zone at all. That is
83117
- * the whole reason the package proposal runs on the crop rather than the frame:
83118
- * a package detector at 0.35 on a full frame proposes furniture, and a subject
83119
- * outside the zone is not part of this model's world.
83120
- */
83121
- function deriveZoneCropRect(bbox, zone) {
83122
- if (!isUsableRect(bbox) || !isUsableRect(zone)) return null;
83123
- const left = Math.max(bbox.x, zone.x);
83124
- const top = Math.max(bbox.y, zone.y);
83125
- const right = Math.min(bbox.x + bbox.w, zone.x + zone.w);
83126
- const bottom = Math.min(bbox.y + bbox.h, zone.y + zone.h);
83127
- if (!(right > left) || !(bottom > top)) return null;
83128
- const visibleFraction = (right - left) * (bottom - top) / (bbox.w * bbox.h);
83129
- return {
83130
- bbox: {
83131
- x: (left - zone.x) / zone.w,
83132
- y: (top - zone.y) / zone.h,
83133
- w: (right - left) / zone.w,
83134
- h: (bottom - top) / zone.h
83135
- },
83136
- visibleFraction
83137
- };
83138
- }
83139
- /**
83140
- * The subject inside its OWN padded crop — the shape a classifier is trained on.
83141
- *
83142
- * The crop is the box grown by `padding` of its own size on each side and then
83143
- * clipped to the frame, so the subject sits in the middle and the classifier
83144
- * still sees a little context. The returned rectangle is where the subject
83145
- * lands inside that crop, which is NOT `[0,0,1,1]` whenever the padding was
83146
- * clipped by a frame edge — a subject at the left border is flush left in its
83147
- * crop, and a caller that assumed the centre would draw the box in the wrong
83148
- * place.
83149
- */
83150
- function deriveSubjectCropRect(bbox, padding) {
83151
- if (!isUsableRect(bbox)) return null;
83152
- const pad = Number.isFinite(padding) && padding > 0 ? padding : 0;
83153
- const cropX = clamp01(bbox.x - bbox.w * pad);
83154
- const cropY = clamp01(bbox.y - bbox.h * pad);
83155
- const cropRight = clamp01(bbox.x + bbox.w * (1 + pad));
83156
- const cropBottom = clamp01(bbox.y + bbox.h * (1 + pad));
83157
- const cropW = cropRight - cropX;
83158
- const cropH = cropBottom - cropY;
83159
- if (!(cropW > 0) || !(cropH > 0)) return null;
83160
- return {
83161
- bbox: {
83162
- x: (bbox.x - cropX) / cropW,
83163
- y: (bbox.y - cropY) / cropH,
83164
- w: bbox.w / cropW,
83165
- h: bbox.h / cropH
83166
- },
83167
- visibleFraction: 1
83168
- };
83169
- }
83170
- //#endregion
83171
- //#region src/pipeline-analytics/retrain/retrain-lighting.ts
83172
- function isNight(condition) {
83173
- return condition === "night" || condition === "ir";
83174
- }
83175
- function retrainFrameLighting(input) {
83176
- if (input.capturedAt === void 0) {
83177
- const measured = resolveConditionDetailed({
83178
- ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
83179
- now: /* @__PURE__ */ new Date(0)
83180
- });
83181
- return measured.source === "ir-chroma" ? {
83182
- condition: measured.condition,
83183
- night: true,
83184
- source: measured.source
83185
- } : null;
83186
- }
83187
- const resolved = resolveConditionDetailed({
83188
- ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
83189
- ...input.coordinates !== null ? { coordinates: input.coordinates } : {},
83190
- now: new Date(input.capturedAt)
83191
- });
83192
- return {
83193
- ...resolved,
83194
- night: isNight(resolved.condition)
83195
- };
83196
- }
83197
- //#endregion
83198
- //#region src/pipeline-analytics/retrain/retrain-export.ts
83199
- /**
83200
- * The ANNOTATED dataset export — the retrain page's own archive.
83201
- *
83202
- * Sibling of `training-export/`, deliberately not the same thing. That one
83203
- * streams a marked track's stored media verbatim and knows nothing about what
83204
- * is in it; this one streams the frames the operator SELECTED (already copied
83205
- * into retrain storage) plus the ground truth they drew.
83206
- *
83207
- * ## The one interesting decision
83208
- *
83209
- * `annotations.json` carries, for every subject, the canonical full-frame box
83210
- * AND the geometry derived for each model shape. **Derived here, at export,
83211
- * never stored** — one box in, three shapes out. Storing the derived shapes is
83212
- * how two crops of the same subject end up in one feature space, which is the
83213
- * failure already recorded for CLIP (D52). A consumer that wants a different
83214
- * input size or a different padding re-derives from the canonical box; nothing
83215
- * in the database has to change and nothing can disagree.
83216
- *
83217
- * Everything else follows the export next door: index reads to plan, one JPEG
83218
- * in memory at a time, a yield between files, and NO inference — a training
83219
- * set's pixels are the pixels the pipeline saw.
83220
- */
83221
- var RETRAIN_EXPORT_PREFIX = "retrain-export";
83222
- /**
83223
- * Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
83224
- * padding, read from the one convention `deriveDetailCropRect` cuts with
83225
- * (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
83226
- * here while the runtime fed `0.15`, so a classifier trained from this export
83227
- * learned on more context than it is ever given live.
83228
- */
83229
- var CLASSIFIER_CROP_PADDING = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
83230
- /** The convention a `crops` layout cuts with — the runtime default, recorded
83231
- * in the manifest so a consumer never has to assume it. */
83232
- var RETRAIN_CROP_CONVENTION = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION;
83233
- /** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
83234
- * the trainer resizes per model, so this only bounds the upload. */
83235
- var RETRAIN_CROP_MAX_SIDE = 256;
83236
- /** Manifest schema id. v2 = capturedAt / complete / lighting / layout. */
83237
- var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v2";
83238
- /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`. */
83239
- function retrainFramePath(frame) {
83240
- const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
83241
- return `frames/${frame.deviceId}/${frame.trackId}/${leaf}.jpg`;
83242
- }
83243
- /** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
83244
- function retrainCropPath(macroClass, annotationId) {
83245
- return `crops/${macroClass}/${annotationId}.jpg`;
83246
- }
83247
- /**
83248
- * Narrow the planned frames to a filter. Pure. Every field narrows; an empty
83249
- * filter keeps everything. Order is the input's.
83250
- */
83251
- function filterRetrainExport(frames, annotationsByFrame, filter) {
83252
- const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
83253
- const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
83254
- return frames.filter((frame) => {
83255
- if (wanted !== null && !wanted.has(frame.frameId)) return false;
83256
- if (devices !== null && !devices.has(frame.deviceId)) return false;
83257
- if (filter.completeOnly === true && !frame.complete) return false;
83258
- if (filter.batchId !== void 0) {
83259
- if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
83260
- }
83261
- return true;
83262
- });
83263
- }
83264
- /**
83265
- * Derive every per-model shape for one annotation.
83266
- *
83267
- * `zone` is optional and per-frame: only a package annotation has one, and a
83268
- * `zoneCrop` for a subject the zone does not contain is ABSENT rather than
83269
- * clamped — a clamped box teaches the model that the subject ends where the
83270
- * crop ends.
83271
- */
83272
- function deriveAll(annotation, frame, zone) {
83273
- const letterbox = deriveLetterboxRect(annotation.bbox, frame.width, frame.height);
83274
- const subjectCrop = deriveSubjectCropRect(annotation.bbox, CLASSIFIER_CROP_PADDING);
83275
- const zoneCrop = zone === null ? null : deriveZoneCropRect(annotation.bbox, zone);
83276
- return {
83277
- ...letterbox !== null ? { letterbox } : {},
83278
- ...zoneCrop !== null ? { zoneCrop } : {},
83279
- ...subjectCrop !== null ? { subjectCrop } : {}
83280
- };
83281
- }
83282
- /** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
83283
- function buildRetrainManifest(input) {
83284
- const layout = input.layout ?? "frames";
83285
- const coordinates = input.coordinates ?? null;
83286
- let annotationCount = 0;
83287
- let modelErrorCount = 0;
83288
- const frames = input.frames.map((frame) => {
83289
- const zone = input.zoneForDevice(frame.deviceId);
83290
- const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
83291
- annotationCount += 1;
83292
- if (a.kind === "model_error") modelErrorCount += 1;
83293
- const derived = deriveAll(a, frame, zone);
83294
- return {
83295
- id: a.id,
83296
- macroClass: a.macroClass,
83297
- ...a.label !== void 0 ? { label: a.label } : {},
83298
- ...a.subLabel !== void 0 ? { subLabel: a.subLabel } : {},
83299
- kind: a.kind,
83300
- source: a.source,
83301
- ...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
83302
- ...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
83303
- bbox: a.bbox,
83304
- ...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
83305
- derived
83306
- };
83307
- });
83308
- return {
83309
- frameId: frame.frameId,
83310
- path: retrainFramePath(frame),
83311
- deviceId: frame.deviceId,
83312
- trackId: frame.trackId,
83313
- width: frame.width,
83314
- height: frame.height,
83315
- sizeBytes: frame.sizeBytes,
83316
- sourceKind: frame.sourceKind,
83317
- copiedAt: frame.copiedAt,
83318
- ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
83319
- complete: frame.complete,
83320
- lighting: retrainFrameLighting({
83321
- ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
83322
- ...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
83323
- coordinates
83324
- }),
83325
- ...zone !== null ? { zoneUsed: zone } : {},
83326
- annotations
83327
- };
83328
- });
83329
- return {
83330
- schema: RETRAIN_MANIFEST_SCHEMA,
83331
- layout,
83332
- detailCropConvention: RETRAIN_CROP_CONVENTION,
83333
- ...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
83334
- generatedAt: input.generatedAt,
83335
- batchId: input.batchId,
83336
- frameCount: frames.length,
83337
- annotationCount,
83338
- modelErrorCount,
83339
- classifierCropPadding: CLASSIFIER_CROP_PADDING,
83340
- frames
83341
- };
83342
- }
83343
- //#endregion
83344
84875
  //#region src/pipeline-analytics/retrain/retrain-export-handler.ts
83345
84876
  /** The export request a URL carries (`getRetrainExportUrl` composes it). */
83346
84877
  function parseRetrainExportRequest(url) {
@@ -83348,11 +84879,15 @@ function parseRetrainExportRequest(url) {
83348
84879
  const params = new URLSearchParams(query);
83349
84880
  const deviceIds = parseRetrainDeviceIds(url);
83350
84881
  const frameIds = (params.get("frameIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84882
+ const collectionIds = (params.get("collectionIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84883
+ const datasetVersionId = params.get("datasetVersionId")?.trim() ?? "";
83351
84884
  const batchId = params.get("batchId")?.trim() ?? "";
83352
84885
  return {
83353
84886
  filter: {
83354
84887
  ...deviceIds.length > 0 ? { deviceIds: [...deviceIds] } : {},
83355
84888
  ...frameIds.length > 0 ? { frameIds } : {},
84889
+ ...collectionIds.length > 0 ? { collectionIds } : {},
84890
+ ...datasetVersionId.length > 0 ? { datasetVersionId } : {},
83356
84891
  ...batchId.length > 0 ? { batchId } : {},
83357
84892
  ...params.get("completeOnly") === "1" ? { completeOnly: true } : {}
83358
84893
  },
@@ -83365,6 +84900,8 @@ function retrainExportQuery(request) {
83365
84900
  const { filter } = request;
83366
84901
  if (filter.deviceIds !== void 0 && filter.deviceIds.length > 0) params.set("deviceIds", filter.deviceIds.join(","));
83367
84902
  if (filter.frameIds !== void 0 && filter.frameIds.length > 0) params.set("frameIds", filter.frameIds.join(","));
84903
+ if (filter.collectionIds !== void 0 && filter.collectionIds.length > 0) params.set("collectionIds", filter.collectionIds.join(","));
84904
+ if (filter.datasetVersionId !== void 0) params.set("datasetVersionId", filter.datasetVersionId);
83368
84905
  if (filter.batchId !== void 0) params.set("batchId", filter.batchId);
83369
84906
  if (filter.completeOnly === true) params.set("completeOnly", "1");
83370
84907
  if (request.layout === "crops") params.set("layout", "crops");
@@ -83439,6 +84976,178 @@ function createRetrainExportHandler(deps) {
83439
84976
  };
83440
84977
  }
83441
84978
  //#endregion
84979
+ //#region src/pipeline-analytics/retrain/retrain-pipeline-boxes.ts
84980
+ /**
84981
+ * The boxes the PIPELINE drew on a copied frame — Step 1 of the training
84982
+ * screen.
84983
+ *
84984
+ * A correction has to start from the decision being corrected. Proposing with
84985
+ * a fresh model run answers a different question ("what would a model say
84986
+ * now"), and the frame the operator is fixing is precisely the one where the
84987
+ * live answer was wrong — a re-run that happens to be right hides the phantom
84988
+ * the operator came to remove. So the canvas opens with what the track store
84989
+ * says the pipeline had on that frame, and the operator accepts, corrects,
84990
+ * relabels or deletes it.
84991
+ *
84992
+ * ## Where a box comes from, best first
84993
+ *
84994
+ * 1. **The subject box recorded ON the source media row** (D510). Exact by
84995
+ * construction: the box was measured on the frame the blob shows. Only the
84996
+ * copy's own track can have one.
84997
+ * 2. **The track position nearest the capture time**, within
84998
+ * {@link PIPELINE_BOX_TOLERANCE_MS}. Positions are frame-rate, so the
84999
+ * nearest one is normally the same detection; the delta is reported so a
85000
+ * reviewer can see how far it was.
85001
+ *
85002
+ * Every OTHER track alive on the camera at that instant contributes its own
85003
+ * nearest position the same way — a frame with three people is three boxes,
85004
+ * and a recording still (no track of its own) gets everything the pipeline
85005
+ * was tracking at that moment, which is exactly what a missed-object review
85006
+ * needs to see is NOT there.
85007
+ *
85008
+ * Nothing here does I/O. The caller reads the tracks and the subject box.
85009
+ */
85010
+ /** How far from the capture time a position may be and still be "this frame".
85011
+ * Positions are recorded at the analysis rate (several per second), so half
85012
+ * a second is a generous match and still not a different moment. */
85013
+ var PIPELINE_BOX_TOLERANCE_MS = 500;
85014
+ /**
85015
+ * How far back a track may have STARTED and still be alive at the frame. The
85016
+ * track store windows on `firstSeen`, so a track born before this is not
85017
+ * asked about — a vehicle parked for hours is the case it misses, and the
85018
+ * operator draws that box by hand.
85019
+ */
85020
+ var PIPELINE_TRACK_LOOKBACK_MS = 30 * 6e4;
85021
+ /** At most this many tracks of one camera are read around one frame. */
85022
+ var PIPELINE_TRACK_AROUND_LIMIT = 200;
85023
+ /** The least-wrong macro tier for a class the macro map does not know. The
85024
+ * operator corrects it; what must not happen is the raw string being
85025
+ * promoted into the macro tier. */
85026
+ var FALLBACK_MACRO_CLASS = "person";
85027
+ /**
85028
+ * Split a detector's class into the two tiers.
85029
+ *
85030
+ * A macro class stays in the macro tier; anything else maps through the COCO
85031
+ * macro map, and the class itself becomes the LABEL. A macro word is never a
85032
+ * label, in any casing — the tier rule the annotation write enforces.
85033
+ */
85034
+ function toRetrainClassTiers(className, label) {
85035
+ const cls = className.trim().toLowerCase();
85036
+ const usableLabel = (value) => {
85037
+ const trimmed = value?.trim();
85038
+ if (trimmed === void 0 || trimmed === "") return void 0;
85039
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
85040
+ };
85041
+ if (isRetrainMacroClass(cls)) {
85042
+ const kept = usableLabel(label);
85043
+ return kept !== void 0 ? {
85044
+ macroClass: cls,
85045
+ label: kept
85046
+ } : { macroClass: cls };
85047
+ }
85048
+ const mapped = require_clip_model_registry.COCO_TO_MACRO.mapping[cls];
85049
+ const macroClass = mapped !== void 0 && isRetrainMacroClass(mapped) ? mapped : FALLBACK_MACRO_CLASS;
85050
+ const kept = usableLabel(className);
85051
+ return kept !== void 0 ? {
85052
+ macroClass,
85053
+ label: kept
85054
+ } : { macroClass };
85055
+ }
85056
+ /** A pixel box as a clamped normalised one, or `null` when nothing is left. */
85057
+ function normalise(box, frameWidth, frameHeight) {
85058
+ if (frameWidth === void 0 || frameHeight === void 0) return null;
85059
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
85060
+ const x = clampSpan(box.x, box.w, frameWidth);
85061
+ const y = clampSpan(box.y, box.h, frameHeight);
85062
+ if (x === null || y === null) return null;
85063
+ return {
85064
+ x: x.start,
85065
+ y: y.start,
85066
+ w: x.size,
85067
+ h: y.size
85068
+ };
85069
+ }
85070
+ /** One axis of a pixel box, normalised and clamped into `[0,1]`. The size is
85071
+ * divided directly when nothing was clamped, so an exact box stays exact. */
85072
+ function clampSpan(start, size, extent) {
85073
+ const lo = start / extent;
85074
+ const hi = (start + size) / extent;
85075
+ const s = Math.max(0, lo);
85076
+ const n = lo >= 0 && hi <= 1 ? size / extent : Math.min(1, hi) - s;
85077
+ return n > 0 ? {
85078
+ start: s,
85079
+ size: n
85080
+ } : null;
85081
+ }
85082
+ function nearestPosition(track, at, tolerance) {
85083
+ let best = null;
85084
+ let bestDelta = Number.POSITIVE_INFINITY;
85085
+ for (const p of track.positions) {
85086
+ const delta = Math.abs(p.timestamp - at);
85087
+ if (delta < bestDelta) {
85088
+ best = p;
85089
+ bestDelta = delta;
85090
+ }
85091
+ }
85092
+ if (best === null || bestDelta > tolerance) return null;
85093
+ return {
85094
+ position: best,
85095
+ deltaMs: bestDelta
85096
+ };
85097
+ }
85098
+ function boxFor(track, bbox, basis, deltaMs, score) {
85099
+ const tiers = toRetrainClassTiers(track.className, track.label);
85100
+ const subLabel = track.subLabel?.trim();
85101
+ return {
85102
+ bbox,
85103
+ macroClass: tiers.macroClass,
85104
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
85105
+ ...subLabel !== void 0 && subLabel !== "" && !isRetrainMacroClass(subLabel.toLowerCase()) ? { subLabel } : {},
85106
+ ...score !== void 0 ? { score } : {},
85107
+ ...track.modelId !== void 0 ? { modelId: track.modelId } : {},
85108
+ trackId: track.trackId,
85109
+ basis,
85110
+ deltaMs
85111
+ };
85112
+ }
85113
+ /** The own track's box: the recorded subject box first, the nearest position second. */
85114
+ function ownBox(input, tolerance) {
85115
+ const track = input.ownTrack;
85116
+ if (track === null) return null;
85117
+ const at = input.frame.capturedAt;
85118
+ const nearest = at !== void 0 ? nearestPosition(track, at, tolerance) : null;
85119
+ if (input.ownSubjectBox !== null) {
85120
+ const recorded = normalise(input.ownSubjectBox, input.ownSubjectBox.frameWidth, input.ownSubjectBox.frameHeight);
85121
+ if (recorded !== null) return boxFor(track, recorded, "subject-box", 0, nearest?.position.score);
85122
+ }
85123
+ if (nearest === null) return null;
85124
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
85125
+ return placed === null ? null : boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score);
85126
+ }
85127
+ /**
85128
+ * Every box the pipeline had on this frame, own track first. Pure.
85129
+ */
85130
+ function pipelineBoxesForFrame(input) {
85131
+ const tolerance = input.toleranceMs ?? PIPELINE_BOX_TOLERANCE_MS;
85132
+ const out = [];
85133
+ const own = ownBox(input, tolerance);
85134
+ if (own !== null) out.push(own);
85135
+ const at = input.frame.capturedAt;
85136
+ if (at === void 0) return out;
85137
+ const ownId = input.frame.trackId;
85138
+ const seen = new Set(ownId !== void 0 ? [ownId] : []);
85139
+ for (const track of input.neighbours) {
85140
+ if (seen.has(track.trackId) || track.deviceId !== input.frame.deviceId) continue;
85141
+ seen.add(track.trackId);
85142
+ const nearest = nearestPosition(track, at, tolerance);
85143
+ if (nearest === null) continue;
85144
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
85145
+ if (placed === null) continue;
85146
+ out.push(boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score));
85147
+ }
85148
+ return out;
85149
+ }
85150
+ //#endregion
83442
85151
  //#region src/pipeline-analytics/retrain/retrain-assist.ts
83443
85152
  /** The whole frame, as a normalised rectangle. */
83444
85153
  var WHOLE_FRAME = {
@@ -83509,12 +85218,11 @@ function mapAssistDetails(input) {
83509
85218
  withoutBox += 1;
83510
85219
  continue;
83511
85220
  }
83512
- const macroClass = isRetrainMacroClass(d.className) ? d.className : FALLBACK_MACRO_CLASS;
83513
- const label = isRetrainMacroClass(d.className) ? d.label : d.className;
85221
+ const tiers = toRetrainClassTiers(d.className, d.label);
83514
85222
  proposals.push({
83515
85223
  bbox,
83516
- macroClass,
83517
- ...label !== void 0 && label !== "" ? { label } : {},
85224
+ macroClass: tiers.macroClass,
85225
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
83518
85226
  score: d.score,
83519
85227
  modelId
83520
85228
  });
@@ -83525,16 +85233,6 @@ function mapAssistDetails(input) {
83525
85233
  withoutBox
83526
85234
  };
83527
85235
  }
83528
- /**
83529
- * Macro tier for a detector class we do not carry as a macro class.
83530
- *
83531
- * The class itself becomes the LABEL (see above) and the macro tier gets a
83532
- * value the operator will almost certainly correct — which is fine, because it
83533
- * is a PROPOSAL and correcting it is the job. What must not happen is the
83534
- * detector's own string being promoted into the macro tier: that is how
83535
- * `postman` becomes a class with one example in it.
83536
- */
83537
- var FALLBACK_MACRO_CLASS = "person";
83538
85236
  /** A proposal, as a draft annotation ready to store if the operator accepts it. */
83539
85237
  function proposalToDraft(proposal, owner) {
83540
85238
  return {
@@ -83743,12 +85441,18 @@ async function restageRetrainTrack(deps, input) {
83743
85441
  var DEFAULT_STAGING_LIMIT = 100;
83744
85442
  /** A macro class that failed validation — the write is refused, never coerced. */
83745
85443
  var RetrainAnnotationError = class extends Error {};
85444
+ /** A store frame on the wire. A recording still carries no track and no
85445
+ * source media row, and says so by leaving both out. */
83746
85446
  function toWireFrame(f) {
83747
85447
  return {
83748
85448
  frameId: f.frameId,
83749
85449
  deviceId: f.deviceId,
83750
- trackId: f.trackId,
83751
- sourceMediaId: f.sourceMediaId,
85450
+ origin: f.origin,
85451
+ ...f.origin === "track" ? {
85452
+ trackId: f.trackId,
85453
+ sourceMediaId: f.sourceMediaId
85454
+ } : {},
85455
+ ...f.epochMs !== void 0 ? { epochMs: f.epochMs } : {},
83752
85456
  sourceKind: f.sourceKind,
83753
85457
  sizeBytes: f.sizeBytes,
83754
85458
  width: f.width,
@@ -83761,7 +85465,7 @@ function toWireFrame(f) {
83761
85465
  function toWireAnnotation(a) {
83762
85466
  return {
83763
85467
  id: a.id,
83764
- trackId: a.trackId,
85468
+ ...a.trackId !== "" ? { trackId: a.trackId } : {},
83765
85469
  deviceId: a.deviceId,
83766
85470
  mediaKey: a.mediaKey,
83767
85471
  bbox: a.bbox,
@@ -83828,11 +85532,116 @@ var RetrainService = class {
83828
85532
  if (candidates.length === 0) await this.logNoAnnotatableFrame(trackId, media, copies.length);
83829
85533
  return {
83830
85534
  candidates,
83831
- copies: copies.map(toWireFrame),
85535
+ copies: await this.toWireFrames(copies),
83832
85536
  ...autoPick !== null ? { autoPickMediaId: autoPick.mediaId } : {}
83833
85537
  };
83834
85538
  }
83835
85539
  /**
85540
+ * The wire frames, each with the boxes the pipeline drew on it (Step 1) and
85541
+ * the collections it belongs to.
85542
+ *
85543
+ * Index reads only — the track rows and the media rows' subject boxes, no
85544
+ * blob and no inference. One read per distinct track and per distinct
85545
+ * instant, not per box. A failed read degrades to fewer boxes, logged, and
85546
+ * never to a failed listing: the frames are the operator's work and must
85547
+ * stay reachable when the evidence is not.
85548
+ */
85549
+ async toWireFrames(copies) {
85550
+ const collections = this.deps.memberships !== void 0 && copies.length > 0 ? await this.deps.memberships.collectionsOf(copies.map((c) => c.frameId)).catch((err) => {
85551
+ this.deps.logger.warn("retrain frames: collection membership unreadable", { meta: {
85552
+ frames: copies.length,
85553
+ error: String(err)
85554
+ } });
85555
+ return /* @__PURE__ */ new Map();
85556
+ }) : null;
85557
+ const evidence = this.deps.pipelineEvidence;
85558
+ const ownTracks = /* @__PURE__ */ new Map();
85559
+ const subjectBoxes = /* @__PURE__ */ new Map();
85560
+ const out = [];
85561
+ for (const copy of copies) {
85562
+ const wire = toWireFrame(copy);
85563
+ const collectionIds = collections?.get(copy.frameId);
85564
+ const withCollections = collections !== null ? {
85565
+ ...wire,
85566
+ collectionIds: collectionIds ?? []
85567
+ } : wire;
85568
+ if (evidence === void 0) {
85569
+ out.push(withCollections);
85570
+ continue;
85571
+ }
85572
+ const trackId = copy.origin === "track" ? copy.trackId : "";
85573
+ if (trackId !== "" && !ownTracks.has(trackId)) {
85574
+ ownTracks.set(trackId, await this.deps.tracks.getPersistedByTrackId(trackId).catch(() => null));
85575
+ subjectBoxes.set(trackId, await this.readSubjectBoxes(evidence, trackId, copy.deviceId));
85576
+ }
85577
+ const neighbours = copy.capturedAt !== void 0 ? await this.readTracksAround(evidence, copy.deviceId, copy.capturedAt) : [];
85578
+ const pipelineBoxes = pipelineBoxesForFrame({
85579
+ frame: {
85580
+ deviceId: copy.deviceId,
85581
+ ...trackId !== "" ? { trackId } : {},
85582
+ ...copy.capturedAt !== void 0 ? { capturedAt: copy.capturedAt } : {}
85583
+ },
85584
+ ownTrack: trackId !== "" ? ownTracks.get(trackId) ?? null : null,
85585
+ ownSubjectBox: trackId !== "" ? subjectBoxes.get(trackId)?.get(copy.sourceMediaId) ?? null : null,
85586
+ neighbours
85587
+ });
85588
+ out.push({
85589
+ ...withCollections,
85590
+ pipelineBoxes
85591
+ });
85592
+ }
85593
+ return out;
85594
+ }
85595
+ /**
85596
+ * The frame a frame-scoped call names, or a refusal. The frame is the
85597
+ * authority: its camera must be the caller's, and a `trackId`, when the
85598
+ * caller sends one, must be the frame's own — a recording still has none.
85599
+ */
85600
+ async requireFrame(ref, action) {
85601
+ const frame = await this.deps.frames.get(ref.frameId);
85602
+ const trackMismatch = frame !== null && ref.trackId !== void 0 && (frame.origin !== "track" || frame.trackId !== ref.trackId);
85603
+ if (frame === null || frame.deviceId !== ref.deviceId || trackMismatch) {
85604
+ this.deps.logger.warn(`retrain ${action} refused: the frame does not belong to that camera or track`, {
85605
+ tags: { deviceId: ref.deviceId },
85606
+ meta: {
85607
+ frameId: ref.frameId,
85608
+ ...ref.trackId !== void 0 ? { trackId: ref.trackId } : {}
85609
+ }
85610
+ });
85611
+ throw new RetrainAnnotationError(frame === null ? `retrain frame ${ref.frameId} does not exist — select it before annotating it` : `retrain frame ${ref.frameId} does not belong to ${ref.trackId !== void 0 ? `track ${ref.trackId}` : `camera ${String(ref.deviceId)}`}`);
85612
+ }
85613
+ return frame;
85614
+ }
85615
+ async readSubjectBoxes(evidence, trackId, deviceId) {
85616
+ try {
85617
+ const rows = await evidence.subjectBoxes(trackId, deviceId);
85618
+ return new Map(rows.map((r) => [r.mediaId, r.subjectBox]));
85619
+ } catch (err) {
85620
+ this.deps.logger.warn("retrain pipeline boxes: subject boxes unreadable", {
85621
+ tags: { deviceId },
85622
+ meta: {
85623
+ trackId,
85624
+ error: String(err)
85625
+ }
85626
+ });
85627
+ return /* @__PURE__ */ new Map();
85628
+ }
85629
+ }
85630
+ async readTracksAround(evidence, deviceId, atMs) {
85631
+ try {
85632
+ return await evidence.tracksAround(deviceId, atMs);
85633
+ } catch (err) {
85634
+ this.deps.logger.warn("retrain pipeline boxes: tracks around the frame unreadable", {
85635
+ tags: { deviceId },
85636
+ meta: {
85637
+ atMs,
85638
+ error: String(err)
85639
+ }
85640
+ });
85641
+ return [];
85642
+ }
85643
+ }
85644
+ /**
83836
85645
  * A track with no annotatable frame is a REAL state, and the page has to say
83837
85646
  * so rather than render an empty strip that reads as a slow load. Two causes,
83838
85647
  * named apart: the whole-frame media AGED OUT, or the track only ever held
@@ -83885,23 +85694,13 @@ var RetrainService = class {
83885
85694
  * track the caller names: a frame of another track is refused, loudly.
83886
85695
  */
83887
85696
  async setFrameComplete(input) {
83888
- const frame = await this.deps.frames.get(input.frameId);
83889
- if (frame === null || frame.trackId !== input.trackId || frame.deviceId !== input.deviceId) {
83890
- this.deps.logger.warn("retrain complete refused: the frame does not belong to that track", {
83891
- tags: { deviceId: input.deviceId },
83892
- meta: {
83893
- trackId: input.trackId,
83894
- frameId: input.frameId
83895
- }
83896
- });
83897
- throw new RetrainAnnotationError(`retrain frame ${input.frameId} does not belong to track ${input.trackId}`);
83898
- }
83899
- const updated = await this.deps.frames.setComplete(input.frameId, input.complete);
85697
+ const frame = await this.requireFrame(input, "complete");
85698
+ const updated = await this.deps.frames.setComplete(frame.frameId, input.complete);
83900
85699
  if (updated === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} vanished`);
83901
85700
  this.deps.logger.info("retrain frame completeness set", {
83902
85701
  tags: { deviceId: input.deviceId },
83903
85702
  meta: {
83904
- trackId: input.trackId,
85703
+ trackId: frame.trackId,
83905
85704
  frameId: input.frameId,
83906
85705
  complete: input.complete
83907
85706
  }
@@ -83909,36 +85708,41 @@ var RetrainService = class {
83909
85708
  return toWireFrame(updated);
83910
85709
  }
83911
85710
  /** Un-select: annotations first, then the copy. Never the other way round —
83912
- * an annotation whose frame is gone is unopenable and unfixable. */
85711
+ * an annotation whose frame is gone is unopenable and unfixable. The frame's
85712
+ * collection memberships go with it. */
83913
85713
  async deselectFrame(input) {
83914
85714
  const frame = await this.deps.frames.get(input.frameId);
83915
85715
  if (frame === null) {
83916
85716
  this.deps.logger.warn("retrain deselect: no such copied frame", {
83917
85717
  tags: { deviceId: input.deviceId },
83918
- meta: {
83919
- trackId: input.trackId,
83920
- frameId: input.frameId
83921
- }
85718
+ meta: { frameId: input.frameId }
83922
85719
  });
83923
85720
  return {
83924
85721
  removed: false,
83925
85722
  removedAnnotations: 0
83926
85723
  };
83927
85724
  }
83928
- const removedAnnotations = await this.deps.annotations.deleteForFrame(input.frameId);
85725
+ await this.requireFrame(input, "deselect");
85726
+ return {
85727
+ removed: true,
85728
+ removedAnnotations: await this.removeFrame(frame)
85729
+ };
85730
+ }
85731
+ /** Remove a frame from the dataset: annotations, memberships, then the copy. */
85732
+ async removeFrame(frame) {
85733
+ const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
85734
+ await this.deps.memberships?.forgetFrame(frame.frameId);
83929
85735
  await this.deps.frames.deleteFrame(frame);
83930
85736
  this.deps.logger.info("retrain frame removed from the dataset", {
83931
- tags: { deviceId: input.deviceId },
85737
+ tags: { deviceId: frame.deviceId },
83932
85738
  meta: {
83933
- trackId: input.trackId,
83934
- frameId: input.frameId,
85739
+ origin: frame.origin,
85740
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
85741
+ frameId: frame.frameId,
83935
85742
  removedAnnotations
83936
85743
  }
83937
85744
  });
83938
- return {
83939
- removed: true,
83940
- removedAnnotations
83941
- };
85745
+ return removedAnnotations;
83942
85746
  }
83943
85747
  /** The copy's pixels plus its exact dimensions — the canvas needs both. */
83944
85748
  async getFrameImage(frameId) {
@@ -83978,7 +85782,7 @@ var RetrainService = class {
83978
85782
  };
83979
85783
  }
83980
85784
  const frame = await this.deps.frames.get(input.frameId);
83981
- if (frame === null) return {
85785
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
83982
85786
  kind: "refused",
83983
85787
  reason: "unknown-frame"
83984
85788
  };
@@ -84033,8 +85837,8 @@ var RetrainService = class {
84033
85837
  }
84034
85838
  });
84035
85839
  const owner = {
84036
- trackId: input.trackId,
84037
- deviceId: input.deviceId,
85840
+ trackId: frame.trackId,
85841
+ deviceId: frame.deviceId,
84038
85842
  mediaKey: input.frameId
84039
85843
  };
84040
85844
  return {
@@ -84057,8 +85861,12 @@ var RetrainService = class {
84057
85861
  belowThreshold: mapping.belowThreshold
84058
85862
  };
84059
85863
  }
84060
- async listAnnotations(trackId) {
84061
- return (await this.deps.annotations.listByTrack(trackId)).map(toWireAnnotation);
85864
+ /** The annotations of a track, or of named frames (a recording still has
85865
+ * no track). One of the two must be given. */
85866
+ async listAnnotations(input) {
85867
+ if (input.trackId !== void 0 && input.trackId !== "") return (await this.deps.annotations.listByTrack(input.trackId)).map(toWireAnnotation);
85868
+ if (input.frameIds !== void 0) return (await this.deps.annotations.listByFrames(input.frameIds)).map(toWireAnnotation);
85869
+ throw new RetrainAnnotationError("listRetrainAnnotations needs a trackId or frameIds");
84062
85870
  }
84063
85871
  /**
84064
85872
  * Replace every annotation on one frame.
@@ -84070,15 +85878,15 @@ var RetrainService = class {
84070
85878
  * believes they stored.
84071
85879
  */
84072
85880
  async saveAnnotations(input) {
84073
- if (await this.deps.frames.get(input.frameId) === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} does not exist — select it before annotating it`);
85881
+ const frame = await this.requireFrame(input, "save");
84074
85882
  for (const a of input.annotations) {
84075
85883
  if (!isRetrainMacroClass(a.macroClass)) throw new RetrainAnnotationError(`"${a.macroClass}" is not a macro class`);
84076
85884
  const violation = checkTierSeparation(a);
84077
85885
  if (violation !== null) throw new RetrainAnnotationError(`"${violation.value}" is a MACRO CLASS and cannot be a ${violation.field} — the tiers are separate (person / postman / Marco)`);
84078
85886
  }
84079
85887
  const drafts = input.annotations.map((a) => ({
84080
- trackId: input.trackId,
84081
- deviceId: input.deviceId,
85888
+ trackId: frame.trackId,
85889
+ deviceId: frame.deviceId,
84082
85890
  mediaKey: input.frameId,
84083
85891
  bbox: a.bbox,
84084
85892
  macroClass: a.macroClass,
@@ -84094,7 +85902,8 @@ var RetrainService = class {
84094
85902
  this.deps.logger.info("retrain annotations saved", {
84095
85903
  tags: { deviceId: input.deviceId },
84096
85904
  meta: {
84097
- trackId: input.trackId,
85905
+ origin: frame.origin,
85906
+ trackId: frame.trackId,
84098
85907
  frameId: input.frameId,
84099
85908
  subjects: saved.length - errors,
84100
85909
  modelErrors: errors
@@ -93496,6 +95305,14 @@ var RECORDED_STILL_PROFILE = "high";
93496
95305
  */
93497
95306
  var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
93498
95307
  /**
95308
+ * Width cap for a still added to the retrain dataset from the recording.
95309
+ *
95310
+ * Effectively none: a training frame is wanted at the camera's own resolution
95311
+ * (the same pixels a native key frame has), so the bound is only a ceiling
95312
+ * against a pathological stream, far above any camera on the fleet.
95313
+ */
95314
+ var RETRAIN_STILL_MAX_WIDTH = 7680;
95315
+ /**
93499
95316
  * Build the production ports.
93500
95317
  *
93501
95318
  * Every port swallows its own failure into the `null` the policy expects: a
@@ -107191,6 +109008,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107191
109008
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
107192
109009
  * until the stores are built. */
107193
109010
  retrain = null;
109011
+ retrainCollections = null;
109012
+ retrainLlmAssist = null;
109013
+ retrainDatasetVersions = null;
109014
+ retrainDatasets = null;
109015
+ retrainCollectionStore = null;
107194
109016
  retrainFrames = null;
107195
109017
  retrainAnnotations = null;
107196
109018
  /** The NC artifact plane (signed public links for notification media) and its
@@ -108986,6 +110808,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
108986
110808
  RetrainFrameStore.declare(api.settingsStore),
108987
110809
  RetrainAnnotationStore.declare(api.settingsStore),
108988
110810
  RetrainCollectionStore.declare(api.settingsStore),
110811
+ RetrainDatasetVersionStore.declare(api.settingsStore),
108989
110812
  OpsLogStore.declare(api.settingsStore),
108990
110813
  DebugNoteArchiveStore.declare(api.settingsStore),
108991
110814
  TrackingTapeIndexStore.declare(api.settingsStore),
@@ -109242,7 +111065,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109242
111065
  });
109243
111066
  this.retrainFrames = retrainFrames;
109244
111067
  this.retrainAnnotations = retrainAnnotations;
109245
- this.retrain = new RetrainService({
111068
+ const retrainCollectionStore = new RetrainCollectionStore({
111069
+ store: api.settingsStore,
111070
+ logger: logger.child("RetrainCollections"),
111071
+ now: () => Date.now(),
111072
+ newId: () => (0, node_crypto.randomUUID)()
111073
+ });
111074
+ this.retrainCollectionStore = retrainCollectionStore;
111075
+ const retrain = new RetrainService({
109246
111076
  tracks: {
109247
111077
  listStaging: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
109248
111078
  listDeviceIds: () => trackStore.listDeviceIds(),
@@ -109260,7 +111090,82 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109260
111090
  frames: retrainFrames,
109261
111091
  annotations: retrainAnnotations,
109262
111092
  runner: (input, nodeId) => nodeId === void 0 ? api.pipelineRunner.runStatelessStep.mutate(input) : api.pipelineRunner.runStatelessStep.mutate(input, require_clip_model_registry.nodePin(nodeId)),
109263
- logger: logger.child("Retrain")
111093
+ logger: logger.child("Retrain"),
111094
+ pipelineEvidence: {
111095
+ tracksAround: (deviceId, atMs) => trackStore.queryHistorical({
111096
+ deviceId,
111097
+ since: atMs - PIPELINE_TRACK_LOOKBACK_MS,
111098
+ until: atMs + PIPELINE_BOX_TOLERANCE_MS,
111099
+ limit: PIPELINE_TRACK_AROUND_LIMIT
111100
+ }),
111101
+ subjectBoxes: (trackId, deviceId) => mediaStore.listSubjectBoxesByOwner("track", trackId, deviceId)
111102
+ },
111103
+ memberships: retrainCollectionStore
111104
+ });
111105
+ this.retrain = retrain;
111106
+ const retrainStills = new RecordedStillService(buildRecordedStillPorts({
111107
+ api: this.ctx.api.recordingArchive,
111108
+ logger: logger.child("RetrainStills"),
111109
+ resolveFfmpeg: this.pinnedFfmpeg
111110
+ }));
111111
+ this.retrainCollections = new RetrainCollectionService({
111112
+ collections: retrainCollectionStore,
111113
+ frames: retrainFrames,
111114
+ annotations: retrainAnnotations,
111115
+ retrain,
111116
+ captureStill: (deviceId, epochMs) => retrainStills.recover(deviceId, epochMs, RETRAIN_STILL_MAX_WIDTH),
111117
+ logger: logger.child("RetrainCollections")
111118
+ });
111119
+ const retrainDatasetVersions = new RetrainDatasetVersionStore({
111120
+ store: api.settingsStore,
111121
+ logger: logger.child("RetrainDatasets")
111122
+ });
111123
+ this.retrainDatasetVersions = retrainDatasetVersions;
111124
+ this.retrainDatasets = new RetrainDatasetService({
111125
+ versions: retrainDatasetVersions,
111126
+ buildPlan: (request) => this.buildRetrainExportPlan(request),
111127
+ now: () => Date.now(),
111128
+ newId: () => (0, node_crypto.randomUUID)(),
111129
+ logger: logger.child("RetrainDatasets")
111130
+ });
111131
+ this.retrainLlmAssist = new RetrainLlmAssistService({
111132
+ frames: retrainFrames,
111133
+ llm: {
111134
+ listProfiles: async () => (await api.llm.listProfiles.query({})).map((p) => ({
111135
+ id: p.id,
111136
+ name: p.name,
111137
+ model: p.model,
111138
+ kind: p.kind,
111139
+ ...p.baseUrl !== void 0 ? { baseUrl: p.baseUrl } : {},
111140
+ enabled: p.enabled,
111141
+ supportsVision: p.supportsVision
111142
+ })),
111143
+ generateVision: async (request) => {
111144
+ const result = await api.llm.generateVision.mutate({
111145
+ profileId: request.profileId,
111146
+ consumer: RETRAIN_LLM_CONSUMER,
111147
+ system: request.system,
111148
+ prompt: request.prompt,
111149
+ jsonSchema: request.jsonSchema,
111150
+ images: [{
111151
+ bytes: new Uint8Array(request.image),
111152
+ mimeType: "image/jpeg"
111153
+ }],
111154
+ temperature: 0,
111155
+ timeoutMs: RETRAIN_LLM_TIMEOUT_MS
111156
+ });
111157
+ return result.ok ? {
111158
+ ok: true,
111159
+ text: result.text,
111160
+ model: result.model
111161
+ } : {
111162
+ ok: false,
111163
+ code: result.code,
111164
+ message: result.message
111165
+ };
111166
+ }
111167
+ },
111168
+ logger: logger.child("RetrainLlmAssist")
109264
111169
  });
109265
111170
  this.mediaRelocate = new MediaRelocateEngine({
109266
111171
  store: api.settingsStore,
@@ -110444,14 +112349,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110444
112349
  };
110445
112350
  return this.requireRetrain().propose({
110446
112351
  deviceId: input.deviceId,
110447
- trackId: input.trackId,
112352
+ ...input.trackId !== void 0 ? { trackId: input.trackId } : {},
110448
112353
  frameId: input.frameId,
110449
112354
  subject,
110450
112355
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
110451
112356
  });
110452
112357
  }
110453
112358
  async listRetrainAnnotations(input) {
110454
- return this.requireRetrain().listAnnotations(input.trackId);
112359
+ return this.requireRetrain().listAnnotations(input);
110455
112360
  }
110456
112361
  async saveRetrainAnnotations(input) {
110457
112362
  return this.requireRetrain().saveAnnotations(input);
@@ -110475,6 +112380,69 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110475
112380
  async setRetrainFrameComplete(input) {
110476
112381
  return this.requireRetrain().setFrameComplete(input);
110477
112382
  }
112383
+ requireRetrainDatasets() {
112384
+ const service = this.retrainDatasets;
112385
+ if (service === null) throw new Error("retrain datasets: analytics stores unavailable");
112386
+ return service;
112387
+ }
112388
+ async previewRetrainDataset(input) {
112389
+ return this.requireRetrainDatasets().preview({
112390
+ selection: input.selection,
112391
+ completeOnly: input.completeOnly ?? false
112392
+ });
112393
+ }
112394
+ async createRetrainDatasetVersion(input) {
112395
+ return this.requireRetrainDatasets().create(input);
112396
+ }
112397
+ async listRetrainDatasetVersions(_input) {
112398
+ return this.requireRetrainDatasets().list();
112399
+ }
112400
+ async deleteRetrainDatasetVersion(input) {
112401
+ return this.requireRetrainDatasets().delete(input);
112402
+ }
112403
+ async verifyRetrainDatasetVersion(input) {
112404
+ return this.requireRetrainDatasets().verify(input);
112405
+ }
112406
+ requireRetrainLlmAssist() {
112407
+ const service = this.retrainLlmAssist;
112408
+ if (service === null) throw new Error("retrain AI assist: analytics stores unavailable");
112409
+ return service;
112410
+ }
112411
+ async listRetrainAssistProfiles(_input) {
112412
+ return this.requireRetrainLlmAssist().listProfiles();
112413
+ }
112414
+ async proposeRetrainAnnotationsWithLlm(input) {
112415
+ return this.requireRetrainLlmAssist().propose(input);
112416
+ }
112417
+ requireRetrainCollections() {
112418
+ const service = this.retrainCollections;
112419
+ if (service === null) throw new Error("retrain collections: analytics stores unavailable");
112420
+ return service;
112421
+ }
112422
+ async createRetrainCollection(input) {
112423
+ return this.requireRetrainCollections().create(input);
112424
+ }
112425
+ async listRetrainCollections(_input) {
112426
+ return this.requireRetrainCollections().list();
112427
+ }
112428
+ async renameRetrainCollection(input) {
112429
+ return this.requireRetrainCollections().rename(input);
112430
+ }
112431
+ async deleteRetrainCollection(input) {
112432
+ return this.requireRetrainCollections().delete(input);
112433
+ }
112434
+ async addRetrainFrameFromRecording(input) {
112435
+ return this.requireRetrainCollections().addFrameFromRecording(input);
112436
+ }
112437
+ async addRetrainFramesToCollection(input) {
112438
+ return this.requireRetrainCollections().addFrames(input);
112439
+ }
112440
+ async removeRetrainFrameFromCollection(input) {
112441
+ return this.requireRetrainCollections().removeFrame(input);
112442
+ }
112443
+ async listRetrainCollectionFrames(input) {
112444
+ return this.requireRetrainCollections().listFrames(input);
112445
+ }
110478
112446
  /**
110479
112447
  * Stream the annotated archive into ONE HTTP request to `target` — the
110480
112448
  * training upload. Same bytes as the download; never on the hub's disk.
@@ -110825,12 +112793,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110825
112793
  * zones simply has no `zoneCrop` geometry — absent, never a clamped guess.
110826
112794
  */
110827
112795
  async buildRetrainExportPlan(request) {
112796
+ const versionId = request.filter.datasetVersionId;
112797
+ if (versionId !== void 0) {
112798
+ const version = await this.retrainDatasetVersions?.get(versionId) ?? null;
112799
+ const { datasetVersionId: _resolved, ...rest } = await this.retrainDatasets?.exportFilterFor(request.filter) ?? {
112800
+ ...request.filter,
112801
+ frameIds: []
112802
+ };
112803
+ const plan = await this.buildRetrainExportPlan({
112804
+ ...request,
112805
+ filter: rest
112806
+ });
112807
+ return version === null ? plan : {
112808
+ ...plan,
112809
+ manifest: withDatasetVersion(plan.manifest, version)
112810
+ };
112811
+ }
110828
112812
  const frameStore = this.retrainFrames;
110829
112813
  const annotationStore = this.retrainAnnotations;
110830
112814
  const trackStore = this.trackStore;
110831
112815
  if (!frameStore || !annotationStore || !trackStore) throw new Error("retrain export: analytics stores unavailable");
110832
112816
  const deviceIds = request.filter.deviceIds ?? [];
110833
- const devices = deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds();
112817
+ const devices = deviceIds.length > 0 ? deviceIds : [...new Set([...await trackStore.listDeviceIds(), ...await frameStore.listDeviceIds()])];
110834
112818
  const candidates = /* @__PURE__ */ new Map();
110835
112819
  const annotationsByFrame = /* @__PURE__ */ new Map();
110836
112820
  const zones = /* @__PURE__ */ new Map();
@@ -110847,11 +112831,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110847
112831
  const frame = await frameStore.get(frameId);
110848
112832
  if (frame !== null) candidates.set(frameId, frame);
110849
112833
  }
110850
- const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter);
112834
+ const collectionsByFrame = await this.retrainCollectionStore?.collectionsOf([...candidates.keys()]) ?? /* @__PURE__ */ new Map();
112835
+ const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter, collectionsByFrame);
110851
112836
  return {
110852
112837
  manifest: buildRetrainManifest({
110853
112838
  frames,
110854
112839
  annotationsByFrame,
112840
+ collectionsByFrame,
110855
112841
  zoneForDevice: (deviceId) => zones.get(deviceId) ?? null,
110856
112842
  generatedAt: Date.now(),
110857
112843
  batchId: (0, node_crypto.randomUUID)(),