@camstack/addon-post-analysis 1.2.342 → 1.2.344

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-Dhjpee-Q.mjs → clip-model-registry-C44gfwjm.mjs} +2279 -62
  2. package/dist/{clip-model-registry-ClEoe-WP.js → clip-model-registry-Ca_IbZyD.js} +2290 -61
  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-XzFWnlu-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D73dyYqt.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-C-2BbuN4.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-ZZJLA-YE.mjs +26 -0
  9. package/dist/pipeline-analytics/{hostInit-C6P0R93E.mjs → hostInit-h_8fvfFv.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +2420 -359
  11. package/dist/pipeline-analytics/index.mjs +2412 -351
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +2 -2
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CsAksj5u.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-Cp4eQz57.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-ClEoe-WP.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-Ca_IbZyD.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");
@@ -23,6 +23,8 @@ let node_timers_promises = require("node:timers/promises");
23
23
  let node_util = require("node:util");
24
24
  let node_buffer = require("node:buffer");
25
25
  let node_zlib = require("node:zlib");
26
+ let node_http = require("node:http");
27
+ let node_https = require("node:https");
26
28
  //#region src/pipeline-analytics/media-occupancy.ts
27
29
  /** The bare type ref legacy NULL-stamped media rows belong to. */
28
30
  var LEGACY_MEDIA_TYPE = "eventMedia";
@@ -5837,16 +5839,16 @@ function ncDetectionSummary(texts, counts) {
5837
5839
  //#region src/notification-center/timelapse/detection-counts.ts
5838
5840
  /** One normalisation, used on both sides of every comparison — a rule that
5839
5841
  * named `Person` must match a track classified `person`. */
5840
- function normalise(name) {
5842
+ function normalise$1(name) {
5841
5843
  return name.trim().toLowerCase();
5842
5844
  }
5843
5845
  /** Count per class over the tracks, honouring the rule's selection. */
5844
5846
  function countDetections(input) {
5845
- const selected = (input.reportClasses ?? []).map(normalise).filter((c) => c.length > 0);
5847
+ const selected = (input.reportClasses ?? []).map(normalise$1).filter((c) => c.length > 0);
5846
5848
  const seen = /* @__PURE__ */ new Map();
5847
5849
  for (const t of input.tracks) {
5848
5850
  if (t.className === void 0) continue;
5849
- const key = normalise(t.className);
5851
+ const key = normalise$1(t.className);
5850
5852
  if (key.length === 0) continue;
5851
5853
  if (selected.length > 0 && !selected.includes(key)) continue;
5852
5854
  seen.set(key, (seen.get(key) ?? 0) + 1);
@@ -7028,18 +7030,44 @@ function clockCondition(now) {
7028
7030
  * does not.
7029
7031
  */
7030
7032
  function resolveCondition(input) {
7031
- if (input.forcedMode === "day" || input.forcedMode === "night") return input.forcedMode;
7032
- if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return IR;
7033
+ return resolveConditionDetailed(input).condition;
7034
+ }
7035
+ /**
7036
+ * {@link resolveCondition}, plus the layer that answered. A consumer that
7037
+ * reports a day/night split (the retrain export, D785) must be able to tell a
7038
+ * measured IR frame from an almanac or a clock guess.
7039
+ */
7040
+ function resolveConditionDetailed(input) {
7041
+ if (input.forcedMode === "day" || input.forcedMode === "night") return {
7042
+ condition: input.forcedMode,
7043
+ source: "forced"
7044
+ };
7045
+ if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return {
7046
+ condition: IR,
7047
+ source: "ir-chroma"
7048
+ };
7033
7049
  if (input.coordinates) {
7034
7050
  const times = sunTimes(input.coordinates.lat, input.coordinates.lng, input.now);
7035
7051
  if (times) {
7036
7052
  const t = input.now.getTime();
7037
- if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return DAWN;
7038
- if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return DUSK;
7039
- return t >= times.rise && t < times.set ? DAY : NIGHT;
7053
+ if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return {
7054
+ condition: DAWN,
7055
+ source: "sun"
7056
+ };
7057
+ if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return {
7058
+ condition: DUSK,
7059
+ source: "sun"
7060
+ };
7061
+ return {
7062
+ condition: t >= times.rise && t < times.set ? DAY : NIGHT,
7063
+ source: "sun"
7064
+ };
7040
7065
  }
7041
7066
  }
7042
- return clockCondition(input.now);
7067
+ return {
7068
+ condition: clockCondition(input.now),
7069
+ source: "clock"
7070
+ };
7043
7071
  }
7044
7072
  //#endregion
7045
7073
  //#region src/shared/llm-vision/parse-model-json.ts
@@ -7172,8 +7200,8 @@ async function raceTimeout(call, timeoutMs) {
7172
7200
  * detail the encoder can see.
7173
7201
  */
7174
7202
  async function downscaleJpeg(bytes, maxPx) {
7175
- const { default: sharp$19 } = await import("sharp");
7176
- const out = await sharp$19(Buffer.from(bytes)).resize({
7203
+ const { default: sharp$20 } = await import("sharp");
7204
+ const out = await sharp$20(Buffer.from(bytes)).resize({
7177
7205
  width: maxPx,
7178
7206
  height: maxPx,
7179
7207
  fit: "inside",
@@ -19384,8 +19412,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
19384
19412
  ];
19385
19413
  if (bbox === void 0) return files;
19386
19414
  try {
19387
- const { default: sharp$18 } = await import("sharp");
19388
- const meta = await sharp$18(Buffer.from(jpeg)).metadata();
19415
+ const { default: sharp$19 } = await import("sharp");
19416
+ const meta = await sharp$19(Buffer.from(jpeg)).metadata();
19389
19417
  const W = meta.width ?? 0;
19390
19418
  const H = meta.height ?? 0;
19391
19419
  if (W === 0 || H === 0) return files;
@@ -19393,7 +19421,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
19393
19421
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
19394
19422
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
19395
19423
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
19396
- const cropBuf = await sharp$18(Buffer.from(jpeg)).extract({
19424
+ const cropBuf = await sharp$19(Buffer.from(jpeg)).extract({
19397
19425
  left,
19398
19426
  top,
19399
19427
  width,
@@ -19415,7 +19443,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
19415
19443
  });
19416
19444
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
19417
19445
  const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
19418
- const boxedBuf = await sharp$18(Buffer.from(jpeg)).composite([{
19446
+ const boxedBuf = await sharp$19(Buffer.from(jpeg)).composite([{
19419
19447
  input: overlay,
19420
19448
  top: 0,
19421
19449
  left: 0
@@ -26452,8 +26480,8 @@ var NcDispatcher = class {
26452
26480
  try {
26453
26481
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
26454
26482
  if (points.length === 0) return null;
26455
- const { default: sharp$17 } = await import("sharp");
26456
- const img = sharp$17(Buffer.from(jpeg));
26483
+ const { default: sharp$18 } = await import("sharp");
26484
+ const img = sharp$18(Buffer.from(jpeg));
26457
26485
  const meta = await img.metadata();
26458
26486
  const W = meta.width ?? 0;
26459
26487
  const H = meta.height ?? 0;
@@ -70560,8 +70588,11 @@ var TrackStore = class {
70560
70588
  * RetrainFrameStore — the COPIES the retrain dataset owns.
70561
70589
  *
70562
70590
  * Collection: `pipeline-analytics:retrain-frames`.
70563
- * Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg`, on the default media
70564
- * location, under a path prefix NO owner cascade and NO age sweep walks.
70591
+ * Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg` under the `artifacts`
70592
+ * class root — the dataset is that class's fourth tenant (D341), addressed
70593
+ * through the permanent row ref {@link RETRAIN_MEDIA_LOCATION}. Never the
70594
+ * footage volume: the 2026-08-17 recordings wipe took the whole dataset with
70595
+ * it while it lived on `eventMedia` (D197). See D781.
70565
70596
  *
70566
70597
  * ## Why a copy, and why at selection time
70567
70598
  *
@@ -70588,20 +70619,43 @@ var TrackStore = class {
70588
70619
  */
70589
70620
  /**
70590
70621
  * @durable class=ledger owner=pipeline-analytics
70591
- * write="one row per frame the operator SELECTS into the retrain dataset. The
70592
- * copy is taken eagerly at selection (copyFrame), not referenced, so the row
70593
- * records a blob the dataset owns outright; re-selecting the same source reuses
70594
- * the existing copy rather than writing a second one."
70595
- * retention="none — the blobs sit under a 'retrain' prefix no owner cascade and no
70596
- * age sweep walks, which is exactly what makes a 'trained' track evictable
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."
70628
+ * retention="none — the blobs sit in the 'retrain/' subtree of the artifacts class
70629
+ * (D781), which no owner cascade and no age sweep walks, which is exactly what makes a 'trained' track evictable
70597
70630
  * again (D81). Rows go only when the operator deselects the frame (deleteFrame,
70598
70631
  * after its annotations). Bounded only by operator selection — nothing caps the
70599
70632
  * table or the disk it consumes."
70600
70633
  */
70601
70634
  var RETRAIN_FRAMES_COLLECTION = "pipeline-analytics:retrain-frames";
70602
- /** Storage location for retrain copies — the same default media root the event
70603
- * media lives on, under a prefix nothing else enumerates. */
70604
- var RETRAIN_MEDIA_LOCATION = "eventMedia";
70635
+ /**
70636
+ * The PERMANENT location ref every retrain copy is written under (D781).
70637
+ *
70638
+ * Not a declared storage class: it is the rows' `locationId`, which the
70639
+ * media-storage wiring maps onto the `artifacts` class root — exactly how the
70640
+ * enrolled gallery's `galleryMedia` ref works (D341). The copies are curation
70641
+ * work with NO retention, so they share the one durable class rather than the
70642
+ * footage class, which is quota-evicted, drained and wipeable per disk. Rows
70643
+ * are never restamped for a root move: the ref is remapped instead, and the
70644
+ * singleton repoint carries the whole `artifacts` tree, `retrain/` included.
70645
+ */
70646
+ var RETRAIN_MEDIA_LOCATION = "retrainMedia";
70647
+ /**
70648
+ * Where copies taken before D781 live: a NULL `locationId` meant "wherever
70649
+ * `eventMedia` points", and a concrete `eventMedia:*` id was stamped by the
70650
+ * seal or a relocate pass. The boot reconcile
70651
+ * ({@link RetrainFrameStore.relocateLegacyCopies}) moves them onto
70652
+ * {@link RETRAIN_MEDIA_LOCATION}; until it has, reads resolve them here.
70653
+ */
70654
+ var LEGACY_RETRAIN_MEDIA_LOCATION = "eventMedia";
70655
+ /** Is this row already on the durable retrain tenant? */
70656
+ function rowIsDurableRetrainCopy(data) {
70657
+ return data?.["locationId"] === RETRAIN_MEDIA_LOCATION;
70658
+ }
70605
70659
  /** Path prefix of every retrain copy. Never `<deviceId>/events/…`: the orphan
70606
70660
  * audit resolves ownership from INDEX rows, but a future filesystem-side tool
70607
70661
  * must be able to tell the two corpora apart by path alone. */
@@ -70659,11 +70713,45 @@ var RETRAIN_FRAME_COLUMNS = [
70659
70713
  notNull: true
70660
70714
  },
70661
70715
  (
70662
- /** NULL = current eventMedia default; stamped when migration moves a copy
70663
- * before the logical default is repointed. */
70716
+ /** `retrainMedia` for every copy taken since D781. NULL (the eventMedia
70717
+ * default) or a concrete `eventMedia:*` id only on a pre-D781 copy the boot
70718
+ * reconcile has not moved yet. */
70664
70719
  {
70665
70720
  name: "locationId",
70666
70721
  type: "TEXT"
70722
+ }),
70723
+ (
70724
+ /** When the CAMERA captured the frame — the source media row's timestamp.
70725
+ * NULL on a copy taken before D785. */
70726
+ {
70727
+ name: "capturedAt",
70728
+ type: "INTEGER"
70729
+ }),
70730
+ (
70731
+ /** Mean channel spread of the copy (`chromaSpread`), measured once at copy
70732
+ * time; below `IR_CHROMA_THRESHOLD` the frame is IR. NULL = not measured. */
70733
+ {
70734
+ name: "chromaSpread",
70735
+ type: "REAL"
70736
+ }),
70737
+ (
70738
+ /** The operator's "every subject in this frame is boxed". NULL = no. */
70739
+ {
70740
+ name: "complete",
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"
70667
70755
  })
70668
70756
  ];
70669
70757
  var RETRAIN_FRAME_INDEXES = [
@@ -70689,6 +70777,8 @@ var RETRAIN_FRAME_INDEXES = [
70689
70777
  columns: ["locationId"]
70690
70778
  })
70691
70779
  ];
70780
+ /** Ids per `IN (…)` page in {@link RetrainFrameStore.getMany}. */
70781
+ var GET_MANY_PAGE = 500;
70692
70782
  /**
70693
70783
  * The copy's id, derived from `(trackId, sourceMediaId)`.
70694
70784
  *
@@ -70701,8 +70791,24 @@ var RETRAIN_FRAME_INDEXES = [
70701
70791
  function retrainFrameId(trackId, sourceMediaId) {
70702
70792
  return `retrain:${trackId}:${(0, node_crypto.createHash)("sha1").update(String(sourceMediaId)).digest("hex").slice(0, 16)}`;
70703
70793
  }
70704
- function framePath(deviceId, trackId, frameId) {
70705
- 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`;
70808
+ }
70809
+ /** A stored number, or `undefined` for NULL / absent / junk. */
70810
+ function finiteNumber(value) {
70811
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
70706
70812
  }
70707
70813
  var RetrainFrameStore = class {
70708
70814
  store;
@@ -70711,7 +70817,7 @@ var RetrainFrameStore = class {
70711
70817
  readSource;
70712
70818
  now;
70713
70819
  writeGate;
70714
- resolveEventMediaLocation;
70820
+ measureChroma;
70715
70821
  constructor(deps) {
70716
70822
  this.store = deps.store;
70717
70823
  this.storage = deps.storage;
@@ -70719,28 +70825,20 @@ var RetrainFrameStore = class {
70719
70825
  this.readSource = deps.readSource;
70720
70826
  this.now = deps.now;
70721
70827
  this.writeGate = deps.writeGate;
70722
- this.resolveEventMediaLocation = deps.resolveEventMediaLocation;
70828
+ this.measureChroma = deps.measureChroma;
70723
70829
  }
70724
- /**
70725
- * The location a NEW copy is written to, and the id its row NAMES.
70726
- *
70727
- * `null` = "no concrete location was resolved", which persists as the legacy
70728
- * NULL row. A resolver that throws must not refuse the copy — losing an
70729
- * operator's selection is worse than an unstamped row the seal can fix — but
70730
- * it says so, because that branch re-creates the exact population a
70731
- * non-blocking migration is gated on.
70732
- */
70733
- async resolveWriteLocation(deviceId) {
70734
- if (!this.resolveEventMediaLocation) return null;
70830
+ /** The copy's chroma, or `undefined` when it cannot be measured. */
70831
+ async chromaOf(bytes, deviceId) {
70832
+ if (!this.measureChroma) return void 0;
70735
70833
  try {
70736
- const resolved = await this.resolveEventMediaLocation(deviceId);
70737
- return resolved === RETRAIN_MEDIA_LOCATION ? null : resolved;
70834
+ const value = await this.measureChroma(bytes);
70835
+ return Number.isFinite(value) ? value : void 0;
70738
70836
  } catch (err) {
70739
- this.logger.warn("retrain copy could not resolve its eventMedia location — the row is written UNSTAMPED and a storage repoint would re-aim it at a disk that does not hold it", {
70837
+ this.logger.warn("retrain copy chroma could not be measured — its day/night falls back to the sun", {
70740
70838
  tags: { deviceId },
70741
70839
  meta: { error: String(err) }
70742
70840
  });
70743
- return null;
70841
+ return;
70744
70842
  }
70745
70843
  }
70746
70844
  static async declare(store) {
@@ -70751,9 +70849,11 @@ var RetrainFrameStore = class {
70751
70849
  });
70752
70850
  }
70753
70851
  rowToFrame(id, data) {
70852
+ const origin = data["origin"] === "recording" ? "recording" : "track";
70754
70853
  return {
70755
70854
  frameId: id,
70756
70855
  deviceId: Number(data["deviceId"] ?? 0),
70856
+ origin,
70757
70857
  trackId: String(data["trackId"] ?? ""),
70758
70858
  sourceMediaId: Number(data["sourceMediaId"] ?? 0),
70759
70859
  sourceKind: String(data["sourceKind"] ?? "keyFrame"),
@@ -70761,9 +70861,57 @@ var RetrainFrameStore = class {
70761
70861
  width: Number(data["width"] ?? 0),
70762
70862
  height: Number(data["height"] ?? 0),
70763
70863
  copiedAt: Number(data["copiedAt"] ?? 0),
70764
- locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
70864
+ locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null,
70865
+ ...finiteNumber(data["capturedAt"]) !== void 0 ? { capturedAt: finiteNumber(data["capturedAt"]) } : {},
70866
+ ...finiteNumber(data["chromaSpread"]) !== void 0 ? { chromaSpread: finiteNumber(data["chromaSpread"]) } : {},
70867
+ complete: data["complete"] === true || data["complete"] === 1,
70868
+ ...finiteNumber(data["epochMs"]) !== void 0 ? { epochMs: finiteNumber(data["epochMs"]) } : {}
70765
70869
  };
70766
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
+ }
70767
70915
  /** The copy with this id, or `null`. Index read only — no blob is touched. */
70768
70916
  async get(frameId) {
70769
70917
  try {
@@ -70780,8 +70928,10 @@ var RetrainFrameStore = class {
70780
70928
  return null;
70781
70929
  }
70782
70930
  }
70783
- /** 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. */
70784
70933
  async listByTrack(trackId) {
70934
+ if (trackId === "") return [];
70785
70935
  try {
70786
70936
  return (await this.store.query.query({
70787
70937
  collection: RETRAIN_FRAMES_COLLECTION,
@@ -70801,6 +70951,31 @@ var RetrainFrameStore = class {
70801
70951
  return [];
70802
70952
  }
70803
70953
  }
70954
+ /**
70955
+ * The copies of one camera the operator marked complete. A complete frame
70956
+ * with no box is a hard negative, so an export cannot find these through
70957
+ * the annotation table alone.
70958
+ */
70959
+ async listCompleteByDevice(deviceId, limit) {
70960
+ try {
70961
+ return (await this.store.query.query({
70962
+ collection: RETRAIN_FRAMES_COLLECTION,
70963
+ filter: {
70964
+ where: {
70965
+ deviceId,
70966
+ complete: true
70967
+ },
70968
+ limit
70969
+ }
70970
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToFrame(r.id, r.data));
70971
+ } catch (err) {
70972
+ this.logger.warn("RetrainFrameStore.listCompleteByDevice failed", {
70973
+ tags: { deviceId },
70974
+ meta: { error: String(err) }
70975
+ });
70976
+ return [];
70977
+ }
70978
+ }
70804
70979
  /** How many copies each of these tracks already owns — one query, so the
70805
70980
  * staging list can badge "worked / not worked" without N round trips. */
70806
70981
  async countsByTrack(trackIds) {
@@ -70829,8 +71004,8 @@ var RetrainFrameStore = class {
70829
71004
  async readFrame(frame) {
70830
71005
  try {
70831
71006
  return await this.storage.read({
70832
- location: frame.locationId ?? RETRAIN_MEDIA_LOCATION,
70833
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
71007
+ location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
71008
+ relativePath: framePath(frame)
70834
71009
  });
70835
71010
  } catch (err) {
70836
71011
  this.logger.warn("retrain frame copy unreadable", {
@@ -70899,11 +71074,17 @@ var RetrainFrameStore = class {
70899
71074
  reason: "unreadable-image"
70900
71075
  };
70901
71076
  }
70902
- const path = framePath(input.deviceId, input.trackId, frameId);
70903
- const locationId = await this.resolveWriteLocation(input.deviceId);
71077
+ const path = framePath({
71078
+ ...input,
71079
+ frameId,
71080
+ origin: "track"
71081
+ });
71082
+ const locationId = RETRAIN_MEDIA_LOCATION;
71083
+ const chromaSpread = await this.chromaOf(bytes, input.deviceId);
70904
71084
  const frame = {
70905
71085
  frameId,
70906
71086
  deviceId: input.deviceId,
71087
+ origin: "track",
70907
71088
  trackId: input.trackId,
70908
71089
  sourceMediaId: input.sourceMediaId,
70909
71090
  sourceKind: input.sourceKind,
@@ -70911,11 +71092,14 @@ var RetrainFrameStore = class {
70911
71092
  width: size.width,
70912
71093
  height: size.height,
70913
71094
  copiedAt: this.now(),
70914
- locationId
71095
+ locationId,
71096
+ ...input.capturedAt !== void 0 ? { capturedAt: input.capturedAt } : {},
71097
+ ...chromaSpread !== void 0 ? { chromaSpread } : {},
71098
+ complete: false
70915
71099
  };
70916
71100
  const persist = async () => {
70917
71101
  await this.storage.write({
70918
- location: locationId ?? RETRAIN_MEDIA_LOCATION,
71102
+ location: locationId,
70919
71103
  relativePath: path,
70920
71104
  data: bytes
70921
71105
  });
@@ -70932,7 +71116,10 @@ var RetrainFrameStore = class {
70932
71116
  width: frame.width,
70933
71117
  height: frame.height,
70934
71118
  copiedAt: frame.copiedAt,
70935
- locationId: frame.locationId
71119
+ locationId: frame.locationId,
71120
+ capturedAt: frame.capturedAt ?? null,
71121
+ chromaSpread: frame.chromaSpread ?? null,
71122
+ complete: false
70936
71123
  }
70937
71124
  });
70938
71125
  };
@@ -70972,6 +71159,136 @@ var RetrainFrameStore = class {
70972
71159
  };
70973
71160
  }
70974
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
+ /**
71271
+ * The operator's "every subject in this frame is boxed" (D785 context: an
71272
+ * unboxed subject is a false positive to a detector evaluation). Only the
71273
+ * operator sets it; nothing derives it from the annotation count, because a
71274
+ * frame with zero boxes may be a complete hard negative.
71275
+ *
71276
+ * @returns the updated frame, or `null` when no such copy exists.
71277
+ */
71278
+ async setComplete(frameId, complete) {
71279
+ const existing = await this.get(frameId);
71280
+ if (existing === null) return null;
71281
+ await this.store.update.mutate({
71282
+ collection: RETRAIN_FRAMES_COLLECTION,
71283
+ id: frameId,
71284
+ data: { complete }
71285
+ });
71286
+ return {
71287
+ ...existing,
71288
+ complete
71289
+ };
71290
+ }
71291
+ /**
70975
71292
  * Drop a copy and its blob — the operator un-selecting a frame.
70976
71293
  *
70977
71294
  * The caller must have removed the frame's annotations first; this store does
@@ -70982,8 +71299,8 @@ var RetrainFrameStore = class {
70982
71299
  const remove = async () => {
70983
71300
  try {
70984
71301
  await this.storage.delete({
70985
- location: frame.locationId ?? RETRAIN_MEDIA_LOCATION,
70986
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
71302
+ location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
71303
+ relativePath: framePath(frame)
70987
71304
  });
70988
71305
  } catch {}
70989
71306
  try {
@@ -71004,6 +71321,95 @@ var RetrainFrameStore = class {
71004
71321
  if (this.writeGate) await this.writeGate.run(remove);
71005
71322
  else await remove();
71006
71323
  }
71324
+ /**
71325
+ * Move every copy still on the footage class onto the durable retrain
71326
+ * tenant (D781): copy → read back → restamp → delete the source.
71327
+ *
71328
+ * A reconcile, not a one-shot: one query of a collection bounded by
71329
+ * operator selection, so it is cheap when converged, and it is what would
71330
+ * notice a pre-D781 copy restored from anywhere. The order is the gallery
71331
+ * reconcile's (D341) and for the same reason: the row is restamped only
71332
+ * after the new blob reads back, so a crash at any point leaves a row that
71333
+ * names a location holding its bytes.
71334
+ *
71335
+ * A copy whose bytes are already gone is COUNTED and LOGGED, never deleted:
71336
+ * its annotations are the operator's work, and only the operator decides
71337
+ * to drop them (deselecting the frame).
71338
+ */
71339
+ async relocateLegacyCopies() {
71340
+ const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
71341
+ let relocated = 0;
71342
+ let missing = 0;
71343
+ let failed = 0;
71344
+ for (const row of rows) {
71345
+ if (typeof row.id !== "string") continue;
71346
+ const data = row.data;
71347
+ if (rowIsDurableRetrainCopy(data)) continue;
71348
+ const frame = this.rowToFrame(row.id, data);
71349
+ const from = frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION;
71350
+ const relativePath = framePath(frame);
71351
+ let bytes;
71352
+ try {
71353
+ bytes = await this.storage.read({
71354
+ location: from,
71355
+ relativePath
71356
+ });
71357
+ } catch (err) {
71358
+ missing += 1;
71359
+ this.logger.warn("retrain copy has NO bytes on the footage class — its row and annotations are kept; deselect the frame to drop it", {
71360
+ tags: { deviceId: frame.deviceId },
71361
+ meta: {
71362
+ frameId: frame.frameId,
71363
+ trackId: frame.trackId,
71364
+ from,
71365
+ error: String(err)
71366
+ }
71367
+ });
71368
+ continue;
71369
+ }
71370
+ const move = async () => {
71371
+ await this.storage.write({
71372
+ location: RETRAIN_MEDIA_LOCATION,
71373
+ relativePath,
71374
+ data: bytes
71375
+ });
71376
+ if (!(await this.storage.read({
71377
+ location: RETRAIN_MEDIA_LOCATION,
71378
+ relativePath
71379
+ })).equals(bytes)) throw new Error("read-back differs from the source");
71380
+ await this.store.update.mutate({
71381
+ collection: RETRAIN_FRAMES_COLLECTION,
71382
+ id: frame.frameId,
71383
+ data: { locationId: RETRAIN_MEDIA_LOCATION }
71384
+ });
71385
+ await this.storage.delete({
71386
+ location: from,
71387
+ relativePath
71388
+ });
71389
+ };
71390
+ try {
71391
+ if (this.writeGate) await this.writeGate.run(move);
71392
+ else await move();
71393
+ relocated += 1;
71394
+ } catch (err) {
71395
+ failed += 1;
71396
+ this.logger.error("retrain copy could not be moved off the footage class", {
71397
+ tags: { deviceId: frame.deviceId },
71398
+ meta: {
71399
+ frameId: frame.frameId,
71400
+ trackId: frame.trackId,
71401
+ from,
71402
+ error: String(err)
71403
+ }
71404
+ });
71405
+ }
71406
+ }
71407
+ return {
71408
+ relocated,
71409
+ missing,
71410
+ failed
71411
+ };
71412
+ }
71007
71413
  };
71008
71414
  //#endregion
71009
71415
  //#region src/pipeline-analytics/retention/location-class-wipe.ts
@@ -82108,6 +82514,17 @@ var RETRAIN_ANNOTATION_INDEXES = [
82108
82514
  columns: ["deviceId", "createdAt"]
82109
82515
  }
82110
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
+ }
82111
82528
  function optionalString(value) {
82112
82529
  return typeof value === "string" && value.length > 0 ? value : void 0;
82113
82530
  }
@@ -82115,6 +82532,8 @@ function optionalString(value) {
82115
82532
  function narrowMacroClass(value) {
82116
82533
  return typeof value === "string" && isRetrainMacroClass(value) ? value : "person";
82117
82534
  }
82535
+ /** Frame ids per `IN (…)` page in {@link RetrainAnnotationStore.listByFrames}. */
82536
+ var LIST_BY_FRAMES_PAGE = 500;
82118
82537
  var RetrainAnnotationStore = class {
82119
82538
  store;
82120
82539
  logger;
@@ -82151,7 +82570,7 @@ var RetrainAnnotationStore = class {
82151
82570
  label: optionalString(data["label"]),
82152
82571
  subLabel: optionalString(data["subLabel"]),
82153
82572
  kind: data["kind"] === "model_error" ? "model_error" : "subject",
82154
- source: data["source"] === "assist" ? "assist" : "operator",
82573
+ source: narrowSource(data["source"]),
82155
82574
  assistModelId: optionalString(data["assistModelId"]),
82156
82575
  assistScore: typeof score === "number" ? score : void 0,
82157
82576
  exportedInBatch: optionalString(data["exportedInBatch"]),
@@ -82220,6 +82639,32 @@ var RetrainAnnotationStore = class {
82220
82639
  async listByFrame(mediaKey) {
82221
82640
  return this.queryWhere({ mediaKey }, `frame ${mediaKey}`);
82222
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
+ }
82223
82668
  async listByTrack(trackId) {
82224
82669
  return this.queryWhere({ trackId }, `track ${trackId}`);
82225
82670
  }
@@ -82333,13 +82778,41 @@ var RetrainAnnotationStore = class {
82333
82778
  //#endregion
82334
82779
  //#region src/pipeline-analytics/retrain/retrain-collection-store.ts
82335
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
+ /**
82336
82797
  * @durable class=ledger owner=pipeline-analytics
82337
82798
  * write="one row per named training collection the operator creates. Names are
82338
- * unique on nameKey (lower(trim)); empty names never become rows."
82339
- * retention="none — operator-owned corpus metadata. Rows leave only when an
82340
- * 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."
82341
82804
  */
82342
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";
82343
82816
  var RETRAIN_COLLECTION_COLUMNS = [
82344
82817
  {
82345
82818
  name: "id",
@@ -82376,6 +82849,41 @@ var RETRAIN_COLLECTION_INDEXES = [{
82376
82849
  name: "idx_retrain_collections_created",
82377
82850
  columns: ["createdAt"]
82378
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
+ }];
82379
82887
  function normalizeCollectionName(raw) {
82380
82888
  const name = raw.trim();
82381
82889
  if (name.length === 0) return null;
@@ -82384,18 +82892,22 @@ function normalizeCollectionName(raw) {
82384
82892
  nameKey: name.toLowerCase()
82385
82893
  };
82386
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;
82387
82901
  var RetrainCollectionStore = class {
82388
82902
  store;
82389
82903
  logger;
82390
82904
  now;
82391
82905
  newId;
82392
- countFrames;
82393
82906
  constructor(deps) {
82394
82907
  this.store = deps.store;
82395
82908
  this.logger = deps.logger;
82396
82909
  this.now = deps.now;
82397
82910
  this.newId = deps.newId;
82398
- this.countFrames = deps.countFrames;
82399
82911
  }
82400
82912
  static async declare(store) {
82401
82913
  await store.declareCollection.mutate({
@@ -82403,6 +82915,11 @@ var RetrainCollectionStore = class {
82403
82915
  columns: [...RETRAIN_COLLECTION_COLUMNS],
82404
82916
  indexes: [...RETRAIN_COLLECTION_INDEXES]
82405
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
+ });
82406
82923
  }
82407
82924
  async create(input) {
82408
82925
  const normalized = normalizeCollectionName(input.name);
@@ -82410,7 +82927,7 @@ var RetrainCollectionStore = class {
82410
82927
  kind: "refused",
82411
82928
  reason: "empty-name"
82412
82929
  };
82413
- if (await this.findByNameKey(normalized.nameKey) !== null) return {
82930
+ if (await this.findIdByNameKey(normalized.nameKey) !== null) return {
82414
82931
  kind: "refused",
82415
82932
  reason: "duplicate-name"
82416
82933
  };
@@ -82426,6 +82943,7 @@ var RetrainCollectionStore = class {
82426
82943
  updatedAt: createdAt
82427
82944
  }
82428
82945
  });
82946
+ this.logger.info("retrain collection created", { meta: { collectionId: id } });
82429
82947
  return {
82430
82948
  kind: "created",
82431
82949
  collection: {
@@ -82436,6 +82954,7 @@ var RetrainCollectionStore = class {
82436
82954
  }
82437
82955
  };
82438
82956
  }
82957
+ /** Every collection, newest first, each with its membership count (one query). */
82439
82958
  async list() {
82440
82959
  let rows;
82441
82960
  try {
@@ -82450,34 +82969,158 @@ var RetrainCollectionStore = class {
82450
82969
  this.logger.warn("retrain collections list failed", { meta: { error: String(err) } });
82451
82970
  return [];
82452
82971
  }
82453
- const out = [];
82454
- for (const row of rows) {
82455
- const name = typeof row.data["name"] === "string" ? row.data["name"] : "";
82456
- const createdAt = Number(row.data["createdAt"] ?? 0);
82457
- const frameCount = this.countFrames !== void 0 ? await this.countFrames(row.id) : 0;
82458
- out.push({
82459
- id: row.id,
82460
- name,
82461
- createdAt,
82462
- frameCount
82463
- });
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.membershipRows({ collectionId: id });
83024
+ for (const m of members) await this.store.delete.mutate({
83025
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83026
+ key: m.id
83027
+ });
83028
+ await this.store.delete.mutate({
83029
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
83030
+ key: id
83031
+ });
83032
+ this.logger.info("retrain collection deleted", { meta: {
83033
+ collectionId: id,
83034
+ members: members.length
83035
+ } });
83036
+ return {
83037
+ deleted: true,
83038
+ memberFrameIds: members.map((m) => String(m.data["frameId"] ?? ""))
83039
+ };
83040
+ }
83041
+ /** Add a frame. `false` when it was already a member (nothing written). */
83042
+ async addFrame(input) {
83043
+ const key = membershipKey(input.collectionId, input.frameId);
83044
+ const existing = await this.store.get.query({
83045
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83046
+ key
83047
+ });
83048
+ if (existing !== null && existing !== void 0) return false;
83049
+ await this.store.set.mutate({
83050
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83051
+ key,
83052
+ value: {
83053
+ collectionId: input.collectionId,
83054
+ frameId: input.frameId,
83055
+ deviceId: input.deviceId,
83056
+ addedAt: this.now()
83057
+ }
83058
+ });
83059
+ return true;
83060
+ }
83061
+ /** Remove one membership. `false` when it did not exist. */
83062
+ async removeFrame(input) {
83063
+ const key = membershipKey(input.collectionId, input.frameId);
83064
+ const existing = await this.store.get.query({
83065
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83066
+ key
83067
+ });
83068
+ if (existing === null || existing === void 0) return false;
83069
+ await this.store.delete.mutate({
83070
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83071
+ key
83072
+ });
83073
+ return true;
83074
+ }
83075
+ /** Remove a frame from EVERY collection — the frame itself is leaving. */
83076
+ async forgetFrame(frameId) {
83077
+ const rows = await this.membershipRows({ frameId });
83078
+ for (const r of rows) await this.store.delete.mutate({
83079
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83080
+ key: r.id
83081
+ });
83082
+ return rows.length;
83083
+ }
83084
+ /** The frames of a collection, in the order they were added. */
83085
+ async listFrameIds(collectionId) {
83086
+ 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 !== "");
83087
+ }
83088
+ /** frameId → the collections it belongs to. Frames in none are absent. */
83089
+ async collectionsOf(frameIds) {
83090
+ const out = /* @__PURE__ */ new Map();
83091
+ for (const row of await this.membershipRowsIn("frameId", frameIds)) {
83092
+ const frameId = String(row.data["frameId"] ?? "");
83093
+ const collectionId = String(row.data["collectionId"] ?? "");
83094
+ if (frameId === "" || collectionId === "") continue;
83095
+ out.set(frameId, [...out.get(frameId) ?? [], collectionId]);
82464
83096
  }
83097
+ for (const [frameId, ids] of out) out.set(frameId, ids.toSorted());
82465
83098
  return out;
82466
83099
  }
82467
- async getById(id) {
83100
+ /** The frame ids of several collections, de-duplicated. */
83101
+ async frameIdsOf(collectionIds) {
83102
+ const ids = /* @__PURE__ */ new Set();
83103
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83104
+ const frameId = String(row.data["frameId"] ?? "");
83105
+ if (frameId !== "") ids.add(frameId);
83106
+ }
83107
+ return [...ids];
83108
+ }
83109
+ toCollection(id, data, frameCount) {
83110
+ return {
83111
+ id,
83112
+ name: typeof data["name"] === "string" ? data["name"] : "",
83113
+ createdAt: Number(data["createdAt"] ?? 0),
83114
+ frameCount
83115
+ };
83116
+ }
83117
+ async readRow(id) {
82468
83118
  try {
82469
83119
  const row = await this.store.get.query({
82470
83120
  collection: RETRAIN_COLLECTIONS_COLLECTION,
82471
83121
  key: id
82472
83122
  });
82473
- if (row === null || row === void 0) return null;
82474
- const data = row;
82475
- return {
82476
- id,
82477
- name: typeof data["name"] === "string" ? data["name"] : "",
82478
- createdAt: Number(data["createdAt"] ?? 0),
82479
- frameCount: this.countFrames !== void 0 ? await this.countFrames(id) : 0
82480
- };
83123
+ return row === null || row === void 0 ? null : { ...row };
82481
83124
  } catch (err) {
82482
83125
  this.logger.warn("retrain collection get failed", { meta: {
82483
83126
  collectionId: id,
@@ -82486,14 +83129,125 @@ var RetrainCollectionStore = class {
82486
83129
  return null;
82487
83130
  }
82488
83131
  }
83132
+ async countMembers(collectionIds) {
83133
+ const counts = /* @__PURE__ */ new Map();
83134
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83135
+ const id = String(row.data["collectionId"] ?? "");
83136
+ counts.set(id, (counts.get(id) ?? 0) + 1);
83137
+ }
83138
+ return counts;
83139
+ }
83140
+ async membershipRows(where) {
83141
+ try {
83142
+ return await this.store.query.query({
83143
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83144
+ filter: { where }
83145
+ });
83146
+ } catch (err) {
83147
+ this.logger.warn("retrain collection membership read failed", { meta: {
83148
+ ...where,
83149
+ error: String(err)
83150
+ } });
83151
+ return [];
83152
+ }
83153
+ }
83154
+ async membershipRowsIn(field, values) {
83155
+ const out = [];
83156
+ for (let i = 0; i < values.length; i += IN_PAGE) {
83157
+ const page = values.slice(i, i + IN_PAGE);
83158
+ try {
83159
+ out.push(...await this.store.query.query({
83160
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83161
+ filter: { whereIn: { [field]: [...page] } }
83162
+ }));
83163
+ } catch (err) {
83164
+ this.logger.warn("retrain collection membership read failed", { meta: {
83165
+ field,
83166
+ values: page.length,
83167
+ error: String(err)
83168
+ } });
83169
+ }
83170
+ }
83171
+ return out;
83172
+ }
83173
+ async findIdByNameKey(nameKey) {
83174
+ try {
83175
+ return (await this.store.query.query({
83176
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
83177
+ filter: {
83178
+ where: { nameKey },
83179
+ limit: 1
83180
+ }
83181
+ }))[0]?.id ?? null;
83182
+ } catch (err) {
83183
+ this.logger.warn("retrain collection findByNameKey failed", { meta: {
83184
+ nameKey,
83185
+ error: String(err)
83186
+ } });
83187
+ return null;
83188
+ }
83189
+ }
83190
+ };
83191
+ //#endregion
83192
+ //#region src/pipeline-analytics/retrain/retrain-collection-service.ts
83193
+ /** What each still outcome tells the operator. */
83194
+ var STILL_REFUSAL = {
83195
+ "no-recording": {
83196
+ reason: "still-unavailable",
83197
+ detail: "this camera is not recording, so there is no footage to take a frame from"
83198
+ },
83199
+ "no-footage": {
83200
+ reason: "still-unavailable",
83201
+ detail: "no recorded footage covers that instant (a gap, or already pruned)"
83202
+ },
83203
+ "decode-failed": {
83204
+ reason: "still-unavailable",
83205
+ detail: "the footage is there but the frame could not be decoded"
83206
+ },
83207
+ busy: {
83208
+ reason: "still-busy",
83209
+ detail: "the recorder is already decoding other frames — try again in a moment"
83210
+ }
83211
+ };
83212
+ var RetrainCollectionService = class {
83213
+ deps;
83214
+ constructor(deps) {
83215
+ this.deps = deps;
83216
+ }
83217
+ create(input) {
83218
+ return this.deps.collections.create(input);
83219
+ }
83220
+ list() {
83221
+ return this.deps.collections.list();
83222
+ }
83223
+ rename(input) {
83224
+ return this.deps.collections.rename(input);
83225
+ }
82489
83226
  /**
82490
- * Stub until still ingest lands. Validates the collection exists, then
82491
- * refuses with a named reason so callers can wire UI against a stable shape.
83227
+ * Delete a collection. Its recording stills that no other collection holds
83228
+ * go with it (annotations first); track copies always stay.
82492
83229
  */
82493
- async addRetrainFrameFromRecording(input) {
82494
- if (await this.getById(input.collectionId) === null) {
82495
- this.logger.info("retrain collection addFrame refused: unknown-collection", {
82496
- tags: { deviceId: input.deviceId },
83230
+ async delete(input) {
83231
+ const result = await this.deps.collections.delete(input.id);
83232
+ if (!result.deleted) return {
83233
+ deleted: false,
83234
+ removedFrames: 0
83235
+ };
83236
+ return {
83237
+ deleted: true,
83238
+ removedFrames: await this.dropOrphanStills(result.memberFrameIds)
83239
+ };
83240
+ }
83241
+ /**
83242
+ * Decode the frame at `epochMs` from the camera's recording and add it to
83243
+ * the collection. The moment already in the dataset is reused without a
83244
+ * decode; every refusal is named and writes nothing.
83245
+ */
83246
+ async addFrameFromRecording(input) {
83247
+ const tags = { deviceId: input.deviceId };
83248
+ if (await this.deps.collections.getById(input.collectionId) === null) {
83249
+ this.deps.logger.info("retrain collection add refused: unknown collection", {
83250
+ tags,
82497
83251
  meta: {
82498
83252
  collectionId: input.collectionId,
82499
83253
  epochMs: input.epochMs
@@ -82504,45 +83258,836 @@ var RetrainCollectionStore = class {
82504
83258
  reason: "unknown-collection"
82505
83259
  };
82506
83260
  }
82507
- this.logger.info("retrain collection addFrame stub: still-capture-unimplemented", {
82508
- tags: { deviceId: input.deviceId },
83261
+ const stored = await this.storeStill(input.deviceId, input.epochMs);
83262
+ if (stored.kind === "refused") {
83263
+ this.deps.logger.info("retrain collection add refused", {
83264
+ tags,
83265
+ meta: {
83266
+ collectionId: input.collectionId,
83267
+ epochMs: input.epochMs,
83268
+ reason: stored.reason
83269
+ }
83270
+ });
83271
+ return stored;
83272
+ }
83273
+ await this.deps.collections.addFrame({
83274
+ collectionId: input.collectionId,
83275
+ frameId: stored.frame.frameId,
83276
+ deviceId: input.deviceId
83277
+ });
83278
+ this.deps.logger.info("retrain frame added to a collection from the recording", {
83279
+ tags,
82509
83280
  meta: {
82510
83281
  collectionId: input.collectionId,
82511
- epochMs: input.epochMs
83282
+ frameId: stored.frame.frameId,
83283
+ epochMs: input.epochMs,
83284
+ reused: stored.reused
82512
83285
  }
82513
83286
  });
83287
+ const [wire] = await this.deps.retrain.toWireFrames([stored.frame]);
83288
+ if (wire === void 0) throw new Error("retrain: a stored still produced no wire frame");
82514
83289
  return {
83290
+ kind: "added",
83291
+ collectionId: input.collectionId,
83292
+ frame: wire,
83293
+ reused: stored.reused
83294
+ };
83295
+ }
83296
+ /** Add frames already in the dataset (track copies or stills) to a collection. */
83297
+ async addFrames(input) {
83298
+ if (await this.deps.collections.getById(input.collectionId) === null) return {
83299
+ added: 0,
83300
+ alreadyMember: 0,
83301
+ unknownFrameIds: [...input.frameIds]
83302
+ };
83303
+ const frames = await this.deps.frames.getMany(input.frameIds);
83304
+ const known = new Set(frames.map((f) => f.frameId));
83305
+ let added = 0;
83306
+ let alreadyMember = 0;
83307
+ for (const frame of frames) if (await this.deps.collections.addFrame({
83308
+ collectionId: input.collectionId,
83309
+ frameId: frame.frameId,
83310
+ deviceId: frame.deviceId
83311
+ })) added += 1;
83312
+ else alreadyMember += 1;
83313
+ return {
83314
+ added,
83315
+ alreadyMember,
83316
+ unknownFrameIds: input.frameIds.filter((id) => !known.has(id))
83317
+ };
83318
+ }
83319
+ /**
83320
+ * Remove a frame from a collection. A recording still left in no collection
83321
+ * is deleted with its annotations; a track copy is never deleted here.
83322
+ */
83323
+ async removeFrame(input) {
83324
+ if (!await this.deps.collections.removeFrame(input)) return {
83325
+ removed: false,
83326
+ deletedFrame: false
83327
+ };
83328
+ return {
83329
+ removed: true,
83330
+ deletedFrame: await this.dropOrphanStills([input.frameId]) > 0
83331
+ };
83332
+ }
83333
+ /** The frames of a collection, as the canvas lists them. */
83334
+ async listFrames(input) {
83335
+ const ids = await this.deps.collections.listFrameIds(input.collectionId);
83336
+ return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
83337
+ }
83338
+ async storeStill(deviceId, epochMs) {
83339
+ const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
83340
+ if (existing !== null) return {
83341
+ kind: "stored",
83342
+ frame: existing,
83343
+ reused: true
83344
+ };
83345
+ const still = await this.deps.captureStill(deviceId, epochMs);
83346
+ if (still.outcome !== "ok" || still.jpeg === void 0) {
83347
+ const refusal = still.outcome === "ok" ? STILL_REFUSAL["decode-failed"] : STILL_REFUSAL[still.outcome];
83348
+ return {
83349
+ kind: "refused",
83350
+ reason: refusal.reason,
83351
+ detail: refusal.detail
83352
+ };
83353
+ }
83354
+ const stored = await this.deps.frames.storeRecordingStill({
83355
+ deviceId,
83356
+ epochMs,
83357
+ bytes: still.jpeg
83358
+ });
83359
+ return stored.kind === "copied" ? {
83360
+ kind: "stored",
83361
+ frame: stored.frame,
83362
+ reused: stored.reused
83363
+ } : {
82515
83364
  kind: "refused",
82516
- reason: "still-capture-unimplemented",
82517
- detail: "Still ingest lands in plan Task 3"
83365
+ reason: stored.reason
82518
83366
  };
82519
83367
  }
82520
- async findByNameKey(nameKey) {
82521
- try {
82522
- const row = (await this.store.query.query({
82523
- collection: RETRAIN_COLLECTIONS_COLLECTION,
82524
- filter: {
82525
- where: { nameKey },
82526
- limit: 1
83368
+ /** Delete the recording stills among `frameIds` that no collection holds any more. */
83369
+ async dropOrphanStills(frameIds) {
83370
+ if (frameIds.length === 0) return 0;
83371
+ const memberships = await this.deps.collections.collectionsOf(frameIds);
83372
+ const orphans = (await this.deps.frames.getMany(frameIds)).filter((f) => f.origin === "recording" && (memberships.get(f.frameId)?.length ?? 0) === 0);
83373
+ for (const frame of orphans) await this.deps.retrain.removeFrame(frame);
83374
+ return orphans.length;
83375
+ }
83376
+ };
83377
+ //#endregion
83378
+ //#region src/pipeline-analytics/retrain/retrain-llm-assist.ts
83379
+ var ALWAYS_LOCAL_KINDS = new Set(["managed-local", "apple-foundation"]);
83380
+ var ALWAYS_CLOUD_KINDS = new Set([
83381
+ "openai",
83382
+ "anthropic",
83383
+ "google"
83384
+ ]);
83385
+ function isPrivateIpv4(host) {
83386
+ const parts = host.split(".").map((p) => Number.parseInt(p, 10));
83387
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
83388
+ const [a, b] = parts;
83389
+ 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;
83390
+ }
83391
+ function isLocalHost(rawHost) {
83392
+ const host = rawHost.replace(/^\[|\]$/g, "").toLowerCase();
83393
+ if (host.includes(":")) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
83394
+ if (host === "localhost" || host.endsWith(".local") || host.endsWith(".lan")) return true;
83395
+ if (isPrivateIpv4(host)) return true;
83396
+ return !host.includes(".") && !host.includes(":");
83397
+ }
83398
+ /** Does sending a frame to this profile send it outside the house? */
83399
+ function llmProfileLeavesHouse(profile) {
83400
+ if (ALWAYS_LOCAL_KINDS.has(profile.kind)) return false;
83401
+ if (ALWAYS_CLOUD_KINDS.has(profile.kind)) return true;
83402
+ if (profile.baseUrl === void 0 || profile.baseUrl === "") return true;
83403
+ try {
83404
+ return !isLocalHost(new URL(profile.baseUrl).hostname);
83405
+ } catch {
83406
+ return true;
83407
+ }
83408
+ }
83409
+ /** At most this many boxes are sent for review — a prompt, not a dump. */
83410
+ var LLM_REVIEW_MAX_BOXES = 50;
83411
+ var VERDICTS = [
83412
+ "confirmed",
83413
+ "wrong-class",
83414
+ "not-an-object",
83415
+ "uncertain"
83416
+ ];
83417
+ var SYSTEM_PROMPT = [
83418
+ "You annotate frames from home security cameras to build an object-detection training set.",
83419
+ "Be precise and conservative: only report what is clearly visible.",
83420
+ "Coordinates are normalised to the full frame: x and y are the top-left corner,",
83421
+ "w and h the width and height, all between 0 and 1.",
83422
+ "Answer with JSON only, exactly in the requested schema, with no other text."
83423
+ ].join(" ");
83424
+ /** The JSON schema asked of the model (and enforced again by the parser). */
83425
+ function answerJsonSchema(classes) {
83426
+ return {
83427
+ type: "object",
83428
+ additionalProperties: false,
83429
+ required: ["objects", "reviews"],
83430
+ properties: {
83431
+ objects: {
83432
+ type: "array",
83433
+ items: {
83434
+ type: "object",
83435
+ additionalProperties: false,
83436
+ required: [
83437
+ "class",
83438
+ "box",
83439
+ "confidence",
83440
+ "reason"
83441
+ ],
83442
+ properties: {
83443
+ class: {
83444
+ type: "string",
83445
+ enum: [...classes]
83446
+ },
83447
+ label: { type: "string" },
83448
+ box: {
83449
+ type: "array",
83450
+ items: { type: "number" },
83451
+ minItems: 4,
83452
+ maxItems: 4
83453
+ },
83454
+ confidence: {
83455
+ type: "number",
83456
+ minimum: 0,
83457
+ maximum: 1
83458
+ },
83459
+ reason: { type: "string" }
83460
+ }
82527
83461
  }
82528
- }))[0];
82529
- if (row === void 0) return null;
83462
+ },
83463
+ reviews: {
83464
+ type: "array",
83465
+ items: {
83466
+ type: "object",
83467
+ additionalProperties: false,
83468
+ required: [
83469
+ "ref",
83470
+ "verdict",
83471
+ "reason"
83472
+ ],
83473
+ properties: {
83474
+ ref: { type: "string" },
83475
+ verdict: {
83476
+ type: "string",
83477
+ enum: [...VERDICTS]
83478
+ },
83479
+ suggestedClass: { type: "string" },
83480
+ reason: { type: "string" }
83481
+ }
83482
+ }
83483
+ }
83484
+ }
83485
+ };
83486
+ }
83487
+ var round = (n) => Math.round(n * 1e3) / 1e3;
83488
+ /** Build the request: target classes, the frame size, the boxes to review. */
83489
+ function buildLlmAssistRequest(input) {
83490
+ const boxes = input.boxes.slice(0, LLM_REVIEW_MAX_BOXES).map((b) => JSON.stringify({
83491
+ ref: b.ref,
83492
+ class: b.macroClass,
83493
+ ...b.label !== void 0 ? { label: b.label } : {},
83494
+ box: [
83495
+ round(b.bbox.x),
83496
+ round(b.bbox.y),
83497
+ round(b.bbox.w),
83498
+ round(b.bbox.h)
83499
+ ]
83500
+ }));
83501
+ return {
83502
+ system: SYSTEM_PROMPT,
83503
+ prompt: [
83504
+ `The image is one camera frame, ${String(input.width)}×${String(input.height)} pixels.`,
83505
+ `1. List every object of these classes you can see: ${input.classes.join(", ")}.`,
83506
+ " For each give class, an optional free-text label (e.g. \"van\", \"postman\"),",
83507
+ " box [x, y, w, h] normalised to the frame, confidence 0–1, and a short reason.",
83508
+ " Include small and partly hidden ones; do not include anything else.",
83509
+ 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\": [].",
83510
+ "Answer with JSON: {\"objects\": [...], \"reviews\": [...]}."
83511
+ ].join("\n"),
83512
+ jsonSchema: answerJsonSchema(input.classes)
83513
+ };
83514
+ }
83515
+ /** The whole answer: two arrays and nothing else. Items are checked one by one. */
83516
+ var AnswerSchema = require_clip_model_registry.strictObject({
83517
+ objects: require_clip_model_registry.array(require_clip_model_registry.unknown()),
83518
+ reviews: require_clip_model_registry.array(require_clip_model_registry.unknown())
83519
+ });
83520
+ var ObjectSchema = require_clip_model_registry.strictObject({
83521
+ class: require_clip_model_registry.string(),
83522
+ label: require_clip_model_registry.string().optional(),
83523
+ box: require_clip_model_registry.tuple([
83524
+ require_clip_model_registry.number(),
83525
+ require_clip_model_registry.number(),
83526
+ require_clip_model_registry.number(),
83527
+ require_clip_model_registry.number()
83528
+ ]),
83529
+ confidence: require_clip_model_registry.number().min(0).max(1),
83530
+ reason: require_clip_model_registry.string().max(500)
83531
+ });
83532
+ var ReviewSchema = require_clip_model_registry.strictObject({
83533
+ ref: require_clip_model_registry.string(),
83534
+ verdict: require_clip_model_registry._enum(VERDICTS),
83535
+ suggestedClass: require_clip_model_registry.string().optional(),
83536
+ reason: require_clip_model_registry.string().max(500)
83537
+ });
83538
+ function frameBox(box) {
83539
+ const [x, y, w, h] = box;
83540
+ if (![
83541
+ x,
83542
+ y,
83543
+ w,
83544
+ h
83545
+ ].every(Number.isFinite)) return null;
83546
+ if (!(w > 0) || !(h > 0) || x < -.01 || y < -.01) return null;
83547
+ if (x + w > 1.01 || y + h > 1.01) return null;
83548
+ const cx = Math.max(0, x);
83549
+ const cy = Math.max(0, y);
83550
+ return {
83551
+ x: cx,
83552
+ y: cy,
83553
+ w: Math.min(w, 1 - cx),
83554
+ h: Math.min(h, 1 - cy)
83555
+ };
83556
+ }
83557
+ /** Strip a Markdown code fence a model wrapped its JSON in. */
83558
+ function unfence(text) {
83559
+ const trimmed = text.trim();
83560
+ const fence = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
83561
+ return fence?.[1] !== void 0 ? fence[1].trim() : trimmed;
83562
+ }
83563
+ /** A free-text label, never a macro word (the tier rule). */
83564
+ function usableLabel(label) {
83565
+ const trimmed = label?.trim();
83566
+ if (trimmed === void 0 || trimmed === "") return void 0;
83567
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
83568
+ }
83569
+ /** Parse the model's answer. See the module header for the rules. */
83570
+ function parseLlmAssistAnswer(text, expected) {
83571
+ let raw;
83572
+ try {
83573
+ raw = JSON.parse(unfence(text));
83574
+ } catch {
83575
+ return {
83576
+ kind: "malformed",
83577
+ detail: "the answer is not JSON"
83578
+ };
83579
+ }
83580
+ const answer = AnswerSchema.safeParse(raw);
83581
+ if (!answer.success) return {
83582
+ kind: "malformed",
83583
+ detail: "the answer is not {\"objects\": [...], \"reviews\": [...]}"
83584
+ };
83585
+ const asked = new Set(expected.classes);
83586
+ let rejected = 0;
83587
+ const objects = [];
83588
+ for (const item of answer.data.objects) {
83589
+ const parsed = ObjectSchema.safeParse(item);
83590
+ const cls = parsed.success ? parsed.data.class.trim().toLowerCase() : "";
83591
+ const bbox = parsed.success ? frameBox(parsed.data.box) : null;
83592
+ if (!parsed.success || bbox === null || !asked.has(cls) || !isRetrainMacroClass(cls)) {
83593
+ rejected += 1;
83594
+ continue;
83595
+ }
83596
+ const label = usableLabel(parsed.data.label);
83597
+ objects.push({
83598
+ macroClass: cls,
83599
+ ...label !== void 0 ? { label } : {},
83600
+ bbox,
83601
+ confidence: parsed.data.confidence,
83602
+ reason: parsed.data.reason
83603
+ });
83604
+ }
83605
+ const reviews = [];
83606
+ for (const item of answer.data.reviews) {
83607
+ const parsed = ReviewSchema.safeParse(item);
83608
+ if (!parsed.success || !expected.refs.has(parsed.data.ref)) {
83609
+ rejected += 1;
83610
+ continue;
83611
+ }
83612
+ const suggested = parsed.data.suggestedClass?.trim().toLowerCase();
83613
+ reviews.push({
83614
+ ref: parsed.data.ref,
83615
+ verdict: parsed.data.verdict,
83616
+ ...suggested !== void 0 && isRetrainMacroClass(suggested) ? { suggestedClass: suggested } : {},
83617
+ reason: parsed.data.reason
83618
+ });
83619
+ }
83620
+ return {
83621
+ kind: "parsed",
83622
+ objects,
83623
+ reviews,
83624
+ rejected
83625
+ };
83626
+ }
83627
+ //#endregion
83628
+ //#region src/pipeline-analytics/retrain/retrain-llm-service.ts
83629
+ /** The usage tag every AI-assist call is counted under (`llm.getUsage`). */
83630
+ var RETRAIN_LLM_CONSUMER = "retrain-assist";
83631
+ /** One frame, one answer: a cold local vision model can take a minute. */
83632
+ var RETRAIN_LLM_TIMEOUT_MS = 15e4;
83633
+ var RetrainLlmAssistService = class {
83634
+ deps;
83635
+ constructor(deps) {
83636
+ this.deps = deps;
83637
+ }
83638
+ /** The enabled, vision-capable profiles — a text-only one would be refused. */
83639
+ async listProfiles() {
83640
+ return (await this.deps.llm.listProfiles()).filter((p) => p.enabled && p.supportsVision).map((p) => ({
83641
+ id: p.id,
83642
+ name: p.name,
83643
+ model: p.model,
83644
+ kind: p.kind,
83645
+ leavesHouse: llmProfileLeavesHouse(p)
83646
+ }));
83647
+ }
83648
+ async propose(input) {
83649
+ const tags = { deviceId: input.deviceId };
83650
+ const frame = await this.deps.frames.get(input.frameId);
83651
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
83652
+ kind: "refused",
83653
+ reason: "unknown-frame"
83654
+ };
83655
+ const profile = (await this.deps.llm.listProfiles()).find((p) => p.id === input.profileId);
83656
+ if (profile === void 0) return {
83657
+ kind: "refused",
83658
+ reason: "unknown-profile"
83659
+ };
83660
+ if (!profile.enabled) return {
83661
+ kind: "refused",
83662
+ reason: "profile-disabled"
83663
+ };
83664
+ if (!profile.supportsVision) return {
83665
+ kind: "refused",
83666
+ reason: "no-vision"
83667
+ };
83668
+ const leavesHouse = llmProfileLeavesHouse(profile);
83669
+ if (leavesHouse && !input.allowCloud) {
83670
+ this.deps.logger.warn("retrain AI assist refused: a cloud profile without confirmation", {
83671
+ tags,
83672
+ meta: {
83673
+ frameId: input.frameId,
83674
+ profileId: profile.id
83675
+ }
83676
+ });
82530
83677
  return {
82531
- id: row.id,
82532
- name: typeof row.data["name"] === "string" ? row.data["name"] : "",
82533
- createdAt: Number(row.data["createdAt"] ?? 0),
82534
- frameCount: 0
83678
+ kind: "refused",
83679
+ reason: "cloud-not-confirmed",
83680
+ detail: "this profile sends the frame outside the house; confirm the run first"
82535
83681
  };
82536
- } catch (err) {
82537
- this.logger.warn("retrain collection findByNameKey failed", { meta: {
82538
- nameKey,
82539
- error: String(err)
82540
- } });
82541
- return null;
82542
83682
  }
83683
+ const bytes = await this.deps.frames.readFrame(frame);
83684
+ if (bytes === null) return {
83685
+ kind: "refused",
83686
+ reason: "unreadable-frame"
83687
+ };
83688
+ const request = buildLlmAssistRequest({
83689
+ classes: input.classes,
83690
+ width: frame.width,
83691
+ height: frame.height,
83692
+ boxes: input.boxes
83693
+ });
83694
+ const answer = await this.deps.llm.generateVision({
83695
+ profileId: profile.id,
83696
+ system: request.system,
83697
+ prompt: request.prompt,
83698
+ jsonSchema: request.jsonSchema,
83699
+ image: bytes
83700
+ });
83701
+ if (!answer.ok) {
83702
+ this.deps.logger.warn("retrain AI assist: the model call failed", {
83703
+ tags,
83704
+ meta: {
83705
+ frameId: input.frameId,
83706
+ profileId: profile.id,
83707
+ code: answer.code
83708
+ }
83709
+ });
83710
+ return {
83711
+ kind: "refused",
83712
+ reason: "llm-error",
83713
+ detail: `${answer.code}: ${answer.message}`
83714
+ };
83715
+ }
83716
+ const parsed = parseLlmAssistAnswer(answer.text, {
83717
+ classes: input.classes,
83718
+ refs: new Set(input.boxes.map((b) => b.ref))
83719
+ });
83720
+ if (parsed.kind === "malformed") {
83721
+ this.deps.logger.warn("retrain AI assist: the answer was refused as malformed", {
83722
+ tags,
83723
+ meta: {
83724
+ frameId: input.frameId,
83725
+ profileId: profile.id,
83726
+ model: answer.model,
83727
+ detail: parsed.detail,
83728
+ answerChars: answer.text.length
83729
+ }
83730
+ });
83731
+ return {
83732
+ kind: "refused",
83733
+ reason: "malformed-answer",
83734
+ detail: parsed.detail
83735
+ };
83736
+ }
83737
+ this.deps.logger.info("retrain AI assist proposed boxes", {
83738
+ tags,
83739
+ meta: {
83740
+ frameId: input.frameId,
83741
+ profileId: profile.id,
83742
+ model: answer.model,
83743
+ leftHouse: leavesHouse,
83744
+ proposals: parsed.objects.length,
83745
+ reviews: parsed.reviews.length,
83746
+ rejected: parsed.rejected
83747
+ }
83748
+ });
83749
+ return {
83750
+ kind: "proposed",
83751
+ profileId: profile.id,
83752
+ model: answer.model,
83753
+ leftHouse: leavesHouse,
83754
+ proposals: parsed.objects.map((o) => ({
83755
+ bbox: o.bbox,
83756
+ macroClass: o.macroClass,
83757
+ ...o.label !== void 0 ? { label: o.label } : {},
83758
+ kind: "subject",
83759
+ source: "llm",
83760
+ assistModelId: answer.model,
83761
+ assistScore: o.confidence,
83762
+ reason: o.reason
83763
+ })),
83764
+ reviews: parsed.reviews.map((r) => ({
83765
+ ref: r.ref,
83766
+ verdict: r.verdict,
83767
+ ...r.suggestedClass !== void 0 ? { suggestedClass: r.suggestedClass } : {},
83768
+ reason: r.reason
83769
+ })),
83770
+ rejected: parsed.rejected
83771
+ };
82543
83772
  }
82544
83773
  };
82545
83774
  //#endregion
83775
+ //#region src/pipeline-analytics/retrain/retrain-archive-upload.ts
83776
+ /**
83777
+ * The HTTP sink behind `pipelineAnalytics.writeRetrainArchive`: ONE request
83778
+ * whose body is the archive, written chunk by chunk with backpressure honoured
83779
+ * (D447) — the archive is never assembled in memory or on disk.
83780
+ *
83781
+ * The target is admin-supplied (the training orchestrator hands over a
83782
+ * provider's presigned URL or its upload route). Its headers may carry a
83783
+ * credential, so they are sent and never logged.
83784
+ */
83785
+ function openRetrainUploadSink(target) {
83786
+ const url = new URL(target.url);
83787
+ const req = (url.protocol === "https:" ? node_https.request : node_http.request)(url, {
83788
+ method: target.method,
83789
+ headers: {
83790
+ "content-type": "application/x-tar",
83791
+ ...target.headers
83792
+ }
83793
+ });
83794
+ let failure = null;
83795
+ const answer = new Promise((resolve, reject) => {
83796
+ req.on("response", resolve);
83797
+ req.on("error", (err) => {
83798
+ failure = err;
83799
+ reject(err);
83800
+ });
83801
+ });
83802
+ answer.catch(() => void 0);
83803
+ return {
83804
+ sink: { write: (chunk) => new Promise((resolve, reject) => {
83805
+ if (failure !== null) {
83806
+ reject(failure);
83807
+ return;
83808
+ }
83809
+ if (req.write(chunk, (err) => {
83810
+ if (err) reject(err);
83811
+ })) resolve();
83812
+ else req.once("drain", resolve);
83813
+ }) },
83814
+ finish: async () => {
83815
+ req.end();
83816
+ const res = await answer;
83817
+ res.resume();
83818
+ const status = res.statusCode ?? 0;
83819
+ if (status < 200 || status >= 300) throw new Error(`retrain upload refused by the target: HTTP ${status}`);
83820
+ return status;
83821
+ },
83822
+ abort: (reason) => {
83823
+ req.destroy(new Error(reason));
83824
+ }
83825
+ };
83826
+ }
83827
+ //#endregion
83828
+ //#region src/pipeline-analytics/training-export/tar-stream.ts
83829
+ /**
83830
+ * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
83831
+ * entry, without ever holding the archive in memory.
83832
+ *
83833
+ * ## Why hand-rolled rather than the `tar` package
83834
+ *
83835
+ * `tar` is in the workspace and is the right tool for what it does: archive a
83836
+ * set of paths under one `cwd`. This is a different job. A track's media lives
83837
+ * across several storage LOCATION roots (`eventMedia` and whatever else the
83838
+ * operator relocated to), under paths that encode nothing a training set wants —
83839
+ * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
83840
+ * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
83841
+ * of paths relative to a single cwd and gives no rename hook, so using it would
83842
+ * mean staging a whole shadow tree on disk first: a full second copy of the
83843
+ * dataset, written and then deleted, to avoid writing 100 lines.
83844
+ *
83845
+ * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
83846
+ * fully specified, and asserted against the system `tar` binary in the spec next
83847
+ * door rather than against my reading of the format.
83848
+ *
83849
+ * ## What this deliberately does NOT do
83850
+ *
83851
+ * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
83852
+ * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
83853
+ * splits a path across a 155-byte prefix and a 100-byte name, and
83854
+ * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
83855
+ * it and SAY it skipped it, rather than emitting a silently truncated entry.
83856
+ */
83857
+ var BLOCK_SIZE = 512;
83858
+ var NAME_MAX = 100;
83859
+ var PREFIX_MAX = 155;
83860
+ /**
83861
+ * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
83862
+ * be represented. The split must land on a `/`, and each half has its own cap.
83863
+ */
83864
+ function splitUstarName(name) {
83865
+ if (Buffer.byteLength(name) <= NAME_MAX) return {
83866
+ prefix: "",
83867
+ name
83868
+ };
83869
+ for (let i = name.length - 1; i >= 0; i--) {
83870
+ if (name[i] !== "/") continue;
83871
+ const head = name.slice(0, i);
83872
+ const tail = name.slice(i + 1);
83873
+ if (Buffer.byteLength(tail) > NAME_MAX) break;
83874
+ if (Buffer.byteLength(head) <= PREFIX_MAX) return {
83875
+ prefix: head,
83876
+ name: tail
83877
+ };
83878
+ }
83879
+ return null;
83880
+ }
83881
+ /** True when a path cannot be represented as a ustar entry name. */
83882
+ function tarNameTooLong(name) {
83883
+ return splitUstarName(name) === null;
83884
+ }
83885
+ function writeString(block, value, offset, length) {
83886
+ block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
83887
+ }
83888
+ /** ustar numeric field: zero-padded octal, NUL-terminated. */
83889
+ function writeOctal(block, value, offset, length) {
83890
+ const text = Math.floor(value).toString(8).padStart(length - 1, "0");
83891
+ block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
83892
+ }
83893
+ /**
83894
+ * The 512-byte header for one regular file.
83895
+ *
83896
+ * The checksum is computed with the checksum field itself read as eight spaces —
83897
+ * that is the format's rule, and getting it wrong produces an archive that looks
83898
+ * fine until something tries to read it.
83899
+ */
83900
+ function tarHeader(entry) {
83901
+ const split = splitUstarName(entry.name);
83902
+ if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
83903
+ const block = Buffer.alloc(BLOCK_SIZE);
83904
+ writeString(block, split.name, 0, NAME_MAX);
83905
+ writeOctal(block, 420, 100, 8);
83906
+ writeOctal(block, 0, 108, 8);
83907
+ writeOctal(block, 0, 116, 8);
83908
+ writeOctal(block, entry.body.length, 124, 12);
83909
+ writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
83910
+ block.write(" ", 148, 8, "ascii");
83911
+ block.write("0", 156, 1, "ascii");
83912
+ block.write("ustar\0", 257, 6, "ascii");
83913
+ block.write("00", 263, 2, "ascii");
83914
+ writeString(block, split.prefix, 345, PREFIX_MAX);
83915
+ let sum = 0;
83916
+ for (const byte of block) sum += byte;
83917
+ writeOctal(block, sum, 148, 7);
83918
+ block.write("\0", 154, 1, "ascii");
83919
+ block.write(" ", 155, 1, "ascii");
83920
+ return block;
83921
+ }
83922
+ /** Zero padding that rounds a body up to the 512-byte block. */
83923
+ function tarPadding(size) {
83924
+ const remainder = size % BLOCK_SIZE;
83925
+ return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
83926
+ }
83927
+ /** Header + body + padding for one entry, ready to write. */
83928
+ function tarBlocks(entry) {
83929
+ return [
83930
+ tarHeader(entry),
83931
+ entry.body,
83932
+ tarPadding(entry.body.length)
83933
+ ];
83934
+ }
83935
+ /** The two zero blocks that terminate an archive. */
83936
+ function tarTrailer() {
83937
+ return Buffer.alloc(BLOCK_SIZE * 2);
83938
+ }
83939
+ //#endregion
83940
+ //#region src/pipeline-analytics/retrain/retrain-archive-writer.ts
83941
+ /**
83942
+ * The annotated archive body, written to ANY sink.
83943
+ *
83944
+ * One body, two consumers: the data-plane download (`retrain-export-handler`,
83945
+ * sink = the HTTP response) and `pipelineAnalytics.writeRetrainArchive` (sink
83946
+ * = one streamed HTTP upload to a training provider). The disciplines are the
83947
+ * training export's: the manifest is the first entry, one JPEG is in memory at
83948
+ * a time, a pause between files keeps the hub's one thread available, and the
83949
+ * batch id is stamped only on what the sink actually received.
83950
+ */
83951
+ var RETRAIN_MANIFEST_ENTRY = "annotations.json";
83952
+ /** Pause between files so a large export cannot monopolise the hub's thread. */
83953
+ var RETRAIN_ARCHIVE_PACING_MS = 25;
83954
+ async function writeRetrainArchiveTo(deps) {
83955
+ const { plan, logger } = deps;
83956
+ const pacingMs = deps.pacingMs ?? RETRAIN_ARCHIVE_PACING_MS;
83957
+ const hash = (0, node_crypto.createHash)("sha256");
83958
+ let bytes = 0;
83959
+ const write = async (chunk) => {
83960
+ hash.update(chunk);
83961
+ bytes += chunk.length;
83962
+ await deps.sink.write(chunk);
83963
+ };
83964
+ const writeEntry = async (name, body, mtimeMs) => {
83965
+ for (const block of tarBlocks({
83966
+ name,
83967
+ body,
83968
+ mtimeMs
83969
+ })) await write(block);
83970
+ };
83971
+ const mtimeMs = deps.now();
83972
+ await writeEntry(RETRAIN_MANIFEST_ENTRY, Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"), mtimeMs);
83973
+ const byId = new Map(plan.manifest.frames.map((f) => [f.frameId, f]));
83974
+ const shipped = [];
83975
+ let missing = 0;
83976
+ let crops = 0;
83977
+ for (const frame of plan.frames) {
83978
+ const entry = byId.get(frame.frameId);
83979
+ if (entry === void 0) continue;
83980
+ if (tarNameTooLong(entry.path)) {
83981
+ missing += 1;
83982
+ logger.warn("retrain export skipped a frame — path too long for the archive", {
83983
+ tags: { deviceId: frame.deviceId },
83984
+ meta: {
83985
+ trackId: frame.trackId,
83986
+ frameId: frame.frameId,
83987
+ path: entry.path
83988
+ }
83989
+ });
83990
+ continue;
83991
+ }
83992
+ const frameBytes = await deps.readFrame(frame);
83993
+ if (frameBytes === null) {
83994
+ missing += 1;
83995
+ logger.warn("retrain export skipped a frame — the COPY is unreadable", {
83996
+ tags: { deviceId: frame.deviceId },
83997
+ meta: {
83998
+ trackId: frame.trackId,
83999
+ frameId: frame.frameId
84000
+ }
84001
+ });
84002
+ continue;
84003
+ }
84004
+ await writeEntry(entry.path, frameBytes, mtimeMs);
84005
+ for (const annotation of entry.annotations) {
84006
+ if (annotation.cropPath === void 0) continue;
84007
+ const crop = deps.cutCrop ? await deps.cutCrop(frameBytes, frame, annotation) : null;
84008
+ if (crop === null) {
84009
+ logger.warn("retrain export skipped a crop — it could not be cut", {
84010
+ tags: { deviceId: frame.deviceId },
84011
+ meta: {
84012
+ frameId: frame.frameId,
84013
+ annotationId: annotation.id,
84014
+ cropPath: annotation.cropPath
84015
+ }
84016
+ });
84017
+ continue;
84018
+ }
84019
+ await writeEntry(annotation.cropPath, crop, mtimeMs);
84020
+ crops += 1;
84021
+ }
84022
+ shipped.push(frame.frameId);
84023
+ if (pacingMs > 0) await new Promise((resolve) => setTimeout(resolve, pacingMs));
84024
+ }
84025
+ await write(tarTrailer());
84026
+ await deps.markExported(shipped, plan.manifest.batchId);
84027
+ const stats = {
84028
+ batchId: plan.manifest.batchId,
84029
+ frames: shipped.length,
84030
+ crops,
84031
+ annotations: plan.manifest.annotationCount,
84032
+ missing,
84033
+ bytes,
84034
+ sha256: hash.digest("hex")
84035
+ };
84036
+ logger.info("retrain export complete", { meta: {
84037
+ ...stats,
84038
+ layout: plan.manifest.layout,
84039
+ modelErrors: plan.manifest.modelErrorCount
84040
+ } });
84041
+ return stats;
84042
+ }
84043
+ //#endregion
84044
+ //#region src/pipeline-analytics/retrain/retrain-crop-cut.ts
84045
+ /**
84046
+ * The `crops` layout's cutter: one subject crop from a retrain frame copy.
84047
+ *
84048
+ * A classifier must be trained on the crop the runtime feeds it, so the
84049
+ * rectangle comes from THE detail-crop derivation (`deriveDetailCropRect`,
84050
+ * `types/src/pipeline/detail-crop.ts`) with the convention the manifest
84051
+ * records — never a second rectangle maths. This is the second permitted
84052
+ * caller of that derivation (`scripts/check-clip-crop-single-owner.ts`): it
84053
+ * writes training files, never a vector, so it cannot split a feature space;
84054
+ * what it must not do is cut differently, and calling the one function is how
84055
+ * it doesn't.
84056
+ */
84057
+ /** The pixel window to extract, or `null` when nothing of it is inside the frame. */
84058
+ function retrainCropWindow(input) {
84059
+ const { frameWidth: fw, frameHeight: fh, bbox } = input;
84060
+ const rect = require_clip_model_registry.deriveDetailCropRect({
84061
+ x: bbox.x * fw,
84062
+ y: bbox.y * fh,
84063
+ w: bbox.w * fw,
84064
+ h: bbox.h * fh
84065
+ }, fw, fh, input.convention);
84066
+ const left = Math.max(0, Math.floor(rect.x));
84067
+ const top = Math.max(0, Math.floor(rect.y));
84068
+ const right = Math.min(fw, Math.ceil(rect.x + rect.w));
84069
+ const bottom = Math.min(fh, Math.ceil(rect.y + rect.h));
84070
+ if (right - left < 1 || bottom - top < 1) return null;
84071
+ return {
84072
+ left,
84073
+ top,
84074
+ width: right - left,
84075
+ height: bottom - top
84076
+ };
84077
+ }
84078
+ async function cutRetrainCrop(input) {
84079
+ const window = retrainCropWindow(input);
84080
+ if (window === null) return null;
84081
+ let pipeline = (0, sharp.default)(input.frameBytes).extract(window);
84082
+ if (input.maxSide > 0) pipeline = pipeline.resize({
84083
+ width: input.maxSide,
84084
+ height: input.maxSide,
84085
+ fit: "inside",
84086
+ withoutEnlargement: true
84087
+ });
84088
+ return pipeline.jpeg({ quality: 92 }).toBuffer();
84089
+ }
84090
+ //#endregion
82546
84091
  //#region src/pipeline-analytics/retrain/retrain-geometry.ts
82547
84092
  function clamp01(v) {
82548
84093
  return v < 0 ? 0 : v > 1 ? 1 : v;
@@ -82638,14 +84183,106 @@ function deriveSubjectCropRect(bbox, padding) {
82638
84183
  };
82639
84184
  }
82640
84185
  //#endregion
84186
+ //#region src/pipeline-analytics/retrain/retrain-lighting.ts
84187
+ function isNight(condition) {
84188
+ return condition === "night" || condition === "ir";
84189
+ }
84190
+ function retrainFrameLighting(input) {
84191
+ if (input.capturedAt === void 0) {
84192
+ const measured = resolveConditionDetailed({
84193
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
84194
+ now: /* @__PURE__ */ new Date(0)
84195
+ });
84196
+ return measured.source === "ir-chroma" ? {
84197
+ condition: measured.condition,
84198
+ night: true,
84199
+ source: measured.source
84200
+ } : null;
84201
+ }
84202
+ const resolved = resolveConditionDetailed({
84203
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
84204
+ ...input.coordinates !== null ? { coordinates: input.coordinates } : {},
84205
+ now: new Date(input.capturedAt)
84206
+ });
84207
+ return {
84208
+ ...resolved,
84209
+ night: isNight(resolved.condition)
84210
+ };
84211
+ }
84212
+ //#endregion
82641
84213
  //#region src/pipeline-analytics/retrain/retrain-export.ts
84214
+ /**
84215
+ * The ANNOTATED dataset export — the retrain page's own archive.
84216
+ *
84217
+ * Sibling of `training-export/`, deliberately not the same thing. That one
84218
+ * streams a marked track's stored media verbatim and knows nothing about what
84219
+ * is in it; this one streams the frames the operator SELECTED (already copied
84220
+ * into retrain storage) plus the ground truth they drew.
84221
+ *
84222
+ * ## The one interesting decision
84223
+ *
84224
+ * `annotations.json` carries, for every subject, the canonical full-frame box
84225
+ * AND the geometry derived for each model shape. **Derived here, at export,
84226
+ * never stored** — one box in, three shapes out. Storing the derived shapes is
84227
+ * how two crops of the same subject end up in one feature space, which is the
84228
+ * failure already recorded for CLIP (D52). A consumer that wants a different
84229
+ * input size or a different padding re-derives from the canonical box; nothing
84230
+ * in the database has to change and nothing can disagree.
84231
+ *
84232
+ * Everything else follows the export next door: index reads to plan, one JPEG
84233
+ * in memory at a time, a yield between files, and NO inference — a training
84234
+ * set's pixels are the pixels the pipeline saw.
84235
+ */
82642
84236
  var RETRAIN_EXPORT_PREFIX = "retrain-export";
82643
- /** Padding a subject-crop classifier is trained with. One number, one place. */
82644
- var CLASSIFIER_CROP_PADDING = .2;
82645
- /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`. */
84237
+ /**
84238
+ * Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
84239
+ * padding, read from the one convention `deriveDetailCropRect` cuts with
84240
+ * (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
84241
+ * here while the runtime fed `0.15`, so a classifier trained from this export
84242
+ * learned on more context than it is ever given live.
84243
+ */
84244
+ var CLASSIFIER_CROP_PADDING = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
84245
+ /** The convention a `crops` layout cuts with — the runtime default, recorded
84246
+ * in the manifest so a consumer never has to assume it. */
84247
+ var RETRAIN_CROP_CONVENTION = require_clip_model_registry.DEFAULT_DETAIL_CROP_CONVENTION;
84248
+ /** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
84249
+ * the trainer resizes per model, so this only bounds the upload. */
84250
+ var RETRAIN_CROP_MAX_SIDE = 256;
84251
+ /**
84252
+ * Manifest schema id. v2 = capturedAt / complete / lighting / layout.
84253
+ * v3 = `origin` (`track` | `recording`), `epochMs` on a recording still,
84254
+ * `collectionIds`, and `trackId` ABSENT on a recording still, which has none.
84255
+ */
84256
+ var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v3";
84257
+ /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`,
84258
+ * or `frames/<deviceId>/recording/<leaf>.jpg` for a recording still. */
82646
84259
  function retrainFramePath(frame) {
82647
84260
  const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
82648
- return `frames/${frame.deviceId}/${frame.trackId}/${leaf}.jpg`;
84261
+ const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
84262
+ return `frames/${frame.deviceId}/${dir}/${leaf}.jpg`;
84263
+ }
84264
+ /** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
84265
+ function retrainCropPath(macroClass, annotationId) {
84266
+ return `crops/${macroClass}/${annotationId}.jpg`;
84267
+ }
84268
+ /**
84269
+ * Narrow the planned frames to a filter. Pure. Every field narrows; an empty
84270
+ * filter keeps everything. Order is the input's.
84271
+ */
84272
+ function filterRetrainExport(frames, annotationsByFrame, filter, collectionsByFrame = /* @__PURE__ */ new Map()) {
84273
+ const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
84274
+ const collections = filter.collectionIds !== void 0 && filter.collectionIds.length > 0 ? new Set(filter.collectionIds) : null;
84275
+ const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
84276
+ return frames.filter((frame) => {
84277
+ if (wanted !== null && !wanted.has(frame.frameId)) return false;
84278
+ if (devices !== null && !devices.has(frame.deviceId)) return false;
84279
+ if (filter.completeOnly === true && !frame.complete) return false;
84280
+ if (collections !== null && !(collectionsByFrame.get(frame.frameId) ?? []).some((id) => collections.has(id))) return false;
84281
+ if (filter.batchId !== void 0) {
84282
+ if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
84283
+ }
84284
+ return true;
84285
+ });
82649
84286
  }
82650
84287
  /**
82651
84288
  * Derive every per-model shape for one annotation.
@@ -82667,6 +84304,8 @@ function deriveAll(annotation, frame, zone) {
82667
84304
  }
82668
84305
  /** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
82669
84306
  function buildRetrainManifest(input) {
84307
+ const layout = input.layout ?? "frames";
84308
+ const coordinates = input.coordinates ?? null;
82670
84309
  let annotationCount = 0;
82671
84310
  let modelErrorCount = 0;
82672
84311
  const frames = input.frames.map((frame) => {
@@ -82674,6 +84313,7 @@ function buildRetrainManifest(input) {
82674
84313
  const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
82675
84314
  annotationCount += 1;
82676
84315
  if (a.kind === "model_error") modelErrorCount += 1;
84316
+ const derived = deriveAll(a, frame, zone);
82677
84317
  return {
82678
84318
  id: a.id,
82679
84319
  macroClass: a.macroClass,
@@ -82684,24 +84324,39 @@ function buildRetrainManifest(input) {
82684
84324
  ...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
82685
84325
  ...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
82686
84326
  bbox: a.bbox,
82687
- derived: deriveAll(a, frame, zone)
84327
+ ...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
84328
+ derived
82688
84329
  };
82689
84330
  });
82690
84331
  return {
82691
84332
  frameId: frame.frameId,
82692
84333
  path: retrainFramePath(frame),
82693
84334
  deviceId: frame.deviceId,
82694
- trackId: frame.trackId,
84335
+ origin: frame.origin,
84336
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
84337
+ ...frame.epochMs !== void 0 ? { epochMs: frame.epochMs } : {},
84338
+ collectionIds: input.collectionsByFrame?.get(frame.frameId) ?? [],
82695
84339
  width: frame.width,
82696
84340
  height: frame.height,
82697
84341
  sizeBytes: frame.sizeBytes,
82698
84342
  sourceKind: frame.sourceKind,
82699
84343
  copiedAt: frame.copiedAt,
84344
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
84345
+ complete: frame.complete,
84346
+ lighting: retrainFrameLighting({
84347
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
84348
+ ...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
84349
+ coordinates
84350
+ }),
82700
84351
  ...zone !== null ? { zoneUsed: zone } : {},
82701
84352
  annotations
82702
84353
  };
82703
84354
  });
82704
84355
  return {
84356
+ schema: RETRAIN_MANIFEST_SCHEMA,
84357
+ layout,
84358
+ detailCropConvention: RETRAIN_CROP_CONVENTION,
84359
+ ...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
82705
84360
  generatedAt: input.generatedAt,
82706
84361
  batchId: input.batchId,
82707
84362
  frameCount: frames.length,
@@ -82712,123 +84367,39 @@ function buildRetrainManifest(input) {
82712
84367
  };
82713
84368
  }
82714
84369
  //#endregion
82715
- //#region src/pipeline-analytics/training-export/tar-stream.ts
82716
- /**
82717
- * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
82718
- * entry, without ever holding the archive in memory.
82719
- *
82720
- * ## Why hand-rolled rather than the `tar` package
82721
- *
82722
- * `tar` is in the workspace and is the right tool for what it does: archive a
82723
- * set of paths under one `cwd`. This is a different job. A track's media lives
82724
- * across several storage LOCATION roots (`eventMedia` and whatever else the
82725
- * operator relocated to), under paths that encode nothing a training set wants —
82726
- * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
82727
- * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
82728
- * of paths relative to a single cwd and gives no rename hook, so using it would
82729
- * mean staging a whole shadow tree on disk first: a full second copy of the
82730
- * dataset, written and then deleted, to avoid writing 100 lines.
82731
- *
82732
- * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
82733
- * fully specified, and asserted against the system `tar` binary in the spec next
82734
- * door rather than against my reading of the format.
82735
- *
82736
- * ## What this deliberately does NOT do
82737
- *
82738
- * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
82739
- * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
82740
- * splits a path across a 155-byte prefix and a 100-byte name, and
82741
- * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
82742
- * it and SAY it skipped it, rather than emitting a silently truncated entry.
82743
- */
82744
- var BLOCK_SIZE = 512;
82745
- var NAME_MAX = 100;
82746
- var PREFIX_MAX = 155;
82747
- /**
82748
- * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
82749
- * be represented. The split must land on a `/`, and each half has its own cap.
82750
- */
82751
- function splitUstarName(name) {
82752
- if (Buffer.byteLength(name) <= NAME_MAX) return {
82753
- prefix: "",
82754
- name
82755
- };
82756
- for (let i = name.length - 1; i >= 0; i--) {
82757
- if (name[i] !== "/") continue;
82758
- const head = name.slice(0, i);
82759
- const tail = name.slice(i + 1);
82760
- if (Buffer.byteLength(tail) > NAME_MAX) break;
82761
- if (Buffer.byteLength(head) <= PREFIX_MAX) return {
82762
- prefix: head,
82763
- name: tail
82764
- };
82765
- }
82766
- return null;
82767
- }
82768
- /** True when a path cannot be represented as a ustar entry name. */
82769
- function tarNameTooLong(name) {
82770
- return splitUstarName(name) === null;
82771
- }
82772
- function writeString(block, value, offset, length) {
82773
- block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
82774
- }
82775
- /** ustar numeric field: zero-padded octal, NUL-terminated. */
82776
- function writeOctal(block, value, offset, length) {
82777
- const text = Math.floor(value).toString(8).padStart(length - 1, "0");
82778
- block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
82779
- }
82780
- /**
82781
- * The 512-byte header for one regular file.
82782
- *
82783
- * The checksum is computed with the checksum field itself read as eight spaces —
82784
- * that is the format's rule, and getting it wrong produces an archive that looks
82785
- * fine until something tries to read it.
82786
- */
82787
- function tarHeader(entry) {
82788
- const split = splitUstarName(entry.name);
82789
- if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
82790
- const block = Buffer.alloc(BLOCK_SIZE);
82791
- writeString(block, split.name, 0, NAME_MAX);
82792
- writeOctal(block, 420, 100, 8);
82793
- writeOctal(block, 0, 108, 8);
82794
- writeOctal(block, 0, 116, 8);
82795
- writeOctal(block, entry.body.length, 124, 12);
82796
- writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
82797
- block.write(" ", 148, 8, "ascii");
82798
- block.write("0", 156, 1, "ascii");
82799
- block.write("ustar\0", 257, 6, "ascii");
82800
- block.write("00", 263, 2, "ascii");
82801
- writeString(block, split.prefix, 345, PREFIX_MAX);
82802
- let sum = 0;
82803
- for (const byte of block) sum += byte;
82804
- writeOctal(block, sum, 148, 7);
82805
- block.write("\0", 154, 1, "ascii");
82806
- block.write(" ", 155, 1, "ascii");
82807
- return block;
82808
- }
82809
- /** Zero padding that rounds a body up to the 512-byte block. */
82810
- function tarPadding(size) {
82811
- const remainder = size % BLOCK_SIZE;
82812
- return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
82813
- }
82814
- /** Header + body + padding for one entry, ready to write. */
82815
- function tarBlocks(entry) {
82816
- return [
82817
- tarHeader(entry),
82818
- entry.body,
82819
- tarPadding(entry.body.length)
82820
- ];
82821
- }
82822
- /** The two zero blocks that terminate an archive. */
82823
- function tarTrailer() {
82824
- return Buffer.alloc(BLOCK_SIZE * 2);
82825
- }
82826
- //#endregion
82827
84370
  //#region src/pipeline-analytics/retrain/retrain-export-handler.ts
82828
- var RETRAIN_MANIFEST_ENTRY = "annotations.json";
82829
- /** Yield between files so a large export cannot monopolise the hub's one
82830
- * thread — the same discipline the training export follows. */
82831
- var PACING_MS = 25;
84371
+ /** The export request a URL carries (`getRetrainExportUrl` composes it). */
84372
+ function parseRetrainExportRequest(url) {
84373
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
84374
+ const params = new URLSearchParams(query);
84375
+ const deviceIds = parseRetrainDeviceIds(url);
84376
+ const frameIds = (params.get("frameIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84377
+ const collectionIds = (params.get("collectionIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84378
+ const batchId = params.get("batchId")?.trim() ?? "";
84379
+ return {
84380
+ filter: {
84381
+ ...deviceIds.length > 0 ? { deviceIds: [...deviceIds] } : {},
84382
+ ...frameIds.length > 0 ? { frameIds } : {},
84383
+ ...collectionIds.length > 0 ? { collectionIds } : {},
84384
+ ...batchId.length > 0 ? { batchId } : {},
84385
+ ...params.get("completeOnly") === "1" ? { completeOnly: true } : {}
84386
+ },
84387
+ layout: params.get("layout") === "crops" ? "crops" : "frames"
84388
+ };
84389
+ }
84390
+ /** The query string `parseRetrainExportRequest` reads back. */
84391
+ function retrainExportQuery(request) {
84392
+ const params = new URLSearchParams();
84393
+ const { filter } = request;
84394
+ if (filter.deviceIds !== void 0 && filter.deviceIds.length > 0) params.set("deviceIds", filter.deviceIds.join(","));
84395
+ if (filter.frameIds !== void 0 && filter.frameIds.length > 0) params.set("frameIds", filter.frameIds.join(","));
84396
+ if (filter.collectionIds !== void 0 && filter.collectionIds.length > 0) params.set("collectionIds", filter.collectionIds.join(","));
84397
+ if (filter.batchId !== void 0) params.set("batchId", filter.batchId);
84398
+ if (filter.completeOnly === true) params.set("completeOnly", "1");
84399
+ if (request.layout === "crops") params.set("layout", "crops");
84400
+ const text = params.toString();
84401
+ return text.length > 0 ? `?${text}` : "";
84402
+ }
82832
84403
  function parseRetrainDeviceIds(url) {
82833
84404
  const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
82834
84405
  const raw = new URLSearchParams(query).get("deviceIds");
@@ -82851,10 +84422,10 @@ function createRetrainExportHandler(deps) {
82851
84422
  res.end();
82852
84423
  return;
82853
84424
  }
82854
- const deviceIds = parseRetrainDeviceIds(req.url ?? "");
84425
+ const request = parseRetrainExportRequest(req.url ?? "");
82855
84426
  let plan;
82856
84427
  try {
82857
- plan = await deps.buildPlan(deviceIds);
84428
+ plan = await deps.buildPlan(request);
82858
84429
  } catch (err) {
82859
84430
  deps.logger.warn("retrain export plan failed", { meta: { error: String(err) } });
82860
84431
  res.writeHead(500, { "content-type": "text/plain" });
@@ -82868,7 +84439,8 @@ function createRetrainExportHandler(deps) {
82868
84439
  "x-camstack-retrain-frames": String(plan.manifest.frameCount),
82869
84440
  "x-camstack-retrain-annotations": String(plan.manifest.annotationCount),
82870
84441
  "x-camstack-retrain-model-errors": String(plan.manifest.modelErrorCount),
82871
- "x-camstack-retrain-batch": plan.manifest.batchId
84442
+ "x-camstack-retrain-batch": plan.manifest.batchId,
84443
+ "x-camstack-retrain-layout": plan.manifest.layout
82872
84444
  });
82873
84445
  if (method === "HEAD") {
82874
84446
  res.end();
@@ -82881,64 +84453,193 @@ function createRetrainExportHandler(deps) {
82881
84453
  });
82882
84454
  if (!ok) res.once("drain", resolve);
82883
84455
  });
82884
- const mtimeMs = deps.now();
82885
- for (const block of tarBlocks({
82886
- name: RETRAIN_MANIFEST_ENTRY,
82887
- body: Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"),
82888
- mtimeMs
82889
- })) await write(block);
82890
- const shipped = [];
82891
- let missing = 0;
82892
- for (const frame of plan.frames) {
82893
- const entry = plan.manifest.frames.find((f) => f.frameId === frame.frameId);
82894
- if (entry === void 0) continue;
82895
- if (tarNameTooLong(entry.path)) {
82896
- missing += 1;
82897
- deps.logger.warn("retrain export skipped a frame — path too long for the archive", {
82898
- tags: { deviceId: frame.deviceId },
82899
- meta: {
82900
- trackId: frame.trackId,
82901
- frameId: frame.frameId,
82902
- path: entry.path
82903
- }
82904
- });
82905
- continue;
82906
- }
82907
- const bytes = await deps.readFrame(frame);
82908
- if (bytes === null) {
82909
- missing += 1;
82910
- deps.logger.warn("retrain export skipped a frame — the COPY is unreadable", {
82911
- tags: { deviceId: frame.deviceId },
82912
- meta: {
82913
- trackId: frame.trackId,
82914
- frameId: frame.frameId
82915
- }
82916
- });
82917
- continue;
82918
- }
82919
- for (const block of tarBlocks({
82920
- name: entry.path,
82921
- body: bytes,
82922
- mtimeMs
82923
- })) await write(block);
82924
- shipped.push(frame.frameId);
82925
- await new Promise((resolve) => setTimeout(resolve, PACING_MS));
82926
- }
82927
- await write(tarTrailer());
84456
+ await writeRetrainArchiveTo({
84457
+ plan,
84458
+ readFrame: deps.readFrame,
84459
+ cutCrop: deps.cutCrop,
84460
+ sink: { write },
84461
+ markExported: deps.markExported,
84462
+ logger: deps.logger,
84463
+ now: deps.now
84464
+ });
82928
84465
  await new Promise((resolve) => {
82929
84466
  res.end(resolve);
82930
84467
  });
82931
- await deps.markExported(shipped, plan.manifest.batchId);
82932
- deps.logger.info("retrain export complete", { meta: {
82933
- batchId: plan.manifest.batchId,
82934
- frames: shipped.length,
82935
- missing,
82936
- annotations: plan.manifest.annotationCount,
82937
- modelErrors: plan.manifest.modelErrorCount
82938
- } });
82939
84468
  };
82940
84469
  }
82941
84470
  //#endregion
84471
+ //#region src/pipeline-analytics/retrain/retrain-pipeline-boxes.ts
84472
+ /**
84473
+ * The boxes the PIPELINE drew on a copied frame — Step 1 of the training
84474
+ * screen.
84475
+ *
84476
+ * A correction has to start from the decision being corrected. Proposing with
84477
+ * a fresh model run answers a different question ("what would a model say
84478
+ * now"), and the frame the operator is fixing is precisely the one where the
84479
+ * live answer was wrong — a re-run that happens to be right hides the phantom
84480
+ * the operator came to remove. So the canvas opens with what the track store
84481
+ * says the pipeline had on that frame, and the operator accepts, corrects,
84482
+ * relabels or deletes it.
84483
+ *
84484
+ * ## Where a box comes from, best first
84485
+ *
84486
+ * 1. **The subject box recorded ON the source media row** (D510). Exact by
84487
+ * construction: the box was measured on the frame the blob shows. Only the
84488
+ * copy's own track can have one.
84489
+ * 2. **The track position nearest the capture time**, within
84490
+ * {@link PIPELINE_BOX_TOLERANCE_MS}. Positions are frame-rate, so the
84491
+ * nearest one is normally the same detection; the delta is reported so a
84492
+ * reviewer can see how far it was.
84493
+ *
84494
+ * Every OTHER track alive on the camera at that instant contributes its own
84495
+ * nearest position the same way — a frame with three people is three boxes,
84496
+ * and a recording still (no track of its own) gets everything the pipeline
84497
+ * was tracking at that moment, which is exactly what a missed-object review
84498
+ * needs to see is NOT there.
84499
+ *
84500
+ * Nothing here does I/O. The caller reads the tracks and the subject box.
84501
+ */
84502
+ /** How far from the capture time a position may be and still be "this frame".
84503
+ * Positions are recorded at the analysis rate (several per second), so half
84504
+ * a second is a generous match and still not a different moment. */
84505
+ var PIPELINE_BOX_TOLERANCE_MS = 500;
84506
+ /**
84507
+ * How far back a track may have STARTED and still be alive at the frame. The
84508
+ * track store windows on `firstSeen`, so a track born before this is not
84509
+ * asked about — a vehicle parked for hours is the case it misses, and the
84510
+ * operator draws that box by hand.
84511
+ */
84512
+ var PIPELINE_TRACK_LOOKBACK_MS = 30 * 6e4;
84513
+ /** At most this many tracks of one camera are read around one frame. */
84514
+ var PIPELINE_TRACK_AROUND_LIMIT = 200;
84515
+ /** The least-wrong macro tier for a class the macro map does not know. The
84516
+ * operator corrects it; what must not happen is the raw string being
84517
+ * promoted into the macro tier. */
84518
+ var FALLBACK_MACRO_CLASS = "person";
84519
+ /**
84520
+ * Split a detector's class into the two tiers.
84521
+ *
84522
+ * A macro class stays in the macro tier; anything else maps through the COCO
84523
+ * macro map, and the class itself becomes the LABEL. A macro word is never a
84524
+ * label, in any casing — the tier rule the annotation write enforces.
84525
+ */
84526
+ function toRetrainClassTiers(className, label) {
84527
+ const cls = className.trim().toLowerCase();
84528
+ const usableLabel = (value) => {
84529
+ const trimmed = value?.trim();
84530
+ if (trimmed === void 0 || trimmed === "") return void 0;
84531
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
84532
+ };
84533
+ if (isRetrainMacroClass(cls)) {
84534
+ const kept = usableLabel(label);
84535
+ return kept !== void 0 ? {
84536
+ macroClass: cls,
84537
+ label: kept
84538
+ } : { macroClass: cls };
84539
+ }
84540
+ const mapped = require_clip_model_registry.COCO_TO_MACRO.mapping[cls];
84541
+ const macroClass = mapped !== void 0 && isRetrainMacroClass(mapped) ? mapped : FALLBACK_MACRO_CLASS;
84542
+ const kept = usableLabel(className);
84543
+ return kept !== void 0 ? {
84544
+ macroClass,
84545
+ label: kept
84546
+ } : { macroClass };
84547
+ }
84548
+ /** A pixel box as a clamped normalised one, or `null` when nothing is left. */
84549
+ function normalise(box, frameWidth, frameHeight) {
84550
+ if (frameWidth === void 0 || frameHeight === void 0) return null;
84551
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
84552
+ const x = clampSpan(box.x, box.w, frameWidth);
84553
+ const y = clampSpan(box.y, box.h, frameHeight);
84554
+ if (x === null || y === null) return null;
84555
+ return {
84556
+ x: x.start,
84557
+ y: y.start,
84558
+ w: x.size,
84559
+ h: y.size
84560
+ };
84561
+ }
84562
+ /** One axis of a pixel box, normalised and clamped into `[0,1]`. The size is
84563
+ * divided directly when nothing was clamped, so an exact box stays exact. */
84564
+ function clampSpan(start, size, extent) {
84565
+ const lo = start / extent;
84566
+ const hi = (start + size) / extent;
84567
+ const s = Math.max(0, lo);
84568
+ const n = lo >= 0 && hi <= 1 ? size / extent : Math.min(1, hi) - s;
84569
+ return n > 0 ? {
84570
+ start: s,
84571
+ size: n
84572
+ } : null;
84573
+ }
84574
+ function nearestPosition(track, at, tolerance) {
84575
+ let best = null;
84576
+ let bestDelta = Number.POSITIVE_INFINITY;
84577
+ for (const p of track.positions) {
84578
+ const delta = Math.abs(p.timestamp - at);
84579
+ if (delta < bestDelta) {
84580
+ best = p;
84581
+ bestDelta = delta;
84582
+ }
84583
+ }
84584
+ if (best === null || bestDelta > tolerance) return null;
84585
+ return {
84586
+ position: best,
84587
+ deltaMs: bestDelta
84588
+ };
84589
+ }
84590
+ function boxFor(track, bbox, basis, deltaMs, score) {
84591
+ const tiers = toRetrainClassTiers(track.className, track.label);
84592
+ const subLabel = track.subLabel?.trim();
84593
+ return {
84594
+ bbox,
84595
+ macroClass: tiers.macroClass,
84596
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
84597
+ ...subLabel !== void 0 && subLabel !== "" && !isRetrainMacroClass(subLabel.toLowerCase()) ? { subLabel } : {},
84598
+ ...score !== void 0 ? { score } : {},
84599
+ ...track.modelId !== void 0 ? { modelId: track.modelId } : {},
84600
+ trackId: track.trackId,
84601
+ basis,
84602
+ deltaMs
84603
+ };
84604
+ }
84605
+ /** The own track's box: the recorded subject box first, the nearest position second. */
84606
+ function ownBox(input, tolerance) {
84607
+ const track = input.ownTrack;
84608
+ if (track === null) return null;
84609
+ const at = input.frame.capturedAt;
84610
+ const nearest = at !== void 0 ? nearestPosition(track, at, tolerance) : null;
84611
+ if (input.ownSubjectBox !== null) {
84612
+ const recorded = normalise(input.ownSubjectBox, input.ownSubjectBox.frameWidth, input.ownSubjectBox.frameHeight);
84613
+ if (recorded !== null) return boxFor(track, recorded, "subject-box", 0, nearest?.position.score);
84614
+ }
84615
+ if (nearest === null) return null;
84616
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
84617
+ return placed === null ? null : boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score);
84618
+ }
84619
+ /**
84620
+ * Every box the pipeline had on this frame, own track first. Pure.
84621
+ */
84622
+ function pipelineBoxesForFrame(input) {
84623
+ const tolerance = input.toleranceMs ?? PIPELINE_BOX_TOLERANCE_MS;
84624
+ const out = [];
84625
+ const own = ownBox(input, tolerance);
84626
+ if (own !== null) out.push(own);
84627
+ const at = input.frame.capturedAt;
84628
+ if (at === void 0) return out;
84629
+ const ownId = input.frame.trackId;
84630
+ const seen = new Set(ownId !== void 0 ? [ownId] : []);
84631
+ for (const track of input.neighbours) {
84632
+ if (seen.has(track.trackId) || track.deviceId !== input.frame.deviceId) continue;
84633
+ seen.add(track.trackId);
84634
+ const nearest = nearestPosition(track, at, tolerance);
84635
+ if (nearest === null) continue;
84636
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
84637
+ if (placed === null) continue;
84638
+ out.push(boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score));
84639
+ }
84640
+ return out;
84641
+ }
84642
+ //#endregion
82942
84643
  //#region src/pipeline-analytics/retrain/retrain-assist.ts
82943
84644
  /** The whole frame, as a normalised rectangle. */
82944
84645
  var WHOLE_FRAME = {
@@ -83009,12 +84710,11 @@ function mapAssistDetails(input) {
83009
84710
  withoutBox += 1;
83010
84711
  continue;
83011
84712
  }
83012
- const macroClass = isRetrainMacroClass(d.className) ? d.className : FALLBACK_MACRO_CLASS;
83013
- const label = isRetrainMacroClass(d.className) ? d.label : d.className;
84713
+ const tiers = toRetrainClassTiers(d.className, d.label);
83014
84714
  proposals.push({
83015
84715
  bbox,
83016
- macroClass,
83017
- ...label !== void 0 && label !== "" ? { label } : {},
84716
+ macroClass: tiers.macroClass,
84717
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
83018
84718
  score: d.score,
83019
84719
  modelId
83020
84720
  });
@@ -83025,16 +84725,6 @@ function mapAssistDetails(input) {
83025
84725
  withoutBox
83026
84726
  };
83027
84727
  }
83028
- /**
83029
- * Macro tier for a detector class we do not carry as a macro class.
83030
- *
83031
- * The class itself becomes the LABEL (see above) and the macro tier gets a
83032
- * value the operator will almost certainly correct — which is fine, because it
83033
- * is a PROPOSAL and correcting it is the job. What must not happen is the
83034
- * detector's own string being promoted into the macro tier: that is how
83035
- * `postman` becomes a class with one example in it.
83036
- */
83037
- var FALLBACK_MACRO_CLASS = "person";
83038
84728
  /** A proposal, as a draft annotation ready to store if the operator accepts it. */
83039
84729
  function proposalToDraft(proposal, owner) {
83040
84730
  return {
@@ -83243,23 +84933,31 @@ async function restageRetrainTrack(deps, input) {
83243
84933
  var DEFAULT_STAGING_LIMIT = 100;
83244
84934
  /** A macro class that failed validation — the write is refused, never coerced. */
83245
84935
  var RetrainAnnotationError = class extends Error {};
84936
+ /** A store frame on the wire. A recording still carries no track and no
84937
+ * source media row, and says so by leaving both out. */
83246
84938
  function toWireFrame(f) {
83247
84939
  return {
83248
84940
  frameId: f.frameId,
83249
84941
  deviceId: f.deviceId,
83250
- trackId: f.trackId,
83251
- sourceMediaId: f.sourceMediaId,
84942
+ origin: f.origin,
84943
+ ...f.origin === "track" ? {
84944
+ trackId: f.trackId,
84945
+ sourceMediaId: f.sourceMediaId
84946
+ } : {},
84947
+ ...f.epochMs !== void 0 ? { epochMs: f.epochMs } : {},
83252
84948
  sourceKind: f.sourceKind,
83253
84949
  sizeBytes: f.sizeBytes,
83254
84950
  width: f.width,
83255
84951
  height: f.height,
83256
- copiedAt: f.copiedAt
84952
+ copiedAt: f.copiedAt,
84953
+ ...f.capturedAt !== void 0 ? { capturedAt: f.capturedAt } : {},
84954
+ complete: f.complete
83257
84955
  };
83258
84956
  }
83259
84957
  function toWireAnnotation(a) {
83260
84958
  return {
83261
84959
  id: a.id,
83262
- trackId: a.trackId,
84960
+ ...a.trackId !== "" ? { trackId: a.trackId } : {},
83263
84961
  deviceId: a.deviceId,
83264
84962
  mediaKey: a.mediaKey,
83265
84963
  bbox: a.bbox,
@@ -83326,11 +85024,116 @@ var RetrainService = class {
83326
85024
  if (candidates.length === 0) await this.logNoAnnotatableFrame(trackId, media, copies.length);
83327
85025
  return {
83328
85026
  candidates,
83329
- copies: copies.map(toWireFrame),
85027
+ copies: await this.toWireFrames(copies),
83330
85028
  ...autoPick !== null ? { autoPickMediaId: autoPick.mediaId } : {}
83331
85029
  };
83332
85030
  }
83333
85031
  /**
85032
+ * The wire frames, each with the boxes the pipeline drew on it (Step 1) and
85033
+ * the collections it belongs to.
85034
+ *
85035
+ * Index reads only — the track rows and the media rows' subject boxes, no
85036
+ * blob and no inference. One read per distinct track and per distinct
85037
+ * instant, not per box. A failed read degrades to fewer boxes, logged, and
85038
+ * never to a failed listing: the frames are the operator's work and must
85039
+ * stay reachable when the evidence is not.
85040
+ */
85041
+ async toWireFrames(copies) {
85042
+ const collections = this.deps.memberships !== void 0 && copies.length > 0 ? await this.deps.memberships.collectionsOf(copies.map((c) => c.frameId)).catch((err) => {
85043
+ this.deps.logger.warn("retrain frames: collection membership unreadable", { meta: {
85044
+ frames: copies.length,
85045
+ error: String(err)
85046
+ } });
85047
+ return /* @__PURE__ */ new Map();
85048
+ }) : null;
85049
+ const evidence = this.deps.pipelineEvidence;
85050
+ const ownTracks = /* @__PURE__ */ new Map();
85051
+ const subjectBoxes = /* @__PURE__ */ new Map();
85052
+ const out = [];
85053
+ for (const copy of copies) {
85054
+ const wire = toWireFrame(copy);
85055
+ const collectionIds = collections?.get(copy.frameId);
85056
+ const withCollections = collections !== null ? {
85057
+ ...wire,
85058
+ collectionIds: collectionIds ?? []
85059
+ } : wire;
85060
+ if (evidence === void 0) {
85061
+ out.push(withCollections);
85062
+ continue;
85063
+ }
85064
+ const trackId = copy.origin === "track" ? copy.trackId : "";
85065
+ if (trackId !== "" && !ownTracks.has(trackId)) {
85066
+ ownTracks.set(trackId, await this.deps.tracks.getPersistedByTrackId(trackId).catch(() => null));
85067
+ subjectBoxes.set(trackId, await this.readSubjectBoxes(evidence, trackId, copy.deviceId));
85068
+ }
85069
+ const neighbours = copy.capturedAt !== void 0 ? await this.readTracksAround(evidence, copy.deviceId, copy.capturedAt) : [];
85070
+ const pipelineBoxes = pipelineBoxesForFrame({
85071
+ frame: {
85072
+ deviceId: copy.deviceId,
85073
+ ...trackId !== "" ? { trackId } : {},
85074
+ ...copy.capturedAt !== void 0 ? { capturedAt: copy.capturedAt } : {}
85075
+ },
85076
+ ownTrack: trackId !== "" ? ownTracks.get(trackId) ?? null : null,
85077
+ ownSubjectBox: trackId !== "" ? subjectBoxes.get(trackId)?.get(copy.sourceMediaId) ?? null : null,
85078
+ neighbours
85079
+ });
85080
+ out.push({
85081
+ ...withCollections,
85082
+ pipelineBoxes
85083
+ });
85084
+ }
85085
+ return out;
85086
+ }
85087
+ /**
85088
+ * The frame a frame-scoped call names, or a refusal. The frame is the
85089
+ * authority: its camera must be the caller's, and a `trackId`, when the
85090
+ * caller sends one, must be the frame's own — a recording still has none.
85091
+ */
85092
+ async requireFrame(ref, action) {
85093
+ const frame = await this.deps.frames.get(ref.frameId);
85094
+ const trackMismatch = frame !== null && ref.trackId !== void 0 && (frame.origin !== "track" || frame.trackId !== ref.trackId);
85095
+ if (frame === null || frame.deviceId !== ref.deviceId || trackMismatch) {
85096
+ this.deps.logger.warn(`retrain ${action} refused: the frame does not belong to that camera or track`, {
85097
+ tags: { deviceId: ref.deviceId },
85098
+ meta: {
85099
+ frameId: ref.frameId,
85100
+ ...ref.trackId !== void 0 ? { trackId: ref.trackId } : {}
85101
+ }
85102
+ });
85103
+ 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)}`}`);
85104
+ }
85105
+ return frame;
85106
+ }
85107
+ async readSubjectBoxes(evidence, trackId, deviceId) {
85108
+ try {
85109
+ const rows = await evidence.subjectBoxes(trackId, deviceId);
85110
+ return new Map(rows.map((r) => [r.mediaId, r.subjectBox]));
85111
+ } catch (err) {
85112
+ this.deps.logger.warn("retrain pipeline boxes: subject boxes unreadable", {
85113
+ tags: { deviceId },
85114
+ meta: {
85115
+ trackId,
85116
+ error: String(err)
85117
+ }
85118
+ });
85119
+ return /* @__PURE__ */ new Map();
85120
+ }
85121
+ }
85122
+ async readTracksAround(evidence, deviceId, atMs) {
85123
+ try {
85124
+ return await evidence.tracksAround(deviceId, atMs);
85125
+ } catch (err) {
85126
+ this.deps.logger.warn("retrain pipeline boxes: tracks around the frame unreadable", {
85127
+ tags: { deviceId },
85128
+ meta: {
85129
+ atMs,
85130
+ error: String(err)
85131
+ }
85132
+ });
85133
+ return [];
85134
+ }
85135
+ }
85136
+ /**
83334
85137
  * A track with no annotatable frame is a REAL state, and the page has to say
83335
85138
  * so rather than render an empty strip that reads as a slow load. Two causes,
83336
85139
  * named apart: the whole-frame media AGED OUT, or the track only ever held
@@ -83356,6 +85159,7 @@ var RetrainService = class {
83356
85159
  async selectFrames(input) {
83357
85160
  const media = await this.deps.media.listTrackMedia(input.trackId);
83358
85161
  const kindOf = new Map(media.map((m) => [m.mediaId, m.kind]));
85162
+ const capturedAtOf = new Map(media.map((m) => [m.mediaId, m.timestamp]));
83359
85163
  const copied = [];
83360
85164
  const refused = [];
83361
85165
  for (const mediaId of input.mediaIds) {
@@ -83363,7 +85167,8 @@ var RetrainService = class {
83363
85167
  deviceId: input.deviceId,
83364
85168
  trackId: input.trackId,
83365
85169
  sourceMediaId: mediaId,
83366
- sourceKind: kindOf.get(mediaId) ?? "keyFrame"
85170
+ sourceKind: kindOf.get(mediaId) ?? "keyFrame",
85171
+ ...capturedAtOf.has(mediaId) ? { capturedAt: capturedAtOf.get(mediaId) } : {}
83367
85172
  });
83368
85173
  if (result.kind === "copied") copied.push(toWireFrame(result.frame));
83369
85174
  else refused.push({
@@ -83376,37 +85181,60 @@ var RetrainService = class {
83376
85181
  refused
83377
85182
  };
83378
85183
  }
85184
+ /**
85185
+ * The operator's "every subject in this frame is boxed". Scoped to the
85186
+ * track the caller names: a frame of another track is refused, loudly.
85187
+ */
85188
+ async setFrameComplete(input) {
85189
+ const frame = await this.requireFrame(input, "complete");
85190
+ const updated = await this.deps.frames.setComplete(frame.frameId, input.complete);
85191
+ if (updated === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} vanished`);
85192
+ this.deps.logger.info("retrain frame completeness set", {
85193
+ tags: { deviceId: input.deviceId },
85194
+ meta: {
85195
+ trackId: frame.trackId,
85196
+ frameId: input.frameId,
85197
+ complete: input.complete
85198
+ }
85199
+ });
85200
+ return toWireFrame(updated);
85201
+ }
83379
85202
  /** Un-select: annotations first, then the copy. Never the other way round —
83380
- * an annotation whose frame is gone is unopenable and unfixable. */
85203
+ * an annotation whose frame is gone is unopenable and unfixable. The frame's
85204
+ * collection memberships go with it. */
83381
85205
  async deselectFrame(input) {
83382
85206
  const frame = await this.deps.frames.get(input.frameId);
83383
85207
  if (frame === null) {
83384
85208
  this.deps.logger.warn("retrain deselect: no such copied frame", {
83385
85209
  tags: { deviceId: input.deviceId },
83386
- meta: {
83387
- trackId: input.trackId,
83388
- frameId: input.frameId
83389
- }
85210
+ meta: { frameId: input.frameId }
83390
85211
  });
83391
85212
  return {
83392
85213
  removed: false,
83393
85214
  removedAnnotations: 0
83394
85215
  };
83395
85216
  }
83396
- const removedAnnotations = await this.deps.annotations.deleteForFrame(input.frameId);
85217
+ await this.requireFrame(input, "deselect");
85218
+ return {
85219
+ removed: true,
85220
+ removedAnnotations: await this.removeFrame(frame)
85221
+ };
85222
+ }
85223
+ /** Remove a frame from the dataset: annotations, memberships, then the copy. */
85224
+ async removeFrame(frame) {
85225
+ const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
85226
+ await this.deps.memberships?.forgetFrame(frame.frameId);
83397
85227
  await this.deps.frames.deleteFrame(frame);
83398
85228
  this.deps.logger.info("retrain frame removed from the dataset", {
83399
- tags: { deviceId: input.deviceId },
85229
+ tags: { deviceId: frame.deviceId },
83400
85230
  meta: {
83401
- trackId: input.trackId,
83402
- frameId: input.frameId,
85231
+ origin: frame.origin,
85232
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
85233
+ frameId: frame.frameId,
83403
85234
  removedAnnotations
83404
85235
  }
83405
85236
  });
83406
- return {
83407
- removed: true,
83408
- removedAnnotations
83409
- };
85237
+ return removedAnnotations;
83410
85238
  }
83411
85239
  /** The copy's pixels plus its exact dimensions — the canvas needs both. */
83412
85240
  async getFrameImage(frameId) {
@@ -83446,7 +85274,7 @@ var RetrainService = class {
83446
85274
  };
83447
85275
  }
83448
85276
  const frame = await this.deps.frames.get(input.frameId);
83449
- if (frame === null) return {
85277
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
83450
85278
  kind: "refused",
83451
85279
  reason: "unknown-frame"
83452
85280
  };
@@ -83501,8 +85329,8 @@ var RetrainService = class {
83501
85329
  }
83502
85330
  });
83503
85331
  const owner = {
83504
- trackId: input.trackId,
83505
- deviceId: input.deviceId,
85332
+ trackId: frame.trackId,
85333
+ deviceId: frame.deviceId,
83506
85334
  mediaKey: input.frameId
83507
85335
  };
83508
85336
  return {
@@ -83525,8 +85353,12 @@ var RetrainService = class {
83525
85353
  belowThreshold: mapping.belowThreshold
83526
85354
  };
83527
85355
  }
83528
- async listAnnotations(trackId) {
83529
- return (await this.deps.annotations.listByTrack(trackId)).map(toWireAnnotation);
85356
+ /** The annotations of a track, or of named frames (a recording still has
85357
+ * no track). One of the two must be given. */
85358
+ async listAnnotations(input) {
85359
+ if (input.trackId !== void 0 && input.trackId !== "") return (await this.deps.annotations.listByTrack(input.trackId)).map(toWireAnnotation);
85360
+ if (input.frameIds !== void 0) return (await this.deps.annotations.listByFrames(input.frameIds)).map(toWireAnnotation);
85361
+ throw new RetrainAnnotationError("listRetrainAnnotations needs a trackId or frameIds");
83530
85362
  }
83531
85363
  /**
83532
85364
  * Replace every annotation on one frame.
@@ -83538,15 +85370,15 @@ var RetrainService = class {
83538
85370
  * believes they stored.
83539
85371
  */
83540
85372
  async saveAnnotations(input) {
83541
- if (await this.deps.frames.get(input.frameId) === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} does not exist — select it before annotating it`);
85373
+ const frame = await this.requireFrame(input, "save");
83542
85374
  for (const a of input.annotations) {
83543
85375
  if (!isRetrainMacroClass(a.macroClass)) throw new RetrainAnnotationError(`"${a.macroClass}" is not a macro class`);
83544
85376
  const violation = checkTierSeparation(a);
83545
85377
  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)`);
83546
85378
  }
83547
85379
  const drafts = input.annotations.map((a) => ({
83548
- trackId: input.trackId,
83549
- deviceId: input.deviceId,
85380
+ trackId: frame.trackId,
85381
+ deviceId: frame.deviceId,
83550
85382
  mediaKey: input.frameId,
83551
85383
  bbox: a.bbox,
83552
85384
  macroClass: a.macroClass,
@@ -83562,7 +85394,8 @@ var RetrainService = class {
83562
85394
  this.deps.logger.info("retrain annotations saved", {
83563
85395
  tags: { deviceId: input.deviceId },
83564
85396
  meta: {
83565
- trackId: input.trackId,
85397
+ origin: frame.origin,
85398
+ trackId: frame.trackId,
83566
85399
  frameId: input.frameId,
83567
85400
  subjects: saved.length - errors,
83568
85401
  modelErrors: errors
@@ -84588,7 +86421,8 @@ function selectsRow(mode, toLocationId, data, isRetrain, fromLocationId) {
84588
86421
  if (mode === "seal") return rowIsUnstamped(data);
84589
86422
  if (mediaRowLocation(data) === toLocationId) return false;
84590
86423
  if (mode === "gallery") return !isRetrain && rowIsGallery(data);
84591
- return isRetrain || !rowIsGallery(data);
86424
+ if (isRetrain) return !rowIsDurableRetrainCopy(data);
86425
+ return !rowIsGallery(data);
84592
86426
  }
84593
86427
  var MediaRelocateEngine = class {
84594
86428
  deps;
@@ -92963,6 +94797,14 @@ var RECORDED_STILL_PROFILE = "high";
92963
94797
  */
92964
94798
  var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
92965
94799
  /**
94800
+ * Width cap for a still added to the retrain dataset from the recording.
94801
+ *
94802
+ * Effectively none: a training frame is wanted at the camera's own resolution
94803
+ * (the same pixels a native key frame has), so the bound is only a ceiling
94804
+ * against a pathological stream, far above any camera on the fleet.
94805
+ */
94806
+ var RETRAIN_STILL_MAX_WIDTH = 7680;
94807
+ /**
92966
94808
  * Build the production ports.
92967
94809
  *
92968
94810
  * Every port swallows its own failure into the `null` the policy expects: a
@@ -97247,7 +99089,7 @@ var ANALYTICS_COLLECTIONS = [
97247
99089
  collection: RETRAIN_FRAMES_COLLECTION,
97248
99090
  classification: {
97249
99091
  kind: "never-orphaned",
97250
- why: "the frame COPIES the dataset owns outright, under a `retrain` prefix no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
99092
+ why: "the frame COPIES the dataset owns outright, in the `retrain/` subtree of the durable `artifacts` class (D781), which no owner cascade and no age sweep walks (D81). Also declares `trackId` as provenance. Rows go only through `deleteFrame`, after their annotations, on operator deselection. NOTE: nothing caps this table or the disk it consumes — that is a deliberate decision, not an oversight, and it is the one collection here whose bound is an operator's habit."
97251
99093
  }
97252
99094
  },
97253
99095
  {
@@ -106658,6 +108500,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
106658
108500
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
106659
108501
  * until the stores are built. */
106660
108502
  retrain = null;
108503
+ retrainCollections = null;
108504
+ retrainLlmAssist = null;
108505
+ retrainCollectionStore = null;
106661
108506
  retrainFrames = null;
106662
108507
  retrainAnnotations = null;
106663
108508
  /** The NC artifact plane (signed public links for notification media) and its
@@ -108514,6 +110359,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
108514
110359
  resolveRoot: (locationId) => locationId === LEGACY_ADDON_DATA_GALLERY_LOCATION ? Promise.resolve(node_path.default.join(this.ctx.dataDir, "gallery")) : locationId === GALLERY_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
108515
110360
  location: ARTIFACTS_MEDIA_LOCATION,
108516
110361
  relativePath: GALLERY_TENANT_SUBPATH
110362
+ }) : locationId === RETRAIN_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
110363
+ location: ARTIFACTS_MEDIA_LOCATION,
110364
+ relativePath: ""
108517
110365
  }) : this.ctx.api.storage.resolve.query({
108518
110366
  location: locationId,
108519
110367
  relativePath: ""
@@ -108692,7 +110540,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
108692
110540
  logger: logger.child("RetrainFrames"),
108693
110541
  now: () => Date.now(),
108694
110542
  writeGate: this.mediaWriteGate,
108695
- resolveEventMediaLocation: (deviceId) => this.resolveEventMediaWriteLocation(api, logger, deviceId),
110543
+ measureChroma: (bytes) => chromaSpread(bytes),
108696
110544
  readSource: async (mediaKey) => {
108697
110545
  const file = await mediaStore.getById(mediaKey);
108698
110546
  return file === null ? null : Buffer.from(file.base64, "base64");
@@ -108706,7 +110554,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
108706
110554
  });
108707
110555
  this.retrainFrames = retrainFrames;
108708
110556
  this.retrainAnnotations = retrainAnnotations;
108709
- this.retrain = new RetrainService({
110557
+ const retrainCollectionStore = new RetrainCollectionStore({
110558
+ store: api.settingsStore,
110559
+ logger: logger.child("RetrainCollections"),
110560
+ now: () => Date.now(),
110561
+ newId: () => (0, node_crypto.randomUUID)()
110562
+ });
110563
+ this.retrainCollectionStore = retrainCollectionStore;
110564
+ const retrain = new RetrainService({
108710
110565
  tracks: {
108711
110566
  listStaging: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
108712
110567
  listDeviceIds: () => trackStore.listDeviceIds(),
@@ -108724,7 +110579,70 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
108724
110579
  frames: retrainFrames,
108725
110580
  annotations: retrainAnnotations,
108726
110581
  runner: (input, nodeId) => nodeId === void 0 ? api.pipelineRunner.runStatelessStep.mutate(input) : api.pipelineRunner.runStatelessStep.mutate(input, require_clip_model_registry.nodePin(nodeId)),
108727
- logger: logger.child("Retrain")
110582
+ logger: logger.child("Retrain"),
110583
+ pipelineEvidence: {
110584
+ tracksAround: (deviceId, atMs) => trackStore.queryHistorical({
110585
+ deviceId,
110586
+ since: atMs - PIPELINE_TRACK_LOOKBACK_MS,
110587
+ until: atMs + PIPELINE_BOX_TOLERANCE_MS,
110588
+ limit: PIPELINE_TRACK_AROUND_LIMIT
110589
+ }),
110590
+ subjectBoxes: (trackId, deviceId) => mediaStore.listSubjectBoxesByOwner("track", trackId, deviceId)
110591
+ },
110592
+ memberships: retrainCollectionStore
110593
+ });
110594
+ this.retrain = retrain;
110595
+ const retrainStills = new RecordedStillService(buildRecordedStillPorts({
110596
+ api: this.ctx.api.recordingArchive,
110597
+ logger: logger.child("RetrainStills"),
110598
+ resolveFfmpeg: this.pinnedFfmpeg
110599
+ }));
110600
+ this.retrainCollections = new RetrainCollectionService({
110601
+ collections: retrainCollectionStore,
110602
+ frames: retrainFrames,
110603
+ annotations: retrainAnnotations,
110604
+ retrain,
110605
+ captureStill: (deviceId, epochMs) => retrainStills.recover(deviceId, epochMs, RETRAIN_STILL_MAX_WIDTH),
110606
+ logger: logger.child("RetrainCollections")
110607
+ });
110608
+ this.retrainLlmAssist = new RetrainLlmAssistService({
110609
+ frames: retrainFrames,
110610
+ llm: {
110611
+ listProfiles: async () => (await api.llm.listProfiles.query({})).map((p) => ({
110612
+ id: p.id,
110613
+ name: p.name,
110614
+ model: p.model,
110615
+ kind: p.kind,
110616
+ ...p.baseUrl !== void 0 ? { baseUrl: p.baseUrl } : {},
110617
+ enabled: p.enabled,
110618
+ supportsVision: p.supportsVision
110619
+ })),
110620
+ generateVision: async (request) => {
110621
+ const result = await api.llm.generateVision.mutate({
110622
+ profileId: request.profileId,
110623
+ consumer: RETRAIN_LLM_CONSUMER,
110624
+ system: request.system,
110625
+ prompt: request.prompt,
110626
+ jsonSchema: request.jsonSchema,
110627
+ images: [{
110628
+ bytes: new Uint8Array(request.image),
110629
+ mimeType: "image/jpeg"
110630
+ }],
110631
+ temperature: 0,
110632
+ timeoutMs: RETRAIN_LLM_TIMEOUT_MS
110633
+ });
110634
+ return result.ok ? {
110635
+ ok: true,
110636
+ text: result.text,
110637
+ model: result.model
110638
+ } : {
110639
+ ok: false,
110640
+ code: result.code,
110641
+ message: result.message
110642
+ };
110643
+ }
110644
+ },
110645
+ logger: logger.child("RetrainLlmAssist")
108728
110646
  });
108729
110647
  this.mediaRelocate = new MediaRelocateEngine({
108730
110648
  store: api.settingsStore,
@@ -109908,14 +111826,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109908
111826
  };
109909
111827
  return this.requireRetrain().propose({
109910
111828
  deviceId: input.deviceId,
109911
- trackId: input.trackId,
111829
+ ...input.trackId !== void 0 ? { trackId: input.trackId } : {},
109912
111830
  frameId: input.frameId,
109913
111831
  subject,
109914
111832
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
109915
111833
  });
109916
111834
  }
109917
111835
  async listRetrainAnnotations(input) {
109918
- return this.requireRetrain().listAnnotations(input.trackId);
111836
+ return this.requireRetrain().listAnnotations(input);
109919
111837
  }
109920
111838
  async saveRetrainAnnotations(input) {
109921
111839
  return this.requireRetrain().saveAnnotations(input);
@@ -109929,10 +111847,99 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109929
111847
  /** Where to stream the ANNOTATED dataset from. Bytes never cross the cap. */
109930
111848
  async getRetrainExportUrl(input) {
109931
111849
  if (this.retrainExportDataPlane === null) throw new Error("retrain export: data plane unavailable on this node");
109932
- const ids = input.deviceIds ?? [];
109933
- const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
111850
+ const { layout, ...filter } = input;
111851
+ const query = retrainExportQuery({
111852
+ filter,
111853
+ layout: layout ?? "frames"
111854
+ });
109934
111855
  return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
109935
111856
  }
111857
+ async setRetrainFrameComplete(input) {
111858
+ return this.requireRetrain().setFrameComplete(input);
111859
+ }
111860
+ requireRetrainLlmAssist() {
111861
+ const service = this.retrainLlmAssist;
111862
+ if (service === null) throw new Error("retrain AI assist: analytics stores unavailable");
111863
+ return service;
111864
+ }
111865
+ async listRetrainAssistProfiles(_input) {
111866
+ return this.requireRetrainLlmAssist().listProfiles();
111867
+ }
111868
+ async proposeRetrainAnnotationsWithLlm(input) {
111869
+ return this.requireRetrainLlmAssist().propose(input);
111870
+ }
111871
+ requireRetrainCollections() {
111872
+ const service = this.retrainCollections;
111873
+ if (service === null) throw new Error("retrain collections: analytics stores unavailable");
111874
+ return service;
111875
+ }
111876
+ async createRetrainCollection(input) {
111877
+ return this.requireRetrainCollections().create(input);
111878
+ }
111879
+ async listRetrainCollections(_input) {
111880
+ return this.requireRetrainCollections().list();
111881
+ }
111882
+ async renameRetrainCollection(input) {
111883
+ return this.requireRetrainCollections().rename(input);
111884
+ }
111885
+ async deleteRetrainCollection(input) {
111886
+ return this.requireRetrainCollections().delete(input);
111887
+ }
111888
+ async addRetrainFrameFromRecording(input) {
111889
+ return this.requireRetrainCollections().addFrameFromRecording(input);
111890
+ }
111891
+ async addRetrainFramesToCollection(input) {
111892
+ return this.requireRetrainCollections().addFrames(input);
111893
+ }
111894
+ async removeRetrainFrameFromCollection(input) {
111895
+ return this.requireRetrainCollections().removeFrame(input);
111896
+ }
111897
+ async listRetrainCollectionFrames(input) {
111898
+ return this.requireRetrainCollections().listFrames(input);
111899
+ }
111900
+ /**
111901
+ * Stream the annotated archive into ONE HTTP request to `target` — the
111902
+ * training upload. Same bytes as the download; never on the hub's disk.
111903
+ */
111904
+ async writeRetrainArchive(input) {
111905
+ const frameStore = this.retrainFrames;
111906
+ if (frameStore === null) throw new Error("retrain export: analytics stores unavailable");
111907
+ const plan = await this.buildRetrainExportPlan({
111908
+ filter: input.filter,
111909
+ layout: input.layout
111910
+ });
111911
+ const upload = openRetrainUploadSink(input.target);
111912
+ const host = new URL(input.target.url).host;
111913
+ try {
111914
+ const stats = await writeRetrainArchiveTo({
111915
+ plan,
111916
+ readFrame: (frame) => frameStore.readFrame(frame),
111917
+ cutCrop: this.retrainCropCutter(),
111918
+ sink: upload.sink,
111919
+ markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
111920
+ logger: this.ctx.logger,
111921
+ now: () => Date.now()
111922
+ });
111923
+ const status = await upload.finish();
111924
+ this.ctx.logger.info("retrain archive uploaded", { meta: {
111925
+ host,
111926
+ status,
111927
+ ...stats,
111928
+ layout: input.layout
111929
+ } });
111930
+ return {
111931
+ ...stats,
111932
+ status
111933
+ };
111934
+ } catch (err) {
111935
+ upload.abort(require_clip_model_registry.errMsg(err));
111936
+ this.ctx.logger.warn("retrain archive upload failed", { meta: {
111937
+ host,
111938
+ error: require_clip_model_registry.errMsg(err)
111939
+ } });
111940
+ throw err;
111941
+ }
111942
+ }
109936
111943
  /**
109937
111944
  * The store seam the `debug.*` actions and the debug bundle both read through.
109938
111945
  *
@@ -110214,15 +112221,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110214
112221
  async serveRetrainExportPlane() {
110215
112222
  try {
110216
112223
  const handler = createRetrainExportHandler({
110217
- buildPlan: (deviceIds) => this.buildRetrainExportPlan(deviceIds),
112224
+ buildPlan: (request) => this.buildRetrainExportPlan(request),
110218
112225
  readFrame: (frame) => this.retrainFrames?.readFrame(frame) ?? Promise.resolve(null),
110219
- markExported: async (frameIds, batchId) => {
110220
- const store = this.retrainAnnotations;
110221
- if (!store || frameIds.length === 0) return;
110222
- const ids = [];
110223
- for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
110224
- await store.markExported(ids, batchId);
110225
- },
112226
+ cutCrop: this.retrainCropCutter(),
112227
+ markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
110226
112228
  logger: this.ctx.logger,
110227
112229
  now: () => Date.now()
110228
112230
  });
@@ -110244,13 +112246,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110244
112246
  * reference rectangle is implicit is a box nobody can check. A camera with no
110245
112247
  * zones simply has no `zoneCrop` geometry — absent, never a clamped guess.
110246
112248
  */
110247
- async buildRetrainExportPlan(deviceIds) {
112249
+ async buildRetrainExportPlan(request) {
110248
112250
  const frameStore = this.retrainFrames;
110249
112251
  const annotationStore = this.retrainAnnotations;
110250
112252
  const trackStore = this.trackStore;
110251
112253
  if (!frameStore || !annotationStore || !trackStore) throw new Error("retrain export: analytics stores unavailable");
110252
- const devices = deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds();
110253
- const frames = [];
112254
+ const deviceIds = request.filter.deviceIds ?? [];
112255
+ const devices = deviceIds.length > 0 ? deviceIds : [...new Set([...await trackStore.listDeviceIds(), ...await frameStore.listDeviceIds()])];
112256
+ const candidates = /* @__PURE__ */ new Map();
110254
112257
  const annotationsByFrame = /* @__PURE__ */ new Map();
110255
112258
  const zones = /* @__PURE__ */ new Map();
110256
112259
  for (const deviceId of devices) {
@@ -110259,22 +112262,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110259
112262
  const existing = annotationsByFrame.get(annotation.mediaKey) ?? [];
110260
112263
  annotationsByFrame.set(annotation.mediaKey, [...existing, annotation]);
110261
112264
  }
112265
+ for (const frame of await frameStore.listCompleteByDevice(deviceId, RETRAIN_EXPORT_MAX)) candidates.set(frame.frameId, frame);
110262
112266
  }
110263
112267
  for (const frameId of annotationsByFrame.keys()) {
112268
+ if (candidates.has(frameId)) continue;
110264
112269
  const frame = await frameStore.get(frameId);
110265
- if (frame !== null) frames.push(frame);
112270
+ if (frame !== null) candidates.set(frameId, frame);
110266
112271
  }
112272
+ const collectionsByFrame = await this.retrainCollectionStore?.collectionsOf([...candidates.keys()]) ?? /* @__PURE__ */ new Map();
112273
+ const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter, collectionsByFrame);
110267
112274
  return {
110268
112275
  manifest: buildRetrainManifest({
110269
112276
  frames,
110270
112277
  annotationsByFrame,
112278
+ collectionsByFrame,
110271
112279
  zoneForDevice: (deviceId) => zones.get(deviceId) ?? null,
110272
112280
  generatedAt: Date.now(),
110273
- batchId: (0, node_crypto.randomUUID)()
112281
+ batchId: (0, node_crypto.randomUUID)(),
112282
+ layout: request.layout,
112283
+ coordinates: await this.siteCoordinates()
110274
112284
  }),
110275
112285
  frames
110276
112286
  };
110277
112287
  }
112288
+ /** Stamp the batch on the annotations of the frames an archive carried. */
112289
+ async markRetrainExported(frameIds, batchId) {
112290
+ const store = this.retrainAnnotations;
112291
+ if (!store || frameIds.length === 0) return;
112292
+ const ids = [];
112293
+ for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
112294
+ await store.markExported(ids, batchId);
112295
+ }
112296
+ /** The `crops` layout's cutter, with the convention the manifest records. */
112297
+ retrainCropCutter() {
112298
+ return (frameBytes, frame, annotation) => cutRetrainCrop({
112299
+ frameBytes,
112300
+ frameWidth: frame.width,
112301
+ frameHeight: frame.height,
112302
+ bbox: annotation.bbox,
112303
+ convention: RETRAIN_CROP_CONVENTION,
112304
+ maxSide: RETRAIN_CROP_MAX_SIDE
112305
+ }).catch((err) => {
112306
+ this.ctx.logger.warn("retrain crop could not be cut", {
112307
+ tags: { deviceId: frame.deviceId },
112308
+ meta: {
112309
+ frameId: frame.frameId,
112310
+ annotationId: annotation.id,
112311
+ error: require_clip_model_registry.errMsg(err)
112312
+ }
112313
+ });
112314
+ return null;
112315
+ });
112316
+ }
110278
112317
  /** Bounding box of a camera's zones, or null when it declares none. */
110279
112318
  async retrainZoneForDevice(deviceId) {
110280
112319
  try {
@@ -110422,6 +112461,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
110422
112461
  }, LTS_FLUSH_INTERVAL_MS);
110423
112462
  this.startOrphanAuditTimer();
110424
112463
  this.reconcileGalleryMediaLocation();
112464
+ this.reconcileRetrainCopiesLocation();
112465
+ }
112466
+ /**
112467
+ * Move pre-D781 retrain copies off the footage class onto the durable
112468
+ * `retrainMedia` tenant, and say how many have no bytes left.
112469
+ */
112470
+ async reconcileRetrainCopiesLocation() {
112471
+ const retrainFrames = this.retrainFrames;
112472
+ if (!retrainFrames) return;
112473
+ try {
112474
+ const result = await retrainFrames.relocateLegacyCopies();
112475
+ if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
112476
+ this.ctx.logger.info("retrain copies reconciled onto the durable retrain tenant", { meta: {
112477
+ ...result,
112478
+ location: RETRAIN_MEDIA_LOCATION
112479
+ } });
112480
+ } catch (err) {
112481
+ this.ctx.logger.warn("retrain copies reconcile failed — the copies stay on eventMedia", { meta: {
112482
+ error: require_clip_model_registry.errMsg(err),
112483
+ location: ARTIFACTS_MEDIA_LOCATION
112484
+ } });
112485
+ }
110425
112486
  }
110426
112487
  /**
110427
112488
  * Move enrolled-gallery blobs off the footage volume, and say out loud how