@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
@@ -1,4 +1,4 @@
1
- import { $ as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, $n as encodeVectorBase64, $r as systemEventDeliveryProblems, $t as RECORDING_SOURCE_CAMSTACK_ADDON, A as EMBEDDING_MODEL_FAILED, Ai as literal, An as applyModeDelaysEdits, Ar as plateGalleryCapability, At as NcLegacyRuleActionSequenceSchema, B as LabelAttributionSchema, Bi as union, Bn as coreBlockIdFromAddonId, Br as rootSupplyServes, Bt as NcSnoozeInputSchema, C as CoverStateSchema, Cn as TrackSideSchema, Cr as linkedStepOf, Ct as NcAlarmConfigSchema, D as DETECTION_PIPELINE_CAP_NAME, Di as array, Dn as alarmDeviceSecureOnArm, Dr as occupancyScope, Dt as NcAlarmSettingsPatchSchema, E as DETECTION_MACRO_CLASSES, Ei as _enum, En as addonWidgetsSourceCapability, Er as notificationRulesCapability, Et as NcAlarmSensorsSchema, F as EVENT_OWNER_TYPES, Fi as record, Fn as audioModeOf, Fr as resolveClipSearchMinScore, Ft as NcRuleSchema, G as MAX_BIRTH_DECISION_RECORDS, Gn as deriveRecordingMode, Gr as ruleKindOf, Gt as NcTaxonomySchema, H as ListTrackRelationsResultSchema, Hi as EventCategory, Hn as createAlarmProfile, Hr as ruleBelongsToAlarm$1, Ht as NcSnoozeSuppressedSchema, I as EXPORT_DENSE_MAX_RANGES, Ii as strictObject, In as buildEventKindDescriptor, Ir as resolveLocationMode, It as NcRuleTargetSchema, J as MAX_TRACK_RELATIONS, Jr as sensorRaisedOf, Jt as NcTemplateVarDescriptorSchema, K as MAX_BIRTH_LATENCY_PROXY_MS, Kn as describeSystemEventDeliveryProblem, Kr as sceneMonitorCapability, Kt as NcTemplatePreviewInputSchema, L as FIRST_LEVEL_MACRO_CLASSES, Li as string, Ln as buildViewerLink, Lr as resolveModeDelays, Lt as NcRuleUpdateInputSchema, M as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Mi as number, Mn as applySecureOnArmEdit, Mr as readMotionTriggeredBy, Mt as NcRuleActionsSchema, N as EVENT_DENSITY_BATCH_MAX, Ni as object, Nn as assertTimelapseCadences, Nr as readTimelapseGeneratedAt, Nt as NcRuleInputSchema, O as DeclaredDevices, Oi as boolean, On as alarmPanelCapability, Ot as NcConditionDescriptorSchema, P as EVENT_KIND_BY_CAP, Pi as partialRecord, Pn as audioMetricsCapability, Pr as renameAlarmProfile, Pt as NcRulePatchSchema, Q as NC_ALARM_DEFAULT_PROFILE_ID, Qr as syncActiveAlarmProfileSettings, Qt as RECORDING_EXPORT_MAX_READ_BYTES, R as FULL_IMAGE_BBOX, Ri as templateLiteral, Rn as cloneAlarmSettings, Rr as resolveModeNotifyPolicy, Rt as NcScheduleSchema, S as ClearTrackRelationResultSchema, Si as nodePin, Sn as TrackRelationSchema, Sr as kebabToCamel, St as NC_TEMPLATE_VARS, T as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Ti as ZodObject, Tn as activateAlarmProfile, Tr as memoizePinnedFfmpeg, Tt as NcAlarmSensorsInputSchema, U as LockStateSchema, Un as deleteAlarmProfile, Ur as ruleDeviceCompatCapQueries, Ut as NcStateSummarySectionIdSchema, V as ListTrackRelationsInputSchema, Vi as unknown, Vn as cosineSimilarity$1, Vr as ruleAutoTriggersAlarm, Vt as NcSnoozeSchema, W as MAX_ARCHIVED_DEBUG_NOTES, Wn as derivePostTriggeredAction, Wr as ruleGroupingApplies, Wt as NcSystemEventKindSchema, X as MediaFileKindEnum, Xn as embeddingRefusalOf, Xr as storageOccupancyCapability, Xt as OpsLogEntrySchema, Y as MOTION_CLOSE_AFTER_MS, Yr as servedTemplateVars, Yt as OPS_LOG_DEFAULT_LIMIT, Z as MotionTriggeredBySchema, Zr as subKindsOf, _ as BIRTH_DECISION_DEFAULT_LIMIT, _i as createEvent, _n as TimelapseRuleInputSchema, _r as isRootSuppliableStepId, _t as NC_SENSOR_TRANSITION_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as templateVarsFor, an as SCENE_DEFAULT_CHECK_INTERVAL_SEC, ar as gifDeriveSpeed, at as NC_AUDIO_CONFIRM_HITS_DEFAULT, b as COCO_TO_MACRO, bi as hydrateSchema, bn as TrackPuritySchema, br as isSystemEventSummaryRule, bt as NC_TANK_SYSTEM_EVENT_KINDS, c as resolveClusterModelPin, ci as zoneAnalyticsCapability, cn as SCENE_DEFAULT_UNCOVERED_POLICY, cr as isAppLinkBase, ct as NC_CLIP_PRE_ROLL_DEFAULT_SEC, d as APPEARANCE_BLOCKS, di as evaluateSensorEdge, dn as SceneMonitorSchema, dr as isDetectionMacroClass, dt as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, ei as systemEventFamilyProblems, en as RebuildObjectEmbeddingsRefusalSchema, er as ensureAlarmProfiles, et as NC_ALARM_DELAY_FIELDS, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fi as isSourceCap, fn as SetTrackPurityInputSchema, fr as isEventKindLabelKey, ft as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, g as ArchivedDebugNoteSchema, gi as CamProfileSchema, gn as TRACK_RELATIONS_DEFAULT_LIMIT, gr as isRetiredClearKey, gt as NC_GROUPING_MIN_SEC, h as AlarmArmModeSchema, hi as BaseAddon, hn as TIMELAPSE_DENSE_FLOOR_SEC, hr as isMediaOwnerType, ht as NC_GROUPING_MAX_SEC, ii as templateCarriesSummary, in as SCENE_DEFAULT_ANCHOR_THRESHOLD, ir as failureContributionCapability, it as NC_ALARM_TRIGGERED_MAX_SEC, j as EMBEDDING_REQUIREMENTS_FAILED, ji as looseObject, jn as applyNonBlockingEdit, jr as readDeviceStateFrom, jt as NcRuleActionSchema, k as DetectionSourceSchema, ki as discriminatedUnion, kn as applyBlockingEdit, kr as pipelineAnalyticsCapability, kt as NcConditionsSchema, l as ALL_CAPABILITY_DEFINITIONS, li as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, ln as SCENE_DIVERGED, lr as isAudioClassName, lt as NC_CLIP_RETENTION_MAX_SEC, m as AUDIO_MACRO_LABELS, mi as errMsg, mn as SetTrackRelationInputSchema, mr as isKnownTemplateVar, mt as NC_GROUPING_DEFAULT_SEC, ni as systemEventSummaryScheduleOf, nn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, nr as evictionPolicyOfLocation, nt as NC_ALARM_NOTIFY_FIELDS, o as FACE_EMBEDDING_STEP_ID, oi as vectorDimFromBase64, on as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, or as gifFromMp4Args, ot as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pi as DeviceType, pn as SetTrackPurityResultSchema, pr as isEventOwnerType, pt as NC_DEFAULT_SNOOZE_MINUTES, q as MAX_TRACK_PURITIES, qn as describeSystemEventFamilyProblem, qr as secureActuationFor, qt as NcTemplatePreviewSchema, ri as systemEventSummarySplitOf, rn as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, rr as faceGalleryCapability, rt as NC_ALARM_SYSTEM_EVENT_KINDS, s as readClusterModelPinStrict, si as viewerLinkBaseOf, sn as SCENE_DEFAULT_QUIET_SECONDS, sr as isAlarmControllableCap, st as NC_CLIP_POST_ROLL_DEFAULT_SEC, t as BUILTIN_CLIP_MODELS, ti as systemEventFilterApplies, tn as RetrainStatusSchema, tr as eventKindLabel, tt as NC_ALARM_DELAY_MAX_SEC, u as APPEARANCE_BIN_COUNT, ui as canonicalHash, un as SCENE_RESET_RECAPTURES, ur as isDefaultAlarmProfileId, ut as NC_CONDITION_CATALOG, v as BaseDevice, vi as customAction, vn as TimelapseRulePatchSchema, vr as isScheduleActive, vt as NC_SNOOZE_MAX_MINUTES, w as DEFAULT_EVENT_COLOR, wi as sleep$1, wn as TrackSourceSchema, wr as mayWriteToLocation, wt as NcAlarmPostTriggeredActionSchema, x as ClearTrackRelationInputSchema, xi as isDeviceScopedCap, xn as TrackRelationKindSchema, xr as isVectorIndexDimConflict, xt as NC_TAXONOMY, y as BirthDecisionRecordSchema, yi as defineCustomActions, yn as TimelapseRuleSchema, yr as isSystemDelivery, yt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, z as FailureCounters, zi as tuple, zn as consumableLowPercentOf, zt as NcSharedSequenceSchema } from "../clip-model-registry-Dhjpee-Q.mjs";
1
+ import { $ as NC_ALARM_DEFAULT_PROFILE_ID, $r as subKindsOf, $t as RECORDING_EXPORT_MAX_READ_BYTES, A as DetectionSourceSchema, Ai as boolean, An as applyBlockingEdit, At as NcConditionsSchema, B as FailureCounters, Bi as templateLiteral, Bn as consumableLowPercentOf, Br as resolveModeNotifyPolicy, Bt as NcSharedSequenceSchema, C as CoverStateSchema, Ci as isDeviceScopedCap, Cn as TrackRelationSchema, Cr as isVectorIndexDimConflict, Ct as NC_TEMPLATE_VARS, D as DETECTION_MACRO_CLASSES, Di as ZodObject, Dn as addonWidgetsSourceCapability, Dr as memoizePinnedFfmpeg, Dt as NcAlarmSensorsSchema, E as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Ei as sleep$1, En as activateAlarmProfile, Er as mayWriteToLocation, Et as NcAlarmSensorsInputSchema, F as EVENT_KIND_BY_CAP, Fi as object, Fn as audioMetricsCapability, Fr as readTimelapseGeneratedAt, Ft as NcRulePatchSchema, G as MAX_ARCHIVED_DEBUG_NOTES, Gn as deriveDetailCropRect, Gr as ruleDeviceCompatCapQueries, Gt as NcSystemEventKindSchema, H as ListTrackRelationsInputSchema, Hi as union, Hn as cosineSimilarity$1, Hr as rootSupplyServes, Ht as NcSnoozeSchema, I as EVENT_OWNER_TYPES, Ii as partialRecord, In as audioModeOf, Ir as renameAlarmProfile, It as NcRuleSchema, J as MAX_TRACK_PURITIES, Jn as describeSystemEventDeliveryProblem, Jr as sceneMonitorCapability, Jt as NcTemplatePreviewSchema, K as MAX_BIRTH_DECISION_RECORDS, Kn as derivePostTriggeredAction, Kr as ruleGroupingApplies, Kt as NcTaxonomySchema, L as EXPORT_DENSE_MAX_RANGES, Li as record, Ln as buildEventKindDescriptor, Lr as resolveClipSearchMinScore, Lt as NcRuleTargetSchema, M as EMBEDDING_REQUIREMENTS_FAILED, Mi as literal, Mn as applyNonBlockingEdit, Mr as plateGalleryCapability, Mt as NcRuleActionSchema, N as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Ni as looseObject, Nn as applySecureOnArmEdit, Nr as readDeviceStateFrom, Nt as NcRuleActionsSchema, O as DETECTION_PIPELINE_CAP_NAME, Oi as _enum, On as alarmDeviceSecureOnArm, Or as notificationRulesCapability, Ot as NcAlarmSettingsPatchSchema, P as EVENT_DENSITY_BATCH_MAX, Pi as number, Pn as assertTimelapseCadences, Pr as readMotionTriggeredBy, Pt as NcRuleInputSchema, Q as MotionTriggeredBySchema, Qn as embeddingRefusalOf, Qr as storageOccupancyCapability, R as FIRST_LEVEL_MACRO_CLASSES, Ri as strictObject, Rn as buildViewerLink, Rr as resolveLocationMode, Rt as NcRuleUpdateInputSchema, S as ClearTrackRelationResultSchema, Si as hydrateSchema, Sn as TrackRelationKindSchema, Sr as isSystemEventSummaryRule, St as NC_TAXONOMY, T as DEFAULT_EVENT_COLOR, Tn as TrackSourceSchema, Tr as linkedStepOf, Tt as NcAlarmPostTriggeredActionSchema, U as ListTrackRelationsResultSchema, Ui as unknown, Un as createAlarmProfile, Ur as ruleAutoTriggersAlarm, Ut as NcSnoozeSuppressedSchema, V as LabelAttributionSchema, Vi as tuple, Vn as coreBlockIdFromAddonId, Vt as NcSnoozeInputSchema, W as LockStateSchema, Wi as EventCategory, Wn as deleteAlarmProfile, Wr as ruleBelongsToAlarm$1, Wt as NcStateSummarySectionIdSchema, X as MOTION_CLOSE_AFTER_MS, Xr as sensorRaisedOf, Xt as OPS_LOG_DEFAULT_LIMIT, Y as MAX_TRACK_RELATIONS, Yn as describeSystemEventFamilyProblem, Yr as secureActuationFor, Yt as NcTemplateVarDescriptorSchema, Z as MediaFileKindEnum, Zr as servedTemplateVars, Zt as OpsLogEntrySchema, _ as BIRTH_DECISION_DEFAULT_LIMIT, _i as BaseAddon, _n as TRACK_RELATIONS_DEFAULT_LIMIT, _r as isMediaOwnerType, _t as NC_GROUPING_MIN_SEC, a as CLIP_EMBEDDING_STEP_ID, ai as systemEventSummarySplitOf, an as SCENE_DEFAULT_ANCHOR_THRESHOLD, ar as faceGalleryCapability, at as NC_ALARM_TRIGGERED_MAX_SEC, b as COCO_TO_MACRO, bi as customAction, bn as TimelapseRuleSchema, br as isScheduleActive, bt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, c as resolveClusterModelPin, ci as vectorDimFromBase64, cn as SCENE_DEFAULT_QUIET_SECONDS, cr as gifFromMp4Args, ct as NC_CLIP_POST_ROLL_DEFAULT_SEC, d as APPEARANCE_BLOCKS, di as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, dn as SCENE_RESET_RECAPTURES, dr as isAudioClassName, dt as NC_CONDITION_CATALOG, ei as syncActiveAlarmProfileSettings, en as RECORDING_SOURCE_CAMSTACK_ADDON, et as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fi as canonicalHash, fn as SceneMonitorSchema, fr as isDefaultAlarmProfileId, ft as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, g as ArchivedDebugNoteSchema, gi as errMsg, gn as TIMELAPSE_DENSE_FLOOR_SEC, gr as isKnownTemplateVar, gt as NC_GROUPING_MAX_SEC, h as AlarmArmModeSchema, hi as DeviceType, hn as SetTrackRelationInputSchema, hr as isEventOwnerType, ht as NC_GROUPING_DEFAULT_SEC, ii as systemEventSummaryScheduleOf, in as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, ir as evictionPolicyOfLocation, it as NC_ALARM_SYSTEM_EVENT_KINDS, j as EMBEDDING_MODEL_FAILED, ji as discriminatedUnion, jn as applyModeDelaysEdits, jr as pipelineAnalyticsCapability, jt as NcLegacyRuleActionSequenceSchema, k as DeclaredDevices, ki as array, kn as alarmPanelCapability, kr as occupancyScope, kt as NcConditionDescriptorSchema, l as ALL_CAPABILITY_DEFINITIONS, li as viewerLinkBaseOf, ln as SCENE_DEFAULT_UNCOVERED_POLICY, lr as isAlarmControllableCap, lt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, m as AUDIO_MACRO_LABELS, mi as isSourceCap, mn as SetTrackPurityResultSchema, mr as isEventKindLabelKey, mt as NC_DEFAULT_SNOOZE_MINUTES, ni as systemEventFamilyProblems, nn as RetrainStatusSchema, nr as ensureAlarmProfiles, nt as NC_ALARM_DELAY_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, oi as templateCarriesSummary, on as SCENE_DEFAULT_CHECK_INTERVAL_SEC, or as failureContributionCapability, ot as NC_AUDIO_CONFIRM_HITS_DEFAULT, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pi as evaluateSensorEdge, pn as SetTrackPurityInputSchema, pr as isDetectionMacroClass, pt as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, q as MAX_BIRTH_LATENCY_PROXY_MS, qn as deriveRecordingMode, qr as ruleKindOf, qt as NcTemplatePreviewInputSchema, ri as systemEventFilterApplies, rn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, rr as eventKindLabel, rt as NC_ALARM_NOTIFY_FIELDS, s as readClusterModelPinStrict, si as templateVarsFor, sn as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, sr as gifDeriveSpeed, st as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, t as BUILTIN_CLIP_MODELS, ti as systemEventDeliveryProblems, tn as RebuildObjectEmbeddingsRefusalSchema, tr as encodeVectorBase64, tt as NC_ALARM_DELAY_FIELDS, u as APPEARANCE_BIN_COUNT, ui as zoneAnalyticsCapability, un as SCENE_DIVERGED, ur as isAppLinkBase, ut as NC_CLIP_RETENTION_MAX_SEC, v as BaseDevice, vi as CamProfileSchema, vn as TimelapseRuleInputSchema, vr as isRetiredClearKey, vt as NC_SENSOR_TRANSITION_KINDS, w as DEFAULT_DETAIL_CROP_CONVENTION, wi as nodePin, wn as TrackSideSchema, wr as kebabToCamel, wt as NcAlarmConfigSchema, x as ClearTrackRelationInputSchema, xi as defineCustomActions, xn as TrackPuritySchema, xr as isSystemDelivery, xt as NC_TANK_SYSTEM_EVENT_KINDS, y as BirthDecisionRecordSchema, yi as createEvent, yn as TimelapseRulePatchSchema, yr as isRootSuppliableStepId, yt as NC_SNOOZE_MAX_MINUTES, z as FULL_IMAGE_BBOX, zi as string, zn as cloneAlarmSettings, zr as resolveModeDelays, zt as NcScheduleSchema } from "../clip-model-registry-C44gfwjm.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -16,6 +16,8 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
16
16
  import { isDeepStrictEqual, promisify } from "node:util";
17
17
  import { Buffer as Buffer$1 } from "node:buffer";
18
18
  import { createGzip, gunzip, gunzipSync, gzip, gzipSync } from "node:zlib";
19
+ import { request } from "node:http";
20
+ import { request as request$1 } from "node:https";
19
21
  //#region src/pipeline-analytics/media-occupancy.ts
20
22
  /** The bare type ref legacy NULL-stamped media rows belong to. */
21
23
  var LEGACY_MEDIA_TYPE = "eventMedia";
@@ -5829,16 +5831,16 @@ function ncDetectionSummary(texts, counts) {
5829
5831
  //#region src/notification-center/timelapse/detection-counts.ts
5830
5832
  /** One normalisation, used on both sides of every comparison — a rule that
5831
5833
  * named `Person` must match a track classified `person`. */
5832
- function normalise(name) {
5834
+ function normalise$1(name) {
5833
5835
  return name.trim().toLowerCase();
5834
5836
  }
5835
5837
  /** Count per class over the tracks, honouring the rule's selection. */
5836
5838
  function countDetections(input) {
5837
- const selected = (input.reportClasses ?? []).map(normalise).filter((c) => c.length > 0);
5839
+ const selected = (input.reportClasses ?? []).map(normalise$1).filter((c) => c.length > 0);
5838
5840
  const seen = /* @__PURE__ */ new Map();
5839
5841
  for (const t of input.tracks) {
5840
5842
  if (t.className === void 0) continue;
5841
- const key = normalise(t.className);
5843
+ const key = normalise$1(t.className);
5842
5844
  if (key.length === 0) continue;
5843
5845
  if (selected.length > 0 && !selected.includes(key)) continue;
5844
5846
  seen.set(key, (seen.get(key) ?? 0) + 1);
@@ -7020,18 +7022,44 @@ function clockCondition(now) {
7020
7022
  * does not.
7021
7023
  */
7022
7024
  function resolveCondition(input) {
7023
- if (input.forcedMode === "day" || input.forcedMode === "night") return input.forcedMode;
7024
- if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return IR;
7025
+ return resolveConditionDetailed(input).condition;
7026
+ }
7027
+ /**
7028
+ * {@link resolveCondition}, plus the layer that answered. A consumer that
7029
+ * reports a day/night split (the retrain export, D785) must be able to tell a
7030
+ * measured IR frame from an almanac or a clock guess.
7031
+ */
7032
+ function resolveConditionDetailed(input) {
7033
+ if (input.forcedMode === "day" || input.forcedMode === "night") return {
7034
+ condition: input.forcedMode,
7035
+ source: "forced"
7036
+ };
7037
+ if (input.chromaSpread !== void 0 && input.chromaSpread < IR_CHROMA_THRESHOLD) return {
7038
+ condition: IR,
7039
+ source: "ir-chroma"
7040
+ };
7025
7041
  if (input.coordinates) {
7026
7042
  const times = sunTimes(input.coordinates.lat, input.coordinates.lng, input.now);
7027
7043
  if (times) {
7028
7044
  const t = input.now.getTime();
7029
- if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return DAWN;
7030
- if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return DUSK;
7031
- return t >= times.rise && t < times.set ? DAY : NIGHT;
7045
+ if (Math.abs(t - times.rise) <= TWILIGHT_BAND_MS) return {
7046
+ condition: DAWN,
7047
+ source: "sun"
7048
+ };
7049
+ if (Math.abs(t - times.set) <= TWILIGHT_BAND_MS) return {
7050
+ condition: DUSK,
7051
+ source: "sun"
7052
+ };
7053
+ return {
7054
+ condition: t >= times.rise && t < times.set ? DAY : NIGHT,
7055
+ source: "sun"
7056
+ };
7032
7057
  }
7033
7058
  }
7034
- return clockCondition(input.now);
7059
+ return {
7060
+ condition: clockCondition(input.now),
7061
+ source: "clock"
7062
+ };
7035
7063
  }
7036
7064
  //#endregion
7037
7065
  //#region src/shared/llm-vision/parse-model-json.ts
@@ -70516,8 +70544,11 @@ var TrackStore = class {
70516
70544
  * RetrainFrameStore — the COPIES the retrain dataset owns.
70517
70545
  *
70518
70546
  * Collection: `pipeline-analytics:retrain-frames`.
70519
- * Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg`, on the default media
70520
- * location, under a path prefix NO owner cascade and NO age sweep walks.
70547
+ * Blobs: `retrain/<deviceId>/<trackId>/<hash>.jpg` under the `artifacts`
70548
+ * class root — the dataset is that class's fourth tenant (D341), addressed
70549
+ * through the permanent row ref {@link RETRAIN_MEDIA_LOCATION}. Never the
70550
+ * footage volume: the 2026-08-17 recordings wipe took the whole dataset with
70551
+ * it while it lived on `eventMedia` (D197). See D781.
70521
70552
  *
70522
70553
  * ## Why a copy, and why at selection time
70523
70554
  *
@@ -70544,20 +70575,43 @@ var TrackStore = class {
70544
70575
  */
70545
70576
  /**
70546
70577
  * @durable class=ledger owner=pipeline-analytics
70547
- * write="one row per frame the operator SELECTS into the retrain dataset. The
70548
- * copy is taken eagerly at selection (copyFrame), not referenced, so the row
70549
- * records a blob the dataset owns outright; re-selecting the same source reuses
70550
- * the existing copy rather than writing a second one."
70551
- * retention="none — the blobs sit under a 'retrain' prefix no owner cascade and no
70552
- * age sweep walks, which is exactly what makes a 'trained' track evictable
70578
+ * write="one row per frame the operator SELECTS into the retrain dataset, or adds
70579
+ * from the recording at an instant (storeRecordingStill, origin 'recording', no
70580
+ * track). The copy is taken eagerly at selection (copyFrame), not referenced, so
70581
+ * the row records a blob the dataset owns outright; re-selecting the same source
70582
+ * (or the same camera instant) reuses the existing copy rather than writing a
70583
+ * second one."
70584
+ * retention="none — the blobs sit in the 'retrain/' subtree of the artifacts class
70585
+ * (D781), which no owner cascade and no age sweep walks, which is exactly what makes a 'trained' track evictable
70553
70586
  * again (D81). Rows go only when the operator deselects the frame (deleteFrame,
70554
70587
  * after its annotations). Bounded only by operator selection — nothing caps the
70555
70588
  * table or the disk it consumes."
70556
70589
  */
70557
70590
  var RETRAIN_FRAMES_COLLECTION = "pipeline-analytics:retrain-frames";
70558
- /** Storage location for retrain copies — the same default media root the event
70559
- * media lives on, under a prefix nothing else enumerates. */
70560
- var RETRAIN_MEDIA_LOCATION = "eventMedia";
70591
+ /**
70592
+ * The PERMANENT location ref every retrain copy is written under (D781).
70593
+ *
70594
+ * Not a declared storage class: it is the rows' `locationId`, which the
70595
+ * media-storage wiring maps onto the `artifacts` class root — exactly how the
70596
+ * enrolled gallery's `galleryMedia` ref works (D341). The copies are curation
70597
+ * work with NO retention, so they share the one durable class rather than the
70598
+ * footage class, which is quota-evicted, drained and wipeable per disk. Rows
70599
+ * are never restamped for a root move: the ref is remapped instead, and the
70600
+ * singleton repoint carries the whole `artifacts` tree, `retrain/` included.
70601
+ */
70602
+ var RETRAIN_MEDIA_LOCATION = "retrainMedia";
70603
+ /**
70604
+ * Where copies taken before D781 live: a NULL `locationId` meant "wherever
70605
+ * `eventMedia` points", and a concrete `eventMedia:*` id was stamped by the
70606
+ * seal or a relocate pass. The boot reconcile
70607
+ * ({@link RetrainFrameStore.relocateLegacyCopies}) moves them onto
70608
+ * {@link RETRAIN_MEDIA_LOCATION}; until it has, reads resolve them here.
70609
+ */
70610
+ var LEGACY_RETRAIN_MEDIA_LOCATION = "eventMedia";
70611
+ /** Is this row already on the durable retrain tenant? */
70612
+ function rowIsDurableRetrainCopy(data) {
70613
+ return data?.["locationId"] === RETRAIN_MEDIA_LOCATION;
70614
+ }
70561
70615
  /** Path prefix of every retrain copy. Never `<deviceId>/events/…`: the orphan
70562
70616
  * audit resolves ownership from INDEX rows, but a future filesystem-side tool
70563
70617
  * must be able to tell the two corpora apart by path alone. */
@@ -70615,11 +70669,45 @@ var RETRAIN_FRAME_COLUMNS = [
70615
70669
  notNull: true
70616
70670
  },
70617
70671
  (
70618
- /** NULL = current eventMedia default; stamped when migration moves a copy
70619
- * before the logical default is repointed. */
70672
+ /** `retrainMedia` for every copy taken since D781. NULL (the eventMedia
70673
+ * default) or a concrete `eventMedia:*` id only on a pre-D781 copy the boot
70674
+ * reconcile has not moved yet. */
70620
70675
  {
70621
70676
  name: "locationId",
70622
70677
  type: "TEXT"
70678
+ }),
70679
+ (
70680
+ /** When the CAMERA captured the frame — the source media row's timestamp.
70681
+ * NULL on a copy taken before D785. */
70682
+ {
70683
+ name: "capturedAt",
70684
+ type: "INTEGER"
70685
+ }),
70686
+ (
70687
+ /** Mean channel spread of the copy (`chromaSpread`), measured once at copy
70688
+ * time; below `IR_CHROMA_THRESHOLD` the frame is IR. NULL = not measured. */
70689
+ {
70690
+ name: "chromaSpread",
70691
+ type: "REAL"
70692
+ }),
70693
+ (
70694
+ /** The operator's "every subject in this frame is boxed". NULL = no. */
70695
+ {
70696
+ name: "complete",
70697
+ type: "BOOLEAN"
70698
+ }),
70699
+ (
70700
+ /** `recording` for a still taken from the recording at an instant (no
70701
+ * track, no source media row). NULL = a track copy. */
70702
+ {
70703
+ name: "origin",
70704
+ type: "TEXT"
70705
+ }),
70706
+ (
70707
+ /** The recording instant a `recording` still was asked for. */
70708
+ {
70709
+ name: "epochMs",
70710
+ type: "INTEGER"
70623
70711
  })
70624
70712
  ];
70625
70713
  var RETRAIN_FRAME_INDEXES = [
@@ -70645,6 +70733,8 @@ var RETRAIN_FRAME_INDEXES = [
70645
70733
  columns: ["locationId"]
70646
70734
  })
70647
70735
  ];
70736
+ /** Ids per `IN (…)` page in {@link RetrainFrameStore.getMany}. */
70737
+ var GET_MANY_PAGE = 500;
70648
70738
  /**
70649
70739
  * The copy's id, derived from `(trackId, sourceMediaId)`.
70650
70740
  *
@@ -70657,8 +70747,24 @@ var RETRAIN_FRAME_INDEXES = [
70657
70747
  function retrainFrameId(trackId, sourceMediaId) {
70658
70748
  return `retrain:${trackId}:${createHash("sha1").update(String(sourceMediaId)).digest("hex").slice(0, 16)}`;
70659
70749
  }
70660
- function framePath(deviceId, trackId, frameId) {
70661
- return `${RETRAIN_PATH_PREFIX}/${deviceId}/${trackId}/${frameId.slice(frameId.lastIndexOf(":") + 1)}.jpg`;
70750
+ /**
70751
+ * A recording still's id, derived from `(deviceId, epochMs)` — the same moment
70752
+ * of the same camera is one copy, however many times it is added.
70753
+ */
70754
+ function recordingFrameId(deviceId, epochMs) {
70755
+ const digest = createHash("sha1").update(String(epochMs)).digest("hex").slice(0, 16);
70756
+ return `retrain:rec:${String(deviceId)}:${digest}`;
70757
+ }
70758
+ /** The directory a recording still sits in, beside the track directories. */
70759
+ var RECORDING_STILL_DIR = "recording";
70760
+ function framePath(frame) {
70761
+ const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
70762
+ const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
70763
+ return `${RETRAIN_PATH_PREFIX}/${frame.deviceId}/${dir}/${leaf}.jpg`;
70764
+ }
70765
+ /** A stored number, or `undefined` for NULL / absent / junk. */
70766
+ function finiteNumber(value) {
70767
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
70662
70768
  }
70663
70769
  var RetrainFrameStore = class {
70664
70770
  store;
@@ -70667,7 +70773,7 @@ var RetrainFrameStore = class {
70667
70773
  readSource;
70668
70774
  now;
70669
70775
  writeGate;
70670
- resolveEventMediaLocation;
70776
+ measureChroma;
70671
70777
  constructor(deps) {
70672
70778
  this.store = deps.store;
70673
70779
  this.storage = deps.storage;
@@ -70675,28 +70781,20 @@ var RetrainFrameStore = class {
70675
70781
  this.readSource = deps.readSource;
70676
70782
  this.now = deps.now;
70677
70783
  this.writeGate = deps.writeGate;
70678
- this.resolveEventMediaLocation = deps.resolveEventMediaLocation;
70784
+ this.measureChroma = deps.measureChroma;
70679
70785
  }
70680
- /**
70681
- * The location a NEW copy is written to, and the id its row NAMES.
70682
- *
70683
- * `null` = "no concrete location was resolved", which persists as the legacy
70684
- * NULL row. A resolver that throws must not refuse the copy — losing an
70685
- * operator's selection is worse than an unstamped row the seal can fix — but
70686
- * it says so, because that branch re-creates the exact population a
70687
- * non-blocking migration is gated on.
70688
- */
70689
- async resolveWriteLocation(deviceId) {
70690
- if (!this.resolveEventMediaLocation) return null;
70786
+ /** The copy's chroma, or `undefined` when it cannot be measured. */
70787
+ async chromaOf(bytes, deviceId) {
70788
+ if (!this.measureChroma) return void 0;
70691
70789
  try {
70692
- const resolved = await this.resolveEventMediaLocation(deviceId);
70693
- return resolved === RETRAIN_MEDIA_LOCATION ? null : resolved;
70790
+ const value = await this.measureChroma(bytes);
70791
+ return Number.isFinite(value) ? value : void 0;
70694
70792
  } catch (err) {
70695
- 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", {
70793
+ this.logger.warn("retrain copy chroma could not be measured — its day/night falls back to the sun", {
70696
70794
  tags: { deviceId },
70697
70795
  meta: { error: String(err) }
70698
70796
  });
70699
- return null;
70797
+ return;
70700
70798
  }
70701
70799
  }
70702
70800
  static async declare(store) {
@@ -70707,9 +70805,11 @@ var RetrainFrameStore = class {
70707
70805
  });
70708
70806
  }
70709
70807
  rowToFrame(id, data) {
70808
+ const origin = data["origin"] === "recording" ? "recording" : "track";
70710
70809
  return {
70711
70810
  frameId: id,
70712
70811
  deviceId: Number(data["deviceId"] ?? 0),
70812
+ origin,
70713
70813
  trackId: String(data["trackId"] ?? ""),
70714
70814
  sourceMediaId: Number(data["sourceMediaId"] ?? 0),
70715
70815
  sourceKind: String(data["sourceKind"] ?? "keyFrame"),
@@ -70717,9 +70817,57 @@ var RetrainFrameStore = class {
70717
70817
  width: Number(data["width"] ?? 0),
70718
70818
  height: Number(data["height"] ?? 0),
70719
70819
  copiedAt: Number(data["copiedAt"] ?? 0),
70720
- locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null
70820
+ locationId: typeof data["locationId"] === "string" && data["locationId"].length > 0 ? data["locationId"] : null,
70821
+ ...finiteNumber(data["capturedAt"]) !== void 0 ? { capturedAt: finiteNumber(data["capturedAt"]) } : {},
70822
+ ...finiteNumber(data["chromaSpread"]) !== void 0 ? { chromaSpread: finiteNumber(data["chromaSpread"]) } : {},
70823
+ complete: data["complete"] === true || data["complete"] === 1,
70824
+ ...finiteNumber(data["epochMs"]) !== void 0 ? { epochMs: finiteNumber(data["epochMs"]) } : {}
70721
70825
  };
70722
70826
  }
70827
+ /** The copies with these ids, in the order given; unknown ids are skipped. */
70828
+ async getMany(frameIds) {
70829
+ if (frameIds.length === 0) return [];
70830
+ try {
70831
+ const byId = /* @__PURE__ */ new Map();
70832
+ for (let i = 0; i < frameIds.length; i += GET_MANY_PAGE) {
70833
+ const page = frameIds.slice(i, i + GET_MANY_PAGE);
70834
+ const rows = await this.store.query.query({
70835
+ collection: RETRAIN_FRAMES_COLLECTION,
70836
+ filter: {
70837
+ whereIn: { id: [...page] },
70838
+ limit: page.length
70839
+ }
70840
+ });
70841
+ for (const r of rows) if (typeof r.id === "string") byId.set(r.id, this.rowToFrame(r.id, r.data));
70842
+ }
70843
+ return frameIds.flatMap((id) => {
70844
+ const frame = byId.get(id);
70845
+ return frame !== void 0 ? [frame] : [];
70846
+ });
70847
+ } catch (err) {
70848
+ this.logger.warn("RetrainFrameStore.getMany failed", { meta: {
70849
+ frames: frameIds.length,
70850
+ error: String(err)
70851
+ } });
70852
+ return [];
70853
+ }
70854
+ }
70855
+ /** Every camera the dataset holds a copy of — a recording still can be the
70856
+ * only thing a camera contributes, with no track anywhere. */
70857
+ async listDeviceIds() {
70858
+ try {
70859
+ const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
70860
+ const ids = /* @__PURE__ */ new Set();
70861
+ for (const r of rows) {
70862
+ const id = Number(r.data["deviceId"]);
70863
+ if (Number.isInteger(id) && id > 0) ids.add(id);
70864
+ }
70865
+ return [...ids].toSorted((a, b) => a - b);
70866
+ } catch (err) {
70867
+ this.logger.warn("RetrainFrameStore.listDeviceIds failed", { meta: { error: String(err) } });
70868
+ return [];
70869
+ }
70870
+ }
70723
70871
  /** The copy with this id, or `null`. Index read only — no blob is touched. */
70724
70872
  async get(frameId) {
70725
70873
  try {
@@ -70736,8 +70884,10 @@ var RetrainFrameStore = class {
70736
70884
  return null;
70737
70885
  }
70738
70886
  }
70739
- /** Every copy taken from this track, oldest first. */
70887
+ /** Every copy taken from this track, oldest first. A recording still has
70888
+ * no track and is never answered here. */
70740
70889
  async listByTrack(trackId) {
70890
+ if (trackId === "") return [];
70741
70891
  try {
70742
70892
  return (await this.store.query.query({
70743
70893
  collection: RETRAIN_FRAMES_COLLECTION,
@@ -70757,6 +70907,31 @@ var RetrainFrameStore = class {
70757
70907
  return [];
70758
70908
  }
70759
70909
  }
70910
+ /**
70911
+ * The copies of one camera the operator marked complete. A complete frame
70912
+ * with no box is a hard negative, so an export cannot find these through
70913
+ * the annotation table alone.
70914
+ */
70915
+ async listCompleteByDevice(deviceId, limit) {
70916
+ try {
70917
+ return (await this.store.query.query({
70918
+ collection: RETRAIN_FRAMES_COLLECTION,
70919
+ filter: {
70920
+ where: {
70921
+ deviceId,
70922
+ complete: true
70923
+ },
70924
+ limit
70925
+ }
70926
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToFrame(r.id, r.data));
70927
+ } catch (err) {
70928
+ this.logger.warn("RetrainFrameStore.listCompleteByDevice failed", {
70929
+ tags: { deviceId },
70930
+ meta: { error: String(err) }
70931
+ });
70932
+ return [];
70933
+ }
70934
+ }
70760
70935
  /** How many copies each of these tracks already owns — one query, so the
70761
70936
  * staging list can badge "worked / not worked" without N round trips. */
70762
70937
  async countsByTrack(trackIds) {
@@ -70785,8 +70960,8 @@ var RetrainFrameStore = class {
70785
70960
  async readFrame(frame) {
70786
70961
  try {
70787
70962
  return await this.storage.read({
70788
- location: frame.locationId ?? RETRAIN_MEDIA_LOCATION,
70789
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
70963
+ location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
70964
+ relativePath: framePath(frame)
70790
70965
  });
70791
70966
  } catch (err) {
70792
70967
  this.logger.warn("retrain frame copy unreadable", {
@@ -70855,11 +71030,17 @@ var RetrainFrameStore = class {
70855
71030
  reason: "unreadable-image"
70856
71031
  };
70857
71032
  }
70858
- const path = framePath(input.deviceId, input.trackId, frameId);
70859
- const locationId = await this.resolveWriteLocation(input.deviceId);
71033
+ const path = framePath({
71034
+ ...input,
71035
+ frameId,
71036
+ origin: "track"
71037
+ });
71038
+ const locationId = RETRAIN_MEDIA_LOCATION;
71039
+ const chromaSpread = await this.chromaOf(bytes, input.deviceId);
70860
71040
  const frame = {
70861
71041
  frameId,
70862
71042
  deviceId: input.deviceId,
71043
+ origin: "track",
70863
71044
  trackId: input.trackId,
70864
71045
  sourceMediaId: input.sourceMediaId,
70865
71046
  sourceKind: input.sourceKind,
@@ -70867,11 +71048,14 @@ var RetrainFrameStore = class {
70867
71048
  width: size.width,
70868
71049
  height: size.height,
70869
71050
  copiedAt: this.now(),
70870
- locationId
71051
+ locationId,
71052
+ ...input.capturedAt !== void 0 ? { capturedAt: input.capturedAt } : {},
71053
+ ...chromaSpread !== void 0 ? { chromaSpread } : {},
71054
+ complete: false
70871
71055
  };
70872
71056
  const persist = async () => {
70873
71057
  await this.storage.write({
70874
- location: locationId ?? RETRAIN_MEDIA_LOCATION,
71058
+ location: locationId,
70875
71059
  relativePath: path,
70876
71060
  data: bytes
70877
71061
  });
@@ -70888,7 +71072,10 @@ var RetrainFrameStore = class {
70888
71072
  width: frame.width,
70889
71073
  height: frame.height,
70890
71074
  copiedAt: frame.copiedAt,
70891
- locationId: frame.locationId
71075
+ locationId: frame.locationId,
71076
+ capturedAt: frame.capturedAt ?? null,
71077
+ chromaSpread: frame.chromaSpread ?? null,
71078
+ complete: false
70892
71079
  }
70893
71080
  });
70894
71081
  };
@@ -70928,6 +71115,136 @@ var RetrainFrameStore = class {
70928
71115
  };
70929
71116
  }
70930
71117
  /**
71118
+ * Store a still decoded from the RECORDING at `epochMs` — a frame with no
71119
+ * track and no media row, usually the one the model saw nothing in.
71120
+ *
71121
+ * Same contract as {@link copyFrame}: an existing copy of that moment is
71122
+ * returned untouched; the blob lands before the row; a refusal is named.
71123
+ * `capturedAt` IS the instant — the camera saw this frame then.
71124
+ */
71125
+ async storeRecordingStill(input) {
71126
+ const frameId = recordingFrameId(input.deviceId, input.epochMs);
71127
+ const existing = await this.get(frameId);
71128
+ if (existing !== null) return {
71129
+ kind: "copied",
71130
+ frame: existing,
71131
+ reused: true
71132
+ };
71133
+ const size = readJpegSize(input.bytes);
71134
+ if (size === null) {
71135
+ this.logger.warn("retrain recording still refused — not a readable JPEG", {
71136
+ tags: { deviceId: input.deviceId },
71137
+ meta: {
71138
+ epochMs: input.epochMs,
71139
+ bytes: input.bytes.length
71140
+ }
71141
+ });
71142
+ return {
71143
+ kind: "refused",
71144
+ reason: "unreadable-image"
71145
+ };
71146
+ }
71147
+ const chromaSpread = await this.chromaOf(input.bytes, input.deviceId);
71148
+ const frame = {
71149
+ frameId,
71150
+ deviceId: input.deviceId,
71151
+ origin: "recording",
71152
+ trackId: "",
71153
+ sourceMediaId: 0,
71154
+ sourceKind: "fullFrame",
71155
+ sizeBytes: input.bytes.length,
71156
+ width: size.width,
71157
+ height: size.height,
71158
+ copiedAt: this.now(),
71159
+ locationId: RETRAIN_MEDIA_LOCATION,
71160
+ capturedAt: input.epochMs,
71161
+ ...chromaSpread !== void 0 ? { chromaSpread } : {},
71162
+ complete: false,
71163
+ epochMs: input.epochMs
71164
+ };
71165
+ const path = framePath(frame);
71166
+ const persist = async () => {
71167
+ await this.storage.write({
71168
+ location: RETRAIN_MEDIA_LOCATION,
71169
+ relativePath: path,
71170
+ data: input.bytes
71171
+ });
71172
+ await this.store.set.mutate({
71173
+ collection: RETRAIN_FRAMES_COLLECTION,
71174
+ key: frameId,
71175
+ value: {
71176
+ deviceId: frame.deviceId,
71177
+ trackId: "",
71178
+ sourceMediaId: 0,
71179
+ sourceKind: frame.sourceKind,
71180
+ path,
71181
+ sizeBytes: frame.sizeBytes,
71182
+ width: frame.width,
71183
+ height: frame.height,
71184
+ copiedAt: frame.copiedAt,
71185
+ locationId: RETRAIN_MEDIA_LOCATION,
71186
+ capturedAt: input.epochMs,
71187
+ chromaSpread: frame.chromaSpread ?? null,
71188
+ complete: false,
71189
+ origin: "recording",
71190
+ epochMs: input.epochMs
71191
+ }
71192
+ });
71193
+ };
71194
+ try {
71195
+ if (this.writeGate) await this.writeGate.run(persist);
71196
+ else await persist();
71197
+ } catch (err) {
71198
+ this.logger.error("retrain recording still failed to persist", {
71199
+ tags: { deviceId: input.deviceId },
71200
+ meta: {
71201
+ frameId,
71202
+ epochMs: input.epochMs,
71203
+ error: String(err)
71204
+ }
71205
+ });
71206
+ return {
71207
+ kind: "refused",
71208
+ reason: "write-failed"
71209
+ };
71210
+ }
71211
+ this.logger.info("retrain recording still stored in the dataset", {
71212
+ tags: { deviceId: input.deviceId },
71213
+ meta: {
71214
+ frameId,
71215
+ epochMs: input.epochMs,
71216
+ width: frame.width,
71217
+ height: frame.height
71218
+ }
71219
+ });
71220
+ return {
71221
+ kind: "copied",
71222
+ frame,
71223
+ reused: false
71224
+ };
71225
+ }
71226
+ /**
71227
+ * The operator's "every subject in this frame is boxed" (D785 context: an
71228
+ * unboxed subject is a false positive to a detector evaluation). Only the
71229
+ * operator sets it; nothing derives it from the annotation count, because a
71230
+ * frame with zero boxes may be a complete hard negative.
71231
+ *
71232
+ * @returns the updated frame, or `null` when no such copy exists.
71233
+ */
71234
+ async setComplete(frameId, complete) {
71235
+ const existing = await this.get(frameId);
71236
+ if (existing === null) return null;
71237
+ await this.store.update.mutate({
71238
+ collection: RETRAIN_FRAMES_COLLECTION,
71239
+ id: frameId,
71240
+ data: { complete }
71241
+ });
71242
+ return {
71243
+ ...existing,
71244
+ complete
71245
+ };
71246
+ }
71247
+ /**
70931
71248
  * Drop a copy and its blob — the operator un-selecting a frame.
70932
71249
  *
70933
71250
  * The caller must have removed the frame's annotations first; this store does
@@ -70938,8 +71255,8 @@ var RetrainFrameStore = class {
70938
71255
  const remove = async () => {
70939
71256
  try {
70940
71257
  await this.storage.delete({
70941
- location: frame.locationId ?? RETRAIN_MEDIA_LOCATION,
70942
- relativePath: framePath(frame.deviceId, frame.trackId, frame.frameId)
71258
+ location: frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION,
71259
+ relativePath: framePath(frame)
70943
71260
  });
70944
71261
  } catch {}
70945
71262
  try {
@@ -70960,6 +71277,95 @@ var RetrainFrameStore = class {
70960
71277
  if (this.writeGate) await this.writeGate.run(remove);
70961
71278
  else await remove();
70962
71279
  }
71280
+ /**
71281
+ * Move every copy still on the footage class onto the durable retrain
71282
+ * tenant (D781): copy → read back → restamp → delete the source.
71283
+ *
71284
+ * A reconcile, not a one-shot: one query of a collection bounded by
71285
+ * operator selection, so it is cheap when converged, and it is what would
71286
+ * notice a pre-D781 copy restored from anywhere. The order is the gallery
71287
+ * reconcile's (D341) and for the same reason: the row is restamped only
71288
+ * after the new blob reads back, so a crash at any point leaves a row that
71289
+ * names a location holding its bytes.
71290
+ *
71291
+ * A copy whose bytes are already gone is COUNTED and LOGGED, never deleted:
71292
+ * its annotations are the operator's work, and only the operator decides
71293
+ * to drop them (deselecting the frame).
71294
+ */
71295
+ async relocateLegacyCopies() {
71296
+ const rows = await this.store.query.query({ collection: RETRAIN_FRAMES_COLLECTION });
71297
+ let relocated = 0;
71298
+ let missing = 0;
71299
+ let failed = 0;
71300
+ for (const row of rows) {
71301
+ if (typeof row.id !== "string") continue;
71302
+ const data = row.data;
71303
+ if (rowIsDurableRetrainCopy(data)) continue;
71304
+ const frame = this.rowToFrame(row.id, data);
71305
+ const from = frame.locationId ?? LEGACY_RETRAIN_MEDIA_LOCATION;
71306
+ const relativePath = framePath(frame);
71307
+ let bytes;
71308
+ try {
71309
+ bytes = await this.storage.read({
71310
+ location: from,
71311
+ relativePath
71312
+ });
71313
+ } catch (err) {
71314
+ missing += 1;
71315
+ this.logger.warn("retrain copy has NO bytes on the footage class — its row and annotations are kept; deselect the frame to drop it", {
71316
+ tags: { deviceId: frame.deviceId },
71317
+ meta: {
71318
+ frameId: frame.frameId,
71319
+ trackId: frame.trackId,
71320
+ from,
71321
+ error: String(err)
71322
+ }
71323
+ });
71324
+ continue;
71325
+ }
71326
+ const move = async () => {
71327
+ await this.storage.write({
71328
+ location: RETRAIN_MEDIA_LOCATION,
71329
+ relativePath,
71330
+ data: bytes
71331
+ });
71332
+ if (!(await this.storage.read({
71333
+ location: RETRAIN_MEDIA_LOCATION,
71334
+ relativePath
71335
+ })).equals(bytes)) throw new Error("read-back differs from the source");
71336
+ await this.store.update.mutate({
71337
+ collection: RETRAIN_FRAMES_COLLECTION,
71338
+ id: frame.frameId,
71339
+ data: { locationId: RETRAIN_MEDIA_LOCATION }
71340
+ });
71341
+ await this.storage.delete({
71342
+ location: from,
71343
+ relativePath
71344
+ });
71345
+ };
71346
+ try {
71347
+ if (this.writeGate) await this.writeGate.run(move);
71348
+ else await move();
71349
+ relocated += 1;
71350
+ } catch (err) {
71351
+ failed += 1;
71352
+ this.logger.error("retrain copy could not be moved off the footage class", {
71353
+ tags: { deviceId: frame.deviceId },
71354
+ meta: {
71355
+ frameId: frame.frameId,
71356
+ trackId: frame.trackId,
71357
+ from,
71358
+ error: String(err)
71359
+ }
71360
+ });
71361
+ }
71362
+ }
71363
+ return {
71364
+ relocated,
71365
+ missing,
71366
+ failed
71367
+ };
71368
+ }
70963
71369
  };
70964
71370
  //#endregion
70965
71371
  //#region src/pipeline-analytics/retention/location-class-wipe.ts
@@ -82013,6 +82419,17 @@ var RETRAIN_ANNOTATION_INDEXES = [
82013
82419
  columns: ["deviceId", "createdAt"]
82014
82420
  }
82015
82421
  ];
82422
+ var ANNOTATION_SOURCES = [
82423
+ "operator",
82424
+ "assist",
82425
+ "pipeline",
82426
+ "llm"
82427
+ ];
82428
+ /** The stored source, narrowed. Unknown reads as `operator` — the least
82429
+ * presumptuous claim a row can make about who drew it. */
82430
+ function narrowSource(value) {
82431
+ return ANNOTATION_SOURCES.find((source) => source === value) ?? "operator";
82432
+ }
82016
82433
  function optionalString(value) {
82017
82434
  return typeof value === "string" && value.length > 0 ? value : void 0;
82018
82435
  }
@@ -82020,6 +82437,8 @@ function optionalString(value) {
82020
82437
  function narrowMacroClass(value) {
82021
82438
  return typeof value === "string" && isRetrainMacroClass(value) ? value : "person";
82022
82439
  }
82440
+ /** Frame ids per `IN (…)` page in {@link RetrainAnnotationStore.listByFrames}. */
82441
+ var LIST_BY_FRAMES_PAGE = 500;
82023
82442
  var RetrainAnnotationStore = class {
82024
82443
  store;
82025
82444
  logger;
@@ -82056,7 +82475,7 @@ var RetrainAnnotationStore = class {
82056
82475
  label: optionalString(data["label"]),
82057
82476
  subLabel: optionalString(data["subLabel"]),
82058
82477
  kind: data["kind"] === "model_error" ? "model_error" : "subject",
82059
- source: data["source"] === "assist" ? "assist" : "operator",
82478
+ source: narrowSource(data["source"]),
82060
82479
  assistModelId: optionalString(data["assistModelId"]),
82061
82480
  assistScore: typeof score === "number" ? score : void 0,
82062
82481
  exportedInBatch: optionalString(data["exportedInBatch"]),
@@ -82125,6 +82544,32 @@ var RetrainAnnotationStore = class {
82125
82544
  async listByFrame(mediaKey) {
82126
82545
  return this.queryWhere({ mediaKey }, `frame ${mediaKey}`);
82127
82546
  }
82547
+ /** Every annotation of these frames, oldest first — paged `IN (…)`. */
82548
+ async listByFrames(mediaKeys) {
82549
+ const out = [];
82550
+ for (let i = 0; i < mediaKeys.length; i += LIST_BY_FRAMES_PAGE) {
82551
+ const page = mediaKeys.slice(i, i + LIST_BY_FRAMES_PAGE);
82552
+ try {
82553
+ const rows = await this.store.query.query({
82554
+ collection: RETRAIN_ANNOTATIONS_COLLECTION,
82555
+ filter: {
82556
+ whereIn: { mediaKey: [...page] },
82557
+ orderBy: {
82558
+ field: "createdAt",
82559
+ direction: "asc"
82560
+ }
82561
+ }
82562
+ });
82563
+ for (const r of rows) if (typeof r.id === "string") out.push(this.rowToAnnotation(r.id, r.data));
82564
+ } catch (err) {
82565
+ this.logger.warn("RetrainAnnotationStore.listByFrames failed", { meta: {
82566
+ frames: page.length,
82567
+ error: String(err)
82568
+ } });
82569
+ }
82570
+ }
82571
+ return out;
82572
+ }
82128
82573
  async listByTrack(trackId) {
82129
82574
  return this.queryWhere({ trackId }, `track ${trackId}`);
82130
82575
  }
@@ -82238,13 +82683,41 @@ var RetrainAnnotationStore = class {
82238
82683
  //#endregion
82239
82684
  //#region src/pipeline-analytics/retrain/retrain-collection-store.ts
82240
82685
  /**
82686
+ * RetrainCollectionStore — named sets over the one retrain corpus.
82687
+ *
82688
+ * Collections: `pipeline-analytics:retrain-collections` (the names) and
82689
+ * `pipeline-analytics:retrain-collection-frames` (the membership).
82690
+ *
82691
+ * A collection OWNS NO PIXELS. Every frame — copied from a track or stilled
82692
+ * from the recording — lives once in `retrain-frames`, with its annotations,
82693
+ * its completeness and its export path; a collection is a name the operator
82694
+ * gives to a subset of it ("packages porch night", "false-positive cars"). So
82695
+ * a frame can be in several collections, a collection's count is its
82696
+ * membership, and deleting a collection removes memberships, never a frame
82697
+ * another collection or a track still holds. Which trackless frames are left
82698
+ * with no reason to exist is the caller's question
82699
+ * (`retrain-collection-service.ts`).
82700
+ */
82701
+ /**
82241
82702
  * @durable class=ledger owner=pipeline-analytics
82242
82703
  * write="one row per named training collection the operator creates. Names are
82243
- * unique on nameKey (lower(trim)); empty names never become rows."
82244
- * retention="none — operator-owned corpus metadata. Rows leave only when an
82245
- * explicit delete API lands (not V1)."
82704
+ * unique on nameKey (lower(trim)); empty names never become rows. A rename
82705
+ * rewrites name + nameKey under the same id."
82706
+ * retention="none — operator-owned corpus metadata. A row leaves only when the
82707
+ * operator deletes the collection (deleteRetrainCollection), which removes its
82708
+ * membership rows first."
82246
82709
  */
82247
82710
  var RETRAIN_COLLECTIONS_COLLECTION = "pipeline-analytics:retrain-collections";
82711
+ /**
82712
+ * @durable class=ledger owner=pipeline-analytics
82713
+ * write="one row per (collection, frame) pair the operator adds — a frame added
82714
+ * from the recording, or an existing retrain frame added to a collection. Keyed
82715
+ * by a hash of the pair, so adding twice is one row."
82716
+ * retention="none — rows leave when the operator removes the frame from the
82717
+ * collection, deletes the collection, or deselects the frame itself. Bounded by
82718
+ * operator curation."
82719
+ */
82720
+ var RETRAIN_COLLECTION_FRAMES_COLLECTION = "pipeline-analytics:retrain-collection-frames";
82248
82721
  var RETRAIN_COLLECTION_COLUMNS = [
82249
82722
  {
82250
82723
  name: "id",
@@ -82281,6 +82754,41 @@ var RETRAIN_COLLECTION_INDEXES = [{
82281
82754
  name: "idx_retrain_collections_created",
82282
82755
  columns: ["createdAt"]
82283
82756
  }];
82757
+ var RETRAIN_COLLECTION_FRAME_COLUMNS = [
82758
+ {
82759
+ name: "id",
82760
+ type: "TEXT",
82761
+ primaryKey: true,
82762
+ notNull: true
82763
+ },
82764
+ {
82765
+ name: "collectionId",
82766
+ type: "TEXT",
82767
+ notNull: true
82768
+ },
82769
+ {
82770
+ name: "frameId",
82771
+ type: "TEXT",
82772
+ notNull: true
82773
+ },
82774
+ {
82775
+ name: "deviceId",
82776
+ type: "INTEGER",
82777
+ notNull: true
82778
+ },
82779
+ {
82780
+ name: "addedAt",
82781
+ type: "INTEGER",
82782
+ notNull: true
82783
+ }
82784
+ ];
82785
+ var RETRAIN_COLLECTION_FRAME_INDEXES = [{
82786
+ name: "idx_retrain_coll_frames_collection",
82787
+ columns: ["collectionId", "addedAt"]
82788
+ }, {
82789
+ name: "idx_retrain_coll_frames_frame",
82790
+ columns: ["frameId"]
82791
+ }];
82284
82792
  function normalizeCollectionName(raw) {
82285
82793
  const name = raw.trim();
82286
82794
  if (name.length === 0) return null;
@@ -82289,18 +82797,22 @@ function normalizeCollectionName(raw) {
82289
82797
  nameKey: name.toLowerCase()
82290
82798
  };
82291
82799
  }
82800
+ /** The membership row's key — one row per pair, whatever the ids contain. */
82801
+ function membershipKey(collectionId, frameId) {
82802
+ return createHash("sha1").update(`${collectionId}\u0000${frameId}`).digest("hex");
82803
+ }
82804
+ /** Ids per `IN (…)` page. */
82805
+ var IN_PAGE = 500;
82292
82806
  var RetrainCollectionStore = class {
82293
82807
  store;
82294
82808
  logger;
82295
82809
  now;
82296
82810
  newId;
82297
- countFrames;
82298
82811
  constructor(deps) {
82299
82812
  this.store = deps.store;
82300
82813
  this.logger = deps.logger;
82301
82814
  this.now = deps.now;
82302
82815
  this.newId = deps.newId;
82303
- this.countFrames = deps.countFrames;
82304
82816
  }
82305
82817
  static async declare(store) {
82306
82818
  await store.declareCollection.mutate({
@@ -82308,6 +82820,11 @@ var RetrainCollectionStore = class {
82308
82820
  columns: [...RETRAIN_COLLECTION_COLUMNS],
82309
82821
  indexes: [...RETRAIN_COLLECTION_INDEXES]
82310
82822
  });
82823
+ await store.declareCollection.mutate({
82824
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82825
+ columns: [...RETRAIN_COLLECTION_FRAME_COLUMNS],
82826
+ indexes: [...RETRAIN_COLLECTION_FRAME_INDEXES]
82827
+ });
82311
82828
  }
82312
82829
  async create(input) {
82313
82830
  const normalized = normalizeCollectionName(input.name);
@@ -82315,7 +82832,7 @@ var RetrainCollectionStore = class {
82315
82832
  kind: "refused",
82316
82833
  reason: "empty-name"
82317
82834
  };
82318
- if (await this.findByNameKey(normalized.nameKey) !== null) return {
82835
+ if (await this.findIdByNameKey(normalized.nameKey) !== null) return {
82319
82836
  kind: "refused",
82320
82837
  reason: "duplicate-name"
82321
82838
  };
@@ -82331,6 +82848,7 @@ var RetrainCollectionStore = class {
82331
82848
  updatedAt: createdAt
82332
82849
  }
82333
82850
  });
82851
+ this.logger.info("retrain collection created", { meta: { collectionId: id } });
82334
82852
  return {
82335
82853
  kind: "created",
82336
82854
  collection: {
@@ -82341,6 +82859,7 @@ var RetrainCollectionStore = class {
82341
82859
  }
82342
82860
  };
82343
82861
  }
82862
+ /** Every collection, newest first, each with its membership count (one query). */
82344
82863
  async list() {
82345
82864
  let rows;
82346
82865
  try {
@@ -82355,34 +82874,158 @@ var RetrainCollectionStore = class {
82355
82874
  this.logger.warn("retrain collections list failed", { meta: { error: String(err) } });
82356
82875
  return [];
82357
82876
  }
82358
- const out = [];
82359
- for (const row of rows) {
82360
- const name = typeof row.data["name"] === "string" ? row.data["name"] : "";
82361
- const createdAt = Number(row.data["createdAt"] ?? 0);
82362
- const frameCount = this.countFrames !== void 0 ? await this.countFrames(row.id) : 0;
82363
- out.push({
82364
- id: row.id,
82365
- name,
82366
- createdAt,
82367
- frameCount
82368
- });
82877
+ const counts = await this.countMembers(rows.map((r) => r.id));
82878
+ return rows.map((row) => this.toCollection(row.id, row.data, counts.get(row.id) ?? 0));
82879
+ }
82880
+ async getById(id) {
82881
+ const data = await this.readRow(id);
82882
+ if (data === null) return null;
82883
+ const counts = await this.countMembers([id]);
82884
+ return this.toCollection(id, data, counts.get(id) ?? 0);
82885
+ }
82886
+ async rename(input) {
82887
+ const normalized = normalizeCollectionName(input.name);
82888
+ if (normalized === null) return {
82889
+ kind: "refused",
82890
+ reason: "empty-name"
82891
+ };
82892
+ const current = await this.readRow(input.id);
82893
+ if (current === null) return {
82894
+ kind: "refused",
82895
+ reason: "unknown-collection"
82896
+ };
82897
+ const holder = await this.findIdByNameKey(normalized.nameKey);
82898
+ if (holder !== null && holder !== input.id) return {
82899
+ kind: "refused",
82900
+ reason: "duplicate-name"
82901
+ };
82902
+ await this.store.update.mutate({
82903
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
82904
+ id: input.id,
82905
+ data: {
82906
+ name: normalized.name,
82907
+ nameKey: normalized.nameKey,
82908
+ updatedAt: this.now()
82909
+ }
82910
+ });
82911
+ this.logger.info("retrain collection renamed", { meta: { collectionId: input.id } });
82912
+ const counts = await this.countMembers([input.id]);
82913
+ return {
82914
+ kind: "renamed",
82915
+ collection: this.toCollection(input.id, {
82916
+ ...current,
82917
+ name: normalized.name
82918
+ }, counts.get(input.id) ?? 0)
82919
+ };
82920
+ }
82921
+ /** Remove the collection and its memberships — members first, so a crash
82922
+ * never leaves memberships pointing at a name that no longer exists. */
82923
+ async delete(id) {
82924
+ if (await this.readRow(id) === null) return {
82925
+ deleted: false,
82926
+ memberFrameIds: []
82927
+ };
82928
+ const members = await this.membershipRows({ collectionId: id });
82929
+ for (const m of members) await this.store.delete.mutate({
82930
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82931
+ key: m.id
82932
+ });
82933
+ await this.store.delete.mutate({
82934
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
82935
+ key: id
82936
+ });
82937
+ this.logger.info("retrain collection deleted", { meta: {
82938
+ collectionId: id,
82939
+ members: members.length
82940
+ } });
82941
+ return {
82942
+ deleted: true,
82943
+ memberFrameIds: members.map((m) => String(m.data["frameId"] ?? ""))
82944
+ };
82945
+ }
82946
+ /** Add a frame. `false` when it was already a member (nothing written). */
82947
+ async addFrame(input) {
82948
+ const key = membershipKey(input.collectionId, input.frameId);
82949
+ const existing = await this.store.get.query({
82950
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82951
+ key
82952
+ });
82953
+ if (existing !== null && existing !== void 0) return false;
82954
+ await this.store.set.mutate({
82955
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82956
+ key,
82957
+ value: {
82958
+ collectionId: input.collectionId,
82959
+ frameId: input.frameId,
82960
+ deviceId: input.deviceId,
82961
+ addedAt: this.now()
82962
+ }
82963
+ });
82964
+ return true;
82965
+ }
82966
+ /** Remove one membership. `false` when it did not exist. */
82967
+ async removeFrame(input) {
82968
+ const key = membershipKey(input.collectionId, input.frameId);
82969
+ const existing = await this.store.get.query({
82970
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82971
+ key
82972
+ });
82973
+ if (existing === null || existing === void 0) return false;
82974
+ await this.store.delete.mutate({
82975
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82976
+ key
82977
+ });
82978
+ return true;
82979
+ }
82980
+ /** Remove a frame from EVERY collection — the frame itself is leaving. */
82981
+ async forgetFrame(frameId) {
82982
+ const rows = await this.membershipRows({ frameId });
82983
+ for (const r of rows) await this.store.delete.mutate({
82984
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
82985
+ key: r.id
82986
+ });
82987
+ return rows.length;
82988
+ }
82989
+ /** The frames of a collection, in the order they were added. */
82990
+ async listFrameIds(collectionId) {
82991
+ 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 !== "");
82992
+ }
82993
+ /** frameId → the collections it belongs to. Frames in none are absent. */
82994
+ async collectionsOf(frameIds) {
82995
+ const out = /* @__PURE__ */ new Map();
82996
+ for (const row of await this.membershipRowsIn("frameId", frameIds)) {
82997
+ const frameId = String(row.data["frameId"] ?? "");
82998
+ const collectionId = String(row.data["collectionId"] ?? "");
82999
+ if (frameId === "" || collectionId === "") continue;
83000
+ out.set(frameId, [...out.get(frameId) ?? [], collectionId]);
82369
83001
  }
83002
+ for (const [frameId, ids] of out) out.set(frameId, ids.toSorted());
82370
83003
  return out;
82371
83004
  }
82372
- async getById(id) {
83005
+ /** The frame ids of several collections, de-duplicated. */
83006
+ async frameIdsOf(collectionIds) {
83007
+ const ids = /* @__PURE__ */ new Set();
83008
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83009
+ const frameId = String(row.data["frameId"] ?? "");
83010
+ if (frameId !== "") ids.add(frameId);
83011
+ }
83012
+ return [...ids];
83013
+ }
83014
+ toCollection(id, data, frameCount) {
83015
+ return {
83016
+ id,
83017
+ name: typeof data["name"] === "string" ? data["name"] : "",
83018
+ createdAt: Number(data["createdAt"] ?? 0),
83019
+ frameCount
83020
+ };
83021
+ }
83022
+ async readRow(id) {
82373
83023
  try {
82374
83024
  const row = await this.store.get.query({
82375
83025
  collection: RETRAIN_COLLECTIONS_COLLECTION,
82376
83026
  key: id
82377
83027
  });
82378
- if (row === null || row === void 0) return null;
82379
- const data = row;
82380
- return {
82381
- id,
82382
- name: typeof data["name"] === "string" ? data["name"] : "",
82383
- createdAt: Number(data["createdAt"] ?? 0),
82384
- frameCount: this.countFrames !== void 0 ? await this.countFrames(id) : 0
82385
- };
83028
+ return row === null || row === void 0 ? null : { ...row };
82386
83029
  } catch (err) {
82387
83030
  this.logger.warn("retrain collection get failed", { meta: {
82388
83031
  collectionId: id,
@@ -82391,14 +83034,125 @@ var RetrainCollectionStore = class {
82391
83034
  return null;
82392
83035
  }
82393
83036
  }
83037
+ async countMembers(collectionIds) {
83038
+ const counts = /* @__PURE__ */ new Map();
83039
+ for (const row of await this.membershipRowsIn("collectionId", collectionIds)) {
83040
+ const id = String(row.data["collectionId"] ?? "");
83041
+ counts.set(id, (counts.get(id) ?? 0) + 1);
83042
+ }
83043
+ return counts;
83044
+ }
83045
+ async membershipRows(where) {
83046
+ try {
83047
+ return await this.store.query.query({
83048
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83049
+ filter: { where }
83050
+ });
83051
+ } catch (err) {
83052
+ this.logger.warn("retrain collection membership read failed", { meta: {
83053
+ ...where,
83054
+ error: String(err)
83055
+ } });
83056
+ return [];
83057
+ }
83058
+ }
83059
+ async membershipRowsIn(field, values) {
83060
+ const out = [];
83061
+ for (let i = 0; i < values.length; i += IN_PAGE) {
83062
+ const page = values.slice(i, i + IN_PAGE);
83063
+ try {
83064
+ out.push(...await this.store.query.query({
83065
+ collection: RETRAIN_COLLECTION_FRAMES_COLLECTION,
83066
+ filter: { whereIn: { [field]: [...page] } }
83067
+ }));
83068
+ } catch (err) {
83069
+ this.logger.warn("retrain collection membership read failed", { meta: {
83070
+ field,
83071
+ values: page.length,
83072
+ error: String(err)
83073
+ } });
83074
+ }
83075
+ }
83076
+ return out;
83077
+ }
83078
+ async findIdByNameKey(nameKey) {
83079
+ try {
83080
+ return (await this.store.query.query({
83081
+ collection: RETRAIN_COLLECTIONS_COLLECTION,
83082
+ filter: {
83083
+ where: { nameKey },
83084
+ limit: 1
83085
+ }
83086
+ }))[0]?.id ?? null;
83087
+ } catch (err) {
83088
+ this.logger.warn("retrain collection findByNameKey failed", { meta: {
83089
+ nameKey,
83090
+ error: String(err)
83091
+ } });
83092
+ return null;
83093
+ }
83094
+ }
83095
+ };
83096
+ //#endregion
83097
+ //#region src/pipeline-analytics/retrain/retrain-collection-service.ts
83098
+ /** What each still outcome tells the operator. */
83099
+ var STILL_REFUSAL = {
83100
+ "no-recording": {
83101
+ reason: "still-unavailable",
83102
+ detail: "this camera is not recording, so there is no footage to take a frame from"
83103
+ },
83104
+ "no-footage": {
83105
+ reason: "still-unavailable",
83106
+ detail: "no recorded footage covers that instant (a gap, or already pruned)"
83107
+ },
83108
+ "decode-failed": {
83109
+ reason: "still-unavailable",
83110
+ detail: "the footage is there but the frame could not be decoded"
83111
+ },
83112
+ busy: {
83113
+ reason: "still-busy",
83114
+ detail: "the recorder is already decoding other frames — try again in a moment"
83115
+ }
83116
+ };
83117
+ var RetrainCollectionService = class {
83118
+ deps;
83119
+ constructor(deps) {
83120
+ this.deps = deps;
83121
+ }
83122
+ create(input) {
83123
+ return this.deps.collections.create(input);
83124
+ }
83125
+ list() {
83126
+ return this.deps.collections.list();
83127
+ }
83128
+ rename(input) {
83129
+ return this.deps.collections.rename(input);
83130
+ }
82394
83131
  /**
82395
- * Stub until still ingest lands. Validates the collection exists, then
82396
- * refuses with a named reason so callers can wire UI against a stable shape.
83132
+ * Delete a collection. Its recording stills that no other collection holds
83133
+ * go with it (annotations first); track copies always stay.
82397
83134
  */
82398
- async addRetrainFrameFromRecording(input) {
82399
- if (await this.getById(input.collectionId) === null) {
82400
- this.logger.info("retrain collection addFrame refused: unknown-collection", {
82401
- tags: { deviceId: input.deviceId },
83135
+ async delete(input) {
83136
+ const result = await this.deps.collections.delete(input.id);
83137
+ if (!result.deleted) return {
83138
+ deleted: false,
83139
+ removedFrames: 0
83140
+ };
83141
+ return {
83142
+ deleted: true,
83143
+ removedFrames: await this.dropOrphanStills(result.memberFrameIds)
83144
+ };
83145
+ }
83146
+ /**
83147
+ * Decode the frame at `epochMs` from the camera's recording and add it to
83148
+ * the collection. The moment already in the dataset is reused without a
83149
+ * decode; every refusal is named and writes nothing.
83150
+ */
83151
+ async addFrameFromRecording(input) {
83152
+ const tags = { deviceId: input.deviceId };
83153
+ if (await this.deps.collections.getById(input.collectionId) === null) {
83154
+ this.deps.logger.info("retrain collection add refused: unknown collection", {
83155
+ tags,
82402
83156
  meta: {
82403
83157
  collectionId: input.collectionId,
82404
83158
  epochMs: input.epochMs
@@ -82409,45 +83163,836 @@ var RetrainCollectionStore = class {
82409
83163
  reason: "unknown-collection"
82410
83164
  };
82411
83165
  }
82412
- this.logger.info("retrain collection addFrame stub: still-capture-unimplemented", {
82413
- tags: { deviceId: input.deviceId },
83166
+ const stored = await this.storeStill(input.deviceId, input.epochMs);
83167
+ if (stored.kind === "refused") {
83168
+ this.deps.logger.info("retrain collection add refused", {
83169
+ tags,
83170
+ meta: {
83171
+ collectionId: input.collectionId,
83172
+ epochMs: input.epochMs,
83173
+ reason: stored.reason
83174
+ }
83175
+ });
83176
+ return stored;
83177
+ }
83178
+ await this.deps.collections.addFrame({
83179
+ collectionId: input.collectionId,
83180
+ frameId: stored.frame.frameId,
83181
+ deviceId: input.deviceId
83182
+ });
83183
+ this.deps.logger.info("retrain frame added to a collection from the recording", {
83184
+ tags,
82414
83185
  meta: {
82415
83186
  collectionId: input.collectionId,
82416
- epochMs: input.epochMs
83187
+ frameId: stored.frame.frameId,
83188
+ epochMs: input.epochMs,
83189
+ reused: stored.reused
82417
83190
  }
82418
83191
  });
83192
+ const [wire] = await this.deps.retrain.toWireFrames([stored.frame]);
83193
+ if (wire === void 0) throw new Error("retrain: a stored still produced no wire frame");
82419
83194
  return {
83195
+ kind: "added",
83196
+ collectionId: input.collectionId,
83197
+ frame: wire,
83198
+ reused: stored.reused
83199
+ };
83200
+ }
83201
+ /** Add frames already in the dataset (track copies or stills) to a collection. */
83202
+ async addFrames(input) {
83203
+ if (await this.deps.collections.getById(input.collectionId) === null) return {
83204
+ added: 0,
83205
+ alreadyMember: 0,
83206
+ unknownFrameIds: [...input.frameIds]
83207
+ };
83208
+ const frames = await this.deps.frames.getMany(input.frameIds);
83209
+ const known = new Set(frames.map((f) => f.frameId));
83210
+ let added = 0;
83211
+ let alreadyMember = 0;
83212
+ for (const frame of frames) if (await this.deps.collections.addFrame({
83213
+ collectionId: input.collectionId,
83214
+ frameId: frame.frameId,
83215
+ deviceId: frame.deviceId
83216
+ })) added += 1;
83217
+ else alreadyMember += 1;
83218
+ return {
83219
+ added,
83220
+ alreadyMember,
83221
+ unknownFrameIds: input.frameIds.filter((id) => !known.has(id))
83222
+ };
83223
+ }
83224
+ /**
83225
+ * Remove a frame from a collection. A recording still left in no collection
83226
+ * is deleted with its annotations; a track copy is never deleted here.
83227
+ */
83228
+ async removeFrame(input) {
83229
+ if (!await this.deps.collections.removeFrame(input)) return {
83230
+ removed: false,
83231
+ deletedFrame: false
83232
+ };
83233
+ return {
83234
+ removed: true,
83235
+ deletedFrame: await this.dropOrphanStills([input.frameId]) > 0
83236
+ };
83237
+ }
83238
+ /** The frames of a collection, as the canvas lists them. */
83239
+ async listFrames(input) {
83240
+ const ids = await this.deps.collections.listFrameIds(input.collectionId);
83241
+ return this.deps.retrain.toWireFrames(await this.deps.frames.getMany(ids));
83242
+ }
83243
+ async storeStill(deviceId, epochMs) {
83244
+ const existing = await this.deps.frames.get(recordingFrameId(deviceId, epochMs));
83245
+ if (existing !== null) return {
83246
+ kind: "stored",
83247
+ frame: existing,
83248
+ reused: true
83249
+ };
83250
+ const still = await this.deps.captureStill(deviceId, epochMs);
83251
+ if (still.outcome !== "ok" || still.jpeg === void 0) {
83252
+ const refusal = still.outcome === "ok" ? STILL_REFUSAL["decode-failed"] : STILL_REFUSAL[still.outcome];
83253
+ return {
83254
+ kind: "refused",
83255
+ reason: refusal.reason,
83256
+ detail: refusal.detail
83257
+ };
83258
+ }
83259
+ const stored = await this.deps.frames.storeRecordingStill({
83260
+ deviceId,
83261
+ epochMs,
83262
+ bytes: still.jpeg
83263
+ });
83264
+ return stored.kind === "copied" ? {
83265
+ kind: "stored",
83266
+ frame: stored.frame,
83267
+ reused: stored.reused
83268
+ } : {
82420
83269
  kind: "refused",
82421
- reason: "still-capture-unimplemented",
82422
- detail: "Still ingest lands in plan Task 3"
83270
+ reason: stored.reason
82423
83271
  };
82424
83272
  }
82425
- async findByNameKey(nameKey) {
82426
- try {
82427
- const row = (await this.store.query.query({
82428
- collection: RETRAIN_COLLECTIONS_COLLECTION,
82429
- filter: {
82430
- where: { nameKey },
82431
- limit: 1
83273
+ /** Delete the recording stills among `frameIds` that no collection holds any more. */
83274
+ async dropOrphanStills(frameIds) {
83275
+ if (frameIds.length === 0) return 0;
83276
+ const memberships = await this.deps.collections.collectionsOf(frameIds);
83277
+ const orphans = (await this.deps.frames.getMany(frameIds)).filter((f) => f.origin === "recording" && (memberships.get(f.frameId)?.length ?? 0) === 0);
83278
+ for (const frame of orphans) await this.deps.retrain.removeFrame(frame);
83279
+ return orphans.length;
83280
+ }
83281
+ };
83282
+ //#endregion
83283
+ //#region src/pipeline-analytics/retrain/retrain-llm-assist.ts
83284
+ var ALWAYS_LOCAL_KINDS = new Set(["managed-local", "apple-foundation"]);
83285
+ var ALWAYS_CLOUD_KINDS = new Set([
83286
+ "openai",
83287
+ "anthropic",
83288
+ "google"
83289
+ ]);
83290
+ function isPrivateIpv4(host) {
83291
+ const parts = host.split(".").map((p) => Number.parseInt(p, 10));
83292
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
83293
+ const [a, b] = parts;
83294
+ 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;
83295
+ }
83296
+ function isLocalHost(rawHost) {
83297
+ const host = rawHost.replace(/^\[|\]$/g, "").toLowerCase();
83298
+ if (host.includes(":")) return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
83299
+ if (host === "localhost" || host.endsWith(".local") || host.endsWith(".lan")) return true;
83300
+ if (isPrivateIpv4(host)) return true;
83301
+ return !host.includes(".") && !host.includes(":");
83302
+ }
83303
+ /** Does sending a frame to this profile send it outside the house? */
83304
+ function llmProfileLeavesHouse(profile) {
83305
+ if (ALWAYS_LOCAL_KINDS.has(profile.kind)) return false;
83306
+ if (ALWAYS_CLOUD_KINDS.has(profile.kind)) return true;
83307
+ if (profile.baseUrl === void 0 || profile.baseUrl === "") return true;
83308
+ try {
83309
+ return !isLocalHost(new URL(profile.baseUrl).hostname);
83310
+ } catch {
83311
+ return true;
83312
+ }
83313
+ }
83314
+ /** At most this many boxes are sent for review — a prompt, not a dump. */
83315
+ var LLM_REVIEW_MAX_BOXES = 50;
83316
+ var VERDICTS = [
83317
+ "confirmed",
83318
+ "wrong-class",
83319
+ "not-an-object",
83320
+ "uncertain"
83321
+ ];
83322
+ var SYSTEM_PROMPT = [
83323
+ "You annotate frames from home security cameras to build an object-detection training set.",
83324
+ "Be precise and conservative: only report what is clearly visible.",
83325
+ "Coordinates are normalised to the full frame: x and y are the top-left corner,",
83326
+ "w and h the width and height, all between 0 and 1.",
83327
+ "Answer with JSON only, exactly in the requested schema, with no other text."
83328
+ ].join(" ");
83329
+ /** The JSON schema asked of the model (and enforced again by the parser). */
83330
+ function answerJsonSchema(classes) {
83331
+ return {
83332
+ type: "object",
83333
+ additionalProperties: false,
83334
+ required: ["objects", "reviews"],
83335
+ properties: {
83336
+ objects: {
83337
+ type: "array",
83338
+ items: {
83339
+ type: "object",
83340
+ additionalProperties: false,
83341
+ required: [
83342
+ "class",
83343
+ "box",
83344
+ "confidence",
83345
+ "reason"
83346
+ ],
83347
+ properties: {
83348
+ class: {
83349
+ type: "string",
83350
+ enum: [...classes]
83351
+ },
83352
+ label: { type: "string" },
83353
+ box: {
83354
+ type: "array",
83355
+ items: { type: "number" },
83356
+ minItems: 4,
83357
+ maxItems: 4
83358
+ },
83359
+ confidence: {
83360
+ type: "number",
83361
+ minimum: 0,
83362
+ maximum: 1
83363
+ },
83364
+ reason: { type: "string" }
83365
+ }
82432
83366
  }
82433
- }))[0];
82434
- if (row === void 0) return null;
83367
+ },
83368
+ reviews: {
83369
+ type: "array",
83370
+ items: {
83371
+ type: "object",
83372
+ additionalProperties: false,
83373
+ required: [
83374
+ "ref",
83375
+ "verdict",
83376
+ "reason"
83377
+ ],
83378
+ properties: {
83379
+ ref: { type: "string" },
83380
+ verdict: {
83381
+ type: "string",
83382
+ enum: [...VERDICTS]
83383
+ },
83384
+ suggestedClass: { type: "string" },
83385
+ reason: { type: "string" }
83386
+ }
83387
+ }
83388
+ }
83389
+ }
83390
+ };
83391
+ }
83392
+ var round = (n) => Math.round(n * 1e3) / 1e3;
83393
+ /** Build the request: target classes, the frame size, the boxes to review. */
83394
+ function buildLlmAssistRequest(input) {
83395
+ const boxes = input.boxes.slice(0, LLM_REVIEW_MAX_BOXES).map((b) => JSON.stringify({
83396
+ ref: b.ref,
83397
+ class: b.macroClass,
83398
+ ...b.label !== void 0 ? { label: b.label } : {},
83399
+ box: [
83400
+ round(b.bbox.x),
83401
+ round(b.bbox.y),
83402
+ round(b.bbox.w),
83403
+ round(b.bbox.h)
83404
+ ]
83405
+ }));
83406
+ return {
83407
+ system: SYSTEM_PROMPT,
83408
+ prompt: [
83409
+ `The image is one camera frame, ${String(input.width)}×${String(input.height)} pixels.`,
83410
+ `1. List every object of these classes you can see: ${input.classes.join(", ")}.`,
83411
+ " For each give class, an optional free-text label (e.g. \"van\", \"postman\"),",
83412
+ " box [x, y, w, h] normalised to the frame, confidence 0–1, and a short reason.",
83413
+ " Include small and partly hidden ones; do not include anything else.",
83414
+ 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\": [].",
83415
+ "Answer with JSON: {\"objects\": [...], \"reviews\": [...]}."
83416
+ ].join("\n"),
83417
+ jsonSchema: answerJsonSchema(input.classes)
83418
+ };
83419
+ }
83420
+ /** The whole answer: two arrays and nothing else. Items are checked one by one. */
83421
+ var AnswerSchema = strictObject({
83422
+ objects: array(unknown()),
83423
+ reviews: array(unknown())
83424
+ });
83425
+ var ObjectSchema = strictObject({
83426
+ class: string(),
83427
+ label: string().optional(),
83428
+ box: tuple([
83429
+ number(),
83430
+ number(),
83431
+ number(),
83432
+ number()
83433
+ ]),
83434
+ confidence: number().min(0).max(1),
83435
+ reason: string().max(500)
83436
+ });
83437
+ var ReviewSchema = strictObject({
83438
+ ref: string(),
83439
+ verdict: _enum(VERDICTS),
83440
+ suggestedClass: string().optional(),
83441
+ reason: string().max(500)
83442
+ });
83443
+ function frameBox(box) {
83444
+ const [x, y, w, h] = box;
83445
+ if (![
83446
+ x,
83447
+ y,
83448
+ w,
83449
+ h
83450
+ ].every(Number.isFinite)) return null;
83451
+ if (!(w > 0) || !(h > 0) || x < -.01 || y < -.01) return null;
83452
+ if (x + w > 1.01 || y + h > 1.01) return null;
83453
+ const cx = Math.max(0, x);
83454
+ const cy = Math.max(0, y);
83455
+ return {
83456
+ x: cx,
83457
+ y: cy,
83458
+ w: Math.min(w, 1 - cx),
83459
+ h: Math.min(h, 1 - cy)
83460
+ };
83461
+ }
83462
+ /** Strip a Markdown code fence a model wrapped its JSON in. */
83463
+ function unfence(text) {
83464
+ const trimmed = text.trim();
83465
+ const fence = /^```(?:json)?\s*\n([\s\S]*?)\n?```$/i.exec(trimmed);
83466
+ return fence?.[1] !== void 0 ? fence[1].trim() : trimmed;
83467
+ }
83468
+ /** A free-text label, never a macro word (the tier rule). */
83469
+ function usableLabel(label) {
83470
+ const trimmed = label?.trim();
83471
+ if (trimmed === void 0 || trimmed === "") return void 0;
83472
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
83473
+ }
83474
+ /** Parse the model's answer. See the module header for the rules. */
83475
+ function parseLlmAssistAnswer(text, expected) {
83476
+ let raw;
83477
+ try {
83478
+ raw = JSON.parse(unfence(text));
83479
+ } catch {
83480
+ return {
83481
+ kind: "malformed",
83482
+ detail: "the answer is not JSON"
83483
+ };
83484
+ }
83485
+ const answer = AnswerSchema.safeParse(raw);
83486
+ if (!answer.success) return {
83487
+ kind: "malformed",
83488
+ detail: "the answer is not {\"objects\": [...], \"reviews\": [...]}"
83489
+ };
83490
+ const asked = new Set(expected.classes);
83491
+ let rejected = 0;
83492
+ const objects = [];
83493
+ for (const item of answer.data.objects) {
83494
+ const parsed = ObjectSchema.safeParse(item);
83495
+ const cls = parsed.success ? parsed.data.class.trim().toLowerCase() : "";
83496
+ const bbox = parsed.success ? frameBox(parsed.data.box) : null;
83497
+ if (!parsed.success || bbox === null || !asked.has(cls) || !isRetrainMacroClass(cls)) {
83498
+ rejected += 1;
83499
+ continue;
83500
+ }
83501
+ const label = usableLabel(parsed.data.label);
83502
+ objects.push({
83503
+ macroClass: cls,
83504
+ ...label !== void 0 ? { label } : {},
83505
+ bbox,
83506
+ confidence: parsed.data.confidence,
83507
+ reason: parsed.data.reason
83508
+ });
83509
+ }
83510
+ const reviews = [];
83511
+ for (const item of answer.data.reviews) {
83512
+ const parsed = ReviewSchema.safeParse(item);
83513
+ if (!parsed.success || !expected.refs.has(parsed.data.ref)) {
83514
+ rejected += 1;
83515
+ continue;
83516
+ }
83517
+ const suggested = parsed.data.suggestedClass?.trim().toLowerCase();
83518
+ reviews.push({
83519
+ ref: parsed.data.ref,
83520
+ verdict: parsed.data.verdict,
83521
+ ...suggested !== void 0 && isRetrainMacroClass(suggested) ? { suggestedClass: suggested } : {},
83522
+ reason: parsed.data.reason
83523
+ });
83524
+ }
83525
+ return {
83526
+ kind: "parsed",
83527
+ objects,
83528
+ reviews,
83529
+ rejected
83530
+ };
83531
+ }
83532
+ //#endregion
83533
+ //#region src/pipeline-analytics/retrain/retrain-llm-service.ts
83534
+ /** The usage tag every AI-assist call is counted under (`llm.getUsage`). */
83535
+ var RETRAIN_LLM_CONSUMER = "retrain-assist";
83536
+ /** One frame, one answer: a cold local vision model can take a minute. */
83537
+ var RETRAIN_LLM_TIMEOUT_MS = 15e4;
83538
+ var RetrainLlmAssistService = class {
83539
+ deps;
83540
+ constructor(deps) {
83541
+ this.deps = deps;
83542
+ }
83543
+ /** The enabled, vision-capable profiles — a text-only one would be refused. */
83544
+ async listProfiles() {
83545
+ return (await this.deps.llm.listProfiles()).filter((p) => p.enabled && p.supportsVision).map((p) => ({
83546
+ id: p.id,
83547
+ name: p.name,
83548
+ model: p.model,
83549
+ kind: p.kind,
83550
+ leavesHouse: llmProfileLeavesHouse(p)
83551
+ }));
83552
+ }
83553
+ async propose(input) {
83554
+ const tags = { deviceId: input.deviceId };
83555
+ const frame = await this.deps.frames.get(input.frameId);
83556
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
83557
+ kind: "refused",
83558
+ reason: "unknown-frame"
83559
+ };
83560
+ const profile = (await this.deps.llm.listProfiles()).find((p) => p.id === input.profileId);
83561
+ if (profile === void 0) return {
83562
+ kind: "refused",
83563
+ reason: "unknown-profile"
83564
+ };
83565
+ if (!profile.enabled) return {
83566
+ kind: "refused",
83567
+ reason: "profile-disabled"
83568
+ };
83569
+ if (!profile.supportsVision) return {
83570
+ kind: "refused",
83571
+ reason: "no-vision"
83572
+ };
83573
+ const leavesHouse = llmProfileLeavesHouse(profile);
83574
+ if (leavesHouse && !input.allowCloud) {
83575
+ this.deps.logger.warn("retrain AI assist refused: a cloud profile without confirmation", {
83576
+ tags,
83577
+ meta: {
83578
+ frameId: input.frameId,
83579
+ profileId: profile.id
83580
+ }
83581
+ });
82435
83582
  return {
82436
- id: row.id,
82437
- name: typeof row.data["name"] === "string" ? row.data["name"] : "",
82438
- createdAt: Number(row.data["createdAt"] ?? 0),
82439
- frameCount: 0
83583
+ kind: "refused",
83584
+ reason: "cloud-not-confirmed",
83585
+ detail: "this profile sends the frame outside the house; confirm the run first"
82440
83586
  };
82441
- } catch (err) {
82442
- this.logger.warn("retrain collection findByNameKey failed", { meta: {
82443
- nameKey,
82444
- error: String(err)
82445
- } });
82446
- return null;
82447
83587
  }
83588
+ const bytes = await this.deps.frames.readFrame(frame);
83589
+ if (bytes === null) return {
83590
+ kind: "refused",
83591
+ reason: "unreadable-frame"
83592
+ };
83593
+ const request = buildLlmAssistRequest({
83594
+ classes: input.classes,
83595
+ width: frame.width,
83596
+ height: frame.height,
83597
+ boxes: input.boxes
83598
+ });
83599
+ const answer = await this.deps.llm.generateVision({
83600
+ profileId: profile.id,
83601
+ system: request.system,
83602
+ prompt: request.prompt,
83603
+ jsonSchema: request.jsonSchema,
83604
+ image: bytes
83605
+ });
83606
+ if (!answer.ok) {
83607
+ this.deps.logger.warn("retrain AI assist: the model call failed", {
83608
+ tags,
83609
+ meta: {
83610
+ frameId: input.frameId,
83611
+ profileId: profile.id,
83612
+ code: answer.code
83613
+ }
83614
+ });
83615
+ return {
83616
+ kind: "refused",
83617
+ reason: "llm-error",
83618
+ detail: `${answer.code}: ${answer.message}`
83619
+ };
83620
+ }
83621
+ const parsed = parseLlmAssistAnswer(answer.text, {
83622
+ classes: input.classes,
83623
+ refs: new Set(input.boxes.map((b) => b.ref))
83624
+ });
83625
+ if (parsed.kind === "malformed") {
83626
+ this.deps.logger.warn("retrain AI assist: the answer was refused as malformed", {
83627
+ tags,
83628
+ meta: {
83629
+ frameId: input.frameId,
83630
+ profileId: profile.id,
83631
+ model: answer.model,
83632
+ detail: parsed.detail,
83633
+ answerChars: answer.text.length
83634
+ }
83635
+ });
83636
+ return {
83637
+ kind: "refused",
83638
+ reason: "malformed-answer",
83639
+ detail: parsed.detail
83640
+ };
83641
+ }
83642
+ this.deps.logger.info("retrain AI assist proposed boxes", {
83643
+ tags,
83644
+ meta: {
83645
+ frameId: input.frameId,
83646
+ profileId: profile.id,
83647
+ model: answer.model,
83648
+ leftHouse: leavesHouse,
83649
+ proposals: parsed.objects.length,
83650
+ reviews: parsed.reviews.length,
83651
+ rejected: parsed.rejected
83652
+ }
83653
+ });
83654
+ return {
83655
+ kind: "proposed",
83656
+ profileId: profile.id,
83657
+ model: answer.model,
83658
+ leftHouse: leavesHouse,
83659
+ proposals: parsed.objects.map((o) => ({
83660
+ bbox: o.bbox,
83661
+ macroClass: o.macroClass,
83662
+ ...o.label !== void 0 ? { label: o.label } : {},
83663
+ kind: "subject",
83664
+ source: "llm",
83665
+ assistModelId: answer.model,
83666
+ assistScore: o.confidence,
83667
+ reason: o.reason
83668
+ })),
83669
+ reviews: parsed.reviews.map((r) => ({
83670
+ ref: r.ref,
83671
+ verdict: r.verdict,
83672
+ ...r.suggestedClass !== void 0 ? { suggestedClass: r.suggestedClass } : {},
83673
+ reason: r.reason
83674
+ })),
83675
+ rejected: parsed.rejected
83676
+ };
82448
83677
  }
82449
83678
  };
82450
83679
  //#endregion
83680
+ //#region src/pipeline-analytics/retrain/retrain-archive-upload.ts
83681
+ /**
83682
+ * The HTTP sink behind `pipelineAnalytics.writeRetrainArchive`: ONE request
83683
+ * whose body is the archive, written chunk by chunk with backpressure honoured
83684
+ * (D447) — the archive is never assembled in memory or on disk.
83685
+ *
83686
+ * The target is admin-supplied (the training orchestrator hands over a
83687
+ * provider's presigned URL or its upload route). Its headers may carry a
83688
+ * credential, so they are sent and never logged.
83689
+ */
83690
+ function openRetrainUploadSink(target) {
83691
+ const url = new URL(target.url);
83692
+ const req = (url.protocol === "https:" ? request$1 : request)(url, {
83693
+ method: target.method,
83694
+ headers: {
83695
+ "content-type": "application/x-tar",
83696
+ ...target.headers
83697
+ }
83698
+ });
83699
+ let failure = null;
83700
+ const answer = new Promise((resolve, reject) => {
83701
+ req.on("response", resolve);
83702
+ req.on("error", (err) => {
83703
+ failure = err;
83704
+ reject(err);
83705
+ });
83706
+ });
83707
+ answer.catch(() => void 0);
83708
+ return {
83709
+ sink: { write: (chunk) => new Promise((resolve, reject) => {
83710
+ if (failure !== null) {
83711
+ reject(failure);
83712
+ return;
83713
+ }
83714
+ if (req.write(chunk, (err) => {
83715
+ if (err) reject(err);
83716
+ })) resolve();
83717
+ else req.once("drain", resolve);
83718
+ }) },
83719
+ finish: async () => {
83720
+ req.end();
83721
+ const res = await answer;
83722
+ res.resume();
83723
+ const status = res.statusCode ?? 0;
83724
+ if (status < 200 || status >= 300) throw new Error(`retrain upload refused by the target: HTTP ${status}`);
83725
+ return status;
83726
+ },
83727
+ abort: (reason) => {
83728
+ req.destroy(new Error(reason));
83729
+ }
83730
+ };
83731
+ }
83732
+ //#endregion
83733
+ //#region src/pipeline-analytics/training-export/tar-stream.ts
83734
+ /**
83735
+ * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
83736
+ * entry, without ever holding the archive in memory.
83737
+ *
83738
+ * ## Why hand-rolled rather than the `tar` package
83739
+ *
83740
+ * `tar` is in the workspace and is the right tool for what it does: archive a
83741
+ * set of paths under one `cwd`. This is a different job. A track's media lives
83742
+ * across several storage LOCATION roots (`eventMedia` and whatever else the
83743
+ * operator relocated to), under paths that encode nothing a training set wants —
83744
+ * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
83745
+ * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
83746
+ * of paths relative to a single cwd and gives no rename hook, so using it would
83747
+ * mean staging a whole shadow tree on disk first: a full second copy of the
83748
+ * dataset, written and then deleted, to avoid writing 100 lines.
83749
+ *
83750
+ * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
83751
+ * fully specified, and asserted against the system `tar` binary in the spec next
83752
+ * door rather than against my reading of the format.
83753
+ *
83754
+ * ## What this deliberately does NOT do
83755
+ *
83756
+ * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
83757
+ * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
83758
+ * splits a path across a 155-byte prefix and a 100-byte name, and
83759
+ * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
83760
+ * it and SAY it skipped it, rather than emitting a silently truncated entry.
83761
+ */
83762
+ var BLOCK_SIZE = 512;
83763
+ var NAME_MAX = 100;
83764
+ var PREFIX_MAX = 155;
83765
+ /**
83766
+ * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
83767
+ * be represented. The split must land on a `/`, and each half has its own cap.
83768
+ */
83769
+ function splitUstarName(name) {
83770
+ if (Buffer.byteLength(name) <= NAME_MAX) return {
83771
+ prefix: "",
83772
+ name
83773
+ };
83774
+ for (let i = name.length - 1; i >= 0; i--) {
83775
+ if (name[i] !== "/") continue;
83776
+ const head = name.slice(0, i);
83777
+ const tail = name.slice(i + 1);
83778
+ if (Buffer.byteLength(tail) > NAME_MAX) break;
83779
+ if (Buffer.byteLength(head) <= PREFIX_MAX) return {
83780
+ prefix: head,
83781
+ name: tail
83782
+ };
83783
+ }
83784
+ return null;
83785
+ }
83786
+ /** True when a path cannot be represented as a ustar entry name. */
83787
+ function tarNameTooLong(name) {
83788
+ return splitUstarName(name) === null;
83789
+ }
83790
+ function writeString(block, value, offset, length) {
83791
+ block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
83792
+ }
83793
+ /** ustar numeric field: zero-padded octal, NUL-terminated. */
83794
+ function writeOctal(block, value, offset, length) {
83795
+ const text = Math.floor(value).toString(8).padStart(length - 1, "0");
83796
+ block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
83797
+ }
83798
+ /**
83799
+ * The 512-byte header for one regular file.
83800
+ *
83801
+ * The checksum is computed with the checksum field itself read as eight spaces —
83802
+ * that is the format's rule, and getting it wrong produces an archive that looks
83803
+ * fine until something tries to read it.
83804
+ */
83805
+ function tarHeader(entry) {
83806
+ const split = splitUstarName(entry.name);
83807
+ if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
83808
+ const block = Buffer.alloc(BLOCK_SIZE);
83809
+ writeString(block, split.name, 0, NAME_MAX);
83810
+ writeOctal(block, 420, 100, 8);
83811
+ writeOctal(block, 0, 108, 8);
83812
+ writeOctal(block, 0, 116, 8);
83813
+ writeOctal(block, entry.body.length, 124, 12);
83814
+ writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
83815
+ block.write(" ", 148, 8, "ascii");
83816
+ block.write("0", 156, 1, "ascii");
83817
+ block.write("ustar\0", 257, 6, "ascii");
83818
+ block.write("00", 263, 2, "ascii");
83819
+ writeString(block, split.prefix, 345, PREFIX_MAX);
83820
+ let sum = 0;
83821
+ for (const byte of block) sum += byte;
83822
+ writeOctal(block, sum, 148, 7);
83823
+ block.write("\0", 154, 1, "ascii");
83824
+ block.write(" ", 155, 1, "ascii");
83825
+ return block;
83826
+ }
83827
+ /** Zero padding that rounds a body up to the 512-byte block. */
83828
+ function tarPadding(size) {
83829
+ const remainder = size % BLOCK_SIZE;
83830
+ return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
83831
+ }
83832
+ /** Header + body + padding for one entry, ready to write. */
83833
+ function tarBlocks(entry) {
83834
+ return [
83835
+ tarHeader(entry),
83836
+ entry.body,
83837
+ tarPadding(entry.body.length)
83838
+ ];
83839
+ }
83840
+ /** The two zero blocks that terminate an archive. */
83841
+ function tarTrailer() {
83842
+ return Buffer.alloc(BLOCK_SIZE * 2);
83843
+ }
83844
+ //#endregion
83845
+ //#region src/pipeline-analytics/retrain/retrain-archive-writer.ts
83846
+ /**
83847
+ * The annotated archive body, written to ANY sink.
83848
+ *
83849
+ * One body, two consumers: the data-plane download (`retrain-export-handler`,
83850
+ * sink = the HTTP response) and `pipelineAnalytics.writeRetrainArchive` (sink
83851
+ * = one streamed HTTP upload to a training provider). The disciplines are the
83852
+ * training export's: the manifest is the first entry, one JPEG is in memory at
83853
+ * a time, a pause between files keeps the hub's one thread available, and the
83854
+ * batch id is stamped only on what the sink actually received.
83855
+ */
83856
+ var RETRAIN_MANIFEST_ENTRY = "annotations.json";
83857
+ /** Pause between files so a large export cannot monopolise the hub's thread. */
83858
+ var RETRAIN_ARCHIVE_PACING_MS = 25;
83859
+ async function writeRetrainArchiveTo(deps) {
83860
+ const { plan, logger } = deps;
83861
+ const pacingMs = deps.pacingMs ?? RETRAIN_ARCHIVE_PACING_MS;
83862
+ const hash = createHash("sha256");
83863
+ let bytes = 0;
83864
+ const write = async (chunk) => {
83865
+ hash.update(chunk);
83866
+ bytes += chunk.length;
83867
+ await deps.sink.write(chunk);
83868
+ };
83869
+ const writeEntry = async (name, body, mtimeMs) => {
83870
+ for (const block of tarBlocks({
83871
+ name,
83872
+ body,
83873
+ mtimeMs
83874
+ })) await write(block);
83875
+ };
83876
+ const mtimeMs = deps.now();
83877
+ await writeEntry(RETRAIN_MANIFEST_ENTRY, Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"), mtimeMs);
83878
+ const byId = new Map(plan.manifest.frames.map((f) => [f.frameId, f]));
83879
+ const shipped = [];
83880
+ let missing = 0;
83881
+ let crops = 0;
83882
+ for (const frame of plan.frames) {
83883
+ const entry = byId.get(frame.frameId);
83884
+ if (entry === void 0) continue;
83885
+ if (tarNameTooLong(entry.path)) {
83886
+ missing += 1;
83887
+ logger.warn("retrain export skipped a frame — path too long for the archive", {
83888
+ tags: { deviceId: frame.deviceId },
83889
+ meta: {
83890
+ trackId: frame.trackId,
83891
+ frameId: frame.frameId,
83892
+ path: entry.path
83893
+ }
83894
+ });
83895
+ continue;
83896
+ }
83897
+ const frameBytes = await deps.readFrame(frame);
83898
+ if (frameBytes === null) {
83899
+ missing += 1;
83900
+ logger.warn("retrain export skipped a frame — the COPY is unreadable", {
83901
+ tags: { deviceId: frame.deviceId },
83902
+ meta: {
83903
+ trackId: frame.trackId,
83904
+ frameId: frame.frameId
83905
+ }
83906
+ });
83907
+ continue;
83908
+ }
83909
+ await writeEntry(entry.path, frameBytes, mtimeMs);
83910
+ for (const annotation of entry.annotations) {
83911
+ if (annotation.cropPath === void 0) continue;
83912
+ const crop = deps.cutCrop ? await deps.cutCrop(frameBytes, frame, annotation) : null;
83913
+ if (crop === null) {
83914
+ logger.warn("retrain export skipped a crop — it could not be cut", {
83915
+ tags: { deviceId: frame.deviceId },
83916
+ meta: {
83917
+ frameId: frame.frameId,
83918
+ annotationId: annotation.id,
83919
+ cropPath: annotation.cropPath
83920
+ }
83921
+ });
83922
+ continue;
83923
+ }
83924
+ await writeEntry(annotation.cropPath, crop, mtimeMs);
83925
+ crops += 1;
83926
+ }
83927
+ shipped.push(frame.frameId);
83928
+ if (pacingMs > 0) await new Promise((resolve) => setTimeout(resolve, pacingMs));
83929
+ }
83930
+ await write(tarTrailer());
83931
+ await deps.markExported(shipped, plan.manifest.batchId);
83932
+ const stats = {
83933
+ batchId: plan.manifest.batchId,
83934
+ frames: shipped.length,
83935
+ crops,
83936
+ annotations: plan.manifest.annotationCount,
83937
+ missing,
83938
+ bytes,
83939
+ sha256: hash.digest("hex")
83940
+ };
83941
+ logger.info("retrain export complete", { meta: {
83942
+ ...stats,
83943
+ layout: plan.manifest.layout,
83944
+ modelErrors: plan.manifest.modelErrorCount
83945
+ } });
83946
+ return stats;
83947
+ }
83948
+ //#endregion
83949
+ //#region src/pipeline-analytics/retrain/retrain-crop-cut.ts
83950
+ /**
83951
+ * The `crops` layout's cutter: one subject crop from a retrain frame copy.
83952
+ *
83953
+ * A classifier must be trained on the crop the runtime feeds it, so the
83954
+ * rectangle comes from THE detail-crop derivation (`deriveDetailCropRect`,
83955
+ * `types/src/pipeline/detail-crop.ts`) with the convention the manifest
83956
+ * records — never a second rectangle maths. This is the second permitted
83957
+ * caller of that derivation (`scripts/check-clip-crop-single-owner.ts`): it
83958
+ * writes training files, never a vector, so it cannot split a feature space;
83959
+ * what it must not do is cut differently, and calling the one function is how
83960
+ * it doesn't.
83961
+ */
83962
+ /** The pixel window to extract, or `null` when nothing of it is inside the frame. */
83963
+ function retrainCropWindow(input) {
83964
+ const { frameWidth: fw, frameHeight: fh, bbox } = input;
83965
+ const rect = deriveDetailCropRect({
83966
+ x: bbox.x * fw,
83967
+ y: bbox.y * fh,
83968
+ w: bbox.w * fw,
83969
+ h: bbox.h * fh
83970
+ }, fw, fh, input.convention);
83971
+ const left = Math.max(0, Math.floor(rect.x));
83972
+ const top = Math.max(0, Math.floor(rect.y));
83973
+ const right = Math.min(fw, Math.ceil(rect.x + rect.w));
83974
+ const bottom = Math.min(fh, Math.ceil(rect.y + rect.h));
83975
+ if (right - left < 1 || bottom - top < 1) return null;
83976
+ return {
83977
+ left,
83978
+ top,
83979
+ width: right - left,
83980
+ height: bottom - top
83981
+ };
83982
+ }
83983
+ async function cutRetrainCrop(input) {
83984
+ const window = retrainCropWindow(input);
83985
+ if (window === null) return null;
83986
+ let pipeline = sharp(input.frameBytes).extract(window);
83987
+ if (input.maxSide > 0) pipeline = pipeline.resize({
83988
+ width: input.maxSide,
83989
+ height: input.maxSide,
83990
+ fit: "inside",
83991
+ withoutEnlargement: true
83992
+ });
83993
+ return pipeline.jpeg({ quality: 92 }).toBuffer();
83994
+ }
83995
+ //#endregion
82451
83996
  //#region src/pipeline-analytics/retrain/retrain-geometry.ts
82452
83997
  function clamp01(v) {
82453
83998
  return v < 0 ? 0 : v > 1 ? 1 : v;
@@ -82543,14 +84088,106 @@ function deriveSubjectCropRect(bbox, padding) {
82543
84088
  };
82544
84089
  }
82545
84090
  //#endregion
84091
+ //#region src/pipeline-analytics/retrain/retrain-lighting.ts
84092
+ function isNight(condition) {
84093
+ return condition === "night" || condition === "ir";
84094
+ }
84095
+ function retrainFrameLighting(input) {
84096
+ if (input.capturedAt === void 0) {
84097
+ const measured = resolveConditionDetailed({
84098
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
84099
+ now: /* @__PURE__ */ new Date(0)
84100
+ });
84101
+ return measured.source === "ir-chroma" ? {
84102
+ condition: measured.condition,
84103
+ night: true,
84104
+ source: measured.source
84105
+ } : null;
84106
+ }
84107
+ const resolved = resolveConditionDetailed({
84108
+ ...input.chromaSpread !== void 0 ? { chromaSpread: input.chromaSpread } : {},
84109
+ ...input.coordinates !== null ? { coordinates: input.coordinates } : {},
84110
+ now: new Date(input.capturedAt)
84111
+ });
84112
+ return {
84113
+ ...resolved,
84114
+ night: isNight(resolved.condition)
84115
+ };
84116
+ }
84117
+ //#endregion
82546
84118
  //#region src/pipeline-analytics/retrain/retrain-export.ts
84119
+ /**
84120
+ * The ANNOTATED dataset export — the retrain page's own archive.
84121
+ *
84122
+ * Sibling of `training-export/`, deliberately not the same thing. That one
84123
+ * streams a marked track's stored media verbatim and knows nothing about what
84124
+ * is in it; this one streams the frames the operator SELECTED (already copied
84125
+ * into retrain storage) plus the ground truth they drew.
84126
+ *
84127
+ * ## The one interesting decision
84128
+ *
84129
+ * `annotations.json` carries, for every subject, the canonical full-frame box
84130
+ * AND the geometry derived for each model shape. **Derived here, at export,
84131
+ * never stored** — one box in, three shapes out. Storing the derived shapes is
84132
+ * how two crops of the same subject end up in one feature space, which is the
84133
+ * failure already recorded for CLIP (D52). A consumer that wants a different
84134
+ * input size or a different padding re-derives from the canonical box; nothing
84135
+ * in the database has to change and nothing can disagree.
84136
+ *
84137
+ * Everything else follows the export next door: index reads to plan, one JPEG
84138
+ * in memory at a time, a yield between files, and NO inference — a training
84139
+ * set's pixels are the pixels the pipeline saw.
84140
+ */
82547
84141
  var RETRAIN_EXPORT_PREFIX = "retrain-export";
82548
- /** Padding a subject-crop classifier is trained with. One number, one place. */
82549
- var CLASSIFIER_CROP_PADDING = .2;
82550
- /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`. */
84142
+ /**
84143
+ * Padding a subject-crop classifier is trained with: the RUNTIME detail-crop
84144
+ * padding, read from the one convention `deriveDetailCropRect` cuts with
84145
+ * (`types/src/pipeline/detail-crop.ts`), never a second literal. It was `0.2`
84146
+ * here while the runtime fed `0.15`, so a classifier trained from this export
84147
+ * learned on more context than it is ever given live.
84148
+ */
84149
+ var CLASSIFIER_CROP_PADDING = DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio;
84150
+ /** The convention a `crops` layout cuts with — the runtime default, recorded
84151
+ * in the manifest so a consumer never has to assume it. */
84152
+ var RETRAIN_CROP_CONVENTION = DEFAULT_DETAIL_CROP_CONVENTION;
84153
+ /** Longest side of a `crops`-layout JPEG. Classifier inputs are 224–260 px;
84154
+ * the trainer resizes per model, so this only bounds the upload. */
84155
+ var RETRAIN_CROP_MAX_SIDE = 256;
84156
+ /**
84157
+ * Manifest schema id. v2 = capturedAt / complete / lighting / layout.
84158
+ * v3 = `origin` (`track` | `recording`), `epochMs` on a recording still,
84159
+ * `collectionIds`, and `trackId` ABSENT on a recording still, which has none.
84160
+ */
84161
+ var RETRAIN_MANIFEST_SCHEMA = "camstack.retrain-export/v3";
84162
+ /** Archive path of a frame: `frames/<deviceId>/<trackId>/<frameId leaf>.jpg`,
84163
+ * or `frames/<deviceId>/recording/<leaf>.jpg` for a recording still. */
82551
84164
  function retrainFramePath(frame) {
82552
84165
  const leaf = frame.frameId.slice(frame.frameId.lastIndexOf(":") + 1);
82553
- return `frames/${frame.deviceId}/${frame.trackId}/${leaf}.jpg`;
84166
+ const dir = frame.origin === "recording" ? RECORDING_STILL_DIR : frame.trackId;
84167
+ return `frames/${frame.deviceId}/${dir}/${leaf}.jpg`;
84168
+ }
84169
+ /** Archive path of a subject crop: `crops/<macroClass>/<annotationId>.jpg`. */
84170
+ function retrainCropPath(macroClass, annotationId) {
84171
+ return `crops/${macroClass}/${annotationId}.jpg`;
84172
+ }
84173
+ /**
84174
+ * Narrow the planned frames to a filter. Pure. Every field narrows; an empty
84175
+ * filter keeps everything. Order is the input's.
84176
+ */
84177
+ function filterRetrainExport(frames, annotationsByFrame, filter, collectionsByFrame = /* @__PURE__ */ new Map()) {
84178
+ const wanted = filter.frameIds !== void 0 ? new Set(filter.frameIds) : null;
84179
+ const collections = filter.collectionIds !== void 0 && filter.collectionIds.length > 0 ? new Set(filter.collectionIds) : null;
84180
+ const devices = filter.deviceIds !== void 0 && filter.deviceIds.length > 0 ? new Set(filter.deviceIds) : null;
84181
+ return frames.filter((frame) => {
84182
+ if (wanted !== null && !wanted.has(frame.frameId)) return false;
84183
+ if (devices !== null && !devices.has(frame.deviceId)) return false;
84184
+ if (filter.completeOnly === true && !frame.complete) return false;
84185
+ if (collections !== null && !(collectionsByFrame.get(frame.frameId) ?? []).some((id) => collections.has(id))) return false;
84186
+ if (filter.batchId !== void 0) {
84187
+ if (!(annotationsByFrame.get(frame.frameId) ?? []).some((a) => a.exportedInBatch === filter.batchId)) return false;
84188
+ }
84189
+ return true;
84190
+ });
82554
84191
  }
82555
84192
  /**
82556
84193
  * Derive every per-model shape for one annotation.
@@ -82572,6 +84209,8 @@ function deriveAll(annotation, frame, zone) {
82572
84209
  }
82573
84210
  /** Build the manifest. Pure — index rows in, JSON out, no blob touched. */
82574
84211
  function buildRetrainManifest(input) {
84212
+ const layout = input.layout ?? "frames";
84213
+ const coordinates = input.coordinates ?? null;
82575
84214
  let annotationCount = 0;
82576
84215
  let modelErrorCount = 0;
82577
84216
  const frames = input.frames.map((frame) => {
@@ -82579,6 +84218,7 @@ function buildRetrainManifest(input) {
82579
84218
  const annotations = (input.annotationsByFrame.get(frame.frameId) ?? []).map((a) => {
82580
84219
  annotationCount += 1;
82581
84220
  if (a.kind === "model_error") modelErrorCount += 1;
84221
+ const derived = deriveAll(a, frame, zone);
82582
84222
  return {
82583
84223
  id: a.id,
82584
84224
  macroClass: a.macroClass,
@@ -82589,24 +84229,39 @@ function buildRetrainManifest(input) {
82589
84229
  ...a.assistModelId !== void 0 ? { assistModelId: a.assistModelId } : {},
82590
84230
  ...a.assistScore !== void 0 ? { assistScore: a.assistScore } : {},
82591
84231
  bbox: a.bbox,
82592
- derived: deriveAll(a, frame, zone)
84232
+ ...layout === "crops" && derived.subjectCrop !== void 0 ? { cropPath: retrainCropPath(a.macroClass, a.id) } : {},
84233
+ derived
82593
84234
  };
82594
84235
  });
82595
84236
  return {
82596
84237
  frameId: frame.frameId,
82597
84238
  path: retrainFramePath(frame),
82598
84239
  deviceId: frame.deviceId,
82599
- trackId: frame.trackId,
84240
+ origin: frame.origin,
84241
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
84242
+ ...frame.epochMs !== void 0 ? { epochMs: frame.epochMs } : {},
84243
+ collectionIds: input.collectionsByFrame?.get(frame.frameId) ?? [],
82600
84244
  width: frame.width,
82601
84245
  height: frame.height,
82602
84246
  sizeBytes: frame.sizeBytes,
82603
84247
  sourceKind: frame.sourceKind,
82604
84248
  copiedAt: frame.copiedAt,
84249
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
84250
+ complete: frame.complete,
84251
+ lighting: retrainFrameLighting({
84252
+ ...frame.capturedAt !== void 0 ? { capturedAt: frame.capturedAt } : {},
84253
+ ...frame.chromaSpread !== void 0 ? { chromaSpread: frame.chromaSpread } : {},
84254
+ coordinates
84255
+ }),
82605
84256
  ...zone !== null ? { zoneUsed: zone } : {},
82606
84257
  annotations
82607
84258
  };
82608
84259
  });
82609
84260
  return {
84261
+ schema: RETRAIN_MANIFEST_SCHEMA,
84262
+ layout,
84263
+ detailCropConvention: RETRAIN_CROP_CONVENTION,
84264
+ ...layout === "crops" ? { cropMaxSide: RETRAIN_CROP_MAX_SIDE } : {},
82610
84265
  generatedAt: input.generatedAt,
82611
84266
  batchId: input.batchId,
82612
84267
  frameCount: frames.length,
@@ -82617,123 +84272,39 @@ function buildRetrainManifest(input) {
82617
84272
  };
82618
84273
  }
82619
84274
  //#endregion
82620
- //#region src/pipeline-analytics/training-export/tar-stream.ts
82621
- /**
82622
- * A minimal POSIX ustar writer — enough to emit a dataset archive entry by
82623
- * entry, without ever holding the archive in memory.
82624
- *
82625
- * ## Why hand-rolled rather than the `tar` package
82626
- *
82627
- * `tar` is in the workspace and is the right tool for what it does: archive a
82628
- * set of paths under one `cwd`. This is a different job. A track's media lives
82629
- * across several storage LOCATION roots (`eventMedia` and whatever else the
82630
- * operator relocated to), under paths that encode nothing a training set wants —
82631
- * `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
82632
- * the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
82633
- * of paths relative to a single cwd and gives no rename hook, so using it would
82634
- * mean staging a whole shadow tree on disk first: a full second copy of the
82635
- * dataset, written and then deleted, to avoid writing 100 lines.
82636
- *
82637
- * So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
82638
- * fully specified, and asserted against the system `tar` binary in the spec next
82639
- * door rather than against my reading of the format.
82640
- *
82641
- * ## What this deliberately does NOT do
82642
- *
82643
- * No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
82644
- * on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
82645
- * splits a path across a 155-byte prefix and a 100-byte name, and
82646
- * {@link tarNameTooLong} reports a path that will not fit so the caller can skip
82647
- * it and SAY it skipped it, rather than emitting a silently truncated entry.
82648
- */
82649
- var BLOCK_SIZE = 512;
82650
- var NAME_MAX = 100;
82651
- var PREFIX_MAX = 155;
82652
- /**
82653
- * Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
82654
- * be represented. The split must land on a `/`, and each half has its own cap.
82655
- */
82656
- function splitUstarName(name) {
82657
- if (Buffer.byteLength(name) <= NAME_MAX) return {
82658
- prefix: "",
82659
- name
82660
- };
82661
- for (let i = name.length - 1; i >= 0; i--) {
82662
- if (name[i] !== "/") continue;
82663
- const head = name.slice(0, i);
82664
- const tail = name.slice(i + 1);
82665
- if (Buffer.byteLength(tail) > NAME_MAX) break;
82666
- if (Buffer.byteLength(head) <= PREFIX_MAX) return {
82667
- prefix: head,
82668
- name: tail
82669
- };
82670
- }
82671
- return null;
82672
- }
82673
- /** True when a path cannot be represented as a ustar entry name. */
82674
- function tarNameTooLong(name) {
82675
- return splitUstarName(name) === null;
82676
- }
82677
- function writeString(block, value, offset, length) {
82678
- block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
82679
- }
82680
- /** ustar numeric field: zero-padded octal, NUL-terminated. */
82681
- function writeOctal(block, value, offset, length) {
82682
- const text = Math.floor(value).toString(8).padStart(length - 1, "0");
82683
- block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
82684
- }
82685
- /**
82686
- * The 512-byte header for one regular file.
82687
- *
82688
- * The checksum is computed with the checksum field itself read as eight spaces —
82689
- * that is the format's rule, and getting it wrong produces an archive that looks
82690
- * fine until something tries to read it.
82691
- */
82692
- function tarHeader(entry) {
82693
- const split = splitUstarName(entry.name);
82694
- if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
82695
- const block = Buffer.alloc(BLOCK_SIZE);
82696
- writeString(block, split.name, 0, NAME_MAX);
82697
- writeOctal(block, 420, 100, 8);
82698
- writeOctal(block, 0, 108, 8);
82699
- writeOctal(block, 0, 116, 8);
82700
- writeOctal(block, entry.body.length, 124, 12);
82701
- writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
82702
- block.write(" ", 148, 8, "ascii");
82703
- block.write("0", 156, 1, "ascii");
82704
- block.write("ustar\0", 257, 6, "ascii");
82705
- block.write("00", 263, 2, "ascii");
82706
- writeString(block, split.prefix, 345, PREFIX_MAX);
82707
- let sum = 0;
82708
- for (const byte of block) sum += byte;
82709
- writeOctal(block, sum, 148, 7);
82710
- block.write("\0", 154, 1, "ascii");
82711
- block.write(" ", 155, 1, "ascii");
82712
- return block;
82713
- }
82714
- /** Zero padding that rounds a body up to the 512-byte block. */
82715
- function tarPadding(size) {
82716
- const remainder = size % BLOCK_SIZE;
82717
- return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
82718
- }
82719
- /** Header + body + padding for one entry, ready to write. */
82720
- function tarBlocks(entry) {
82721
- return [
82722
- tarHeader(entry),
82723
- entry.body,
82724
- tarPadding(entry.body.length)
82725
- ];
82726
- }
82727
- /** The two zero blocks that terminate an archive. */
82728
- function tarTrailer() {
82729
- return Buffer.alloc(BLOCK_SIZE * 2);
82730
- }
82731
- //#endregion
82732
84275
  //#region src/pipeline-analytics/retrain/retrain-export-handler.ts
82733
- var RETRAIN_MANIFEST_ENTRY = "annotations.json";
82734
- /** Yield between files so a large export cannot monopolise the hub's one
82735
- * thread — the same discipline the training export follows. */
82736
- var PACING_MS = 25;
84276
+ /** The export request a URL carries (`getRetrainExportUrl` composes it). */
84277
+ function parseRetrainExportRequest(url) {
84278
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
84279
+ const params = new URLSearchParams(query);
84280
+ const deviceIds = parseRetrainDeviceIds(url);
84281
+ const frameIds = (params.get("frameIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84282
+ const collectionIds = (params.get("collectionIds") ?? "").split(",").map((id) => id.trim()).filter((id) => id.length > 0);
84283
+ const batchId = params.get("batchId")?.trim() ?? "";
84284
+ return {
84285
+ filter: {
84286
+ ...deviceIds.length > 0 ? { deviceIds: [...deviceIds] } : {},
84287
+ ...frameIds.length > 0 ? { frameIds } : {},
84288
+ ...collectionIds.length > 0 ? { collectionIds } : {},
84289
+ ...batchId.length > 0 ? { batchId } : {},
84290
+ ...params.get("completeOnly") === "1" ? { completeOnly: true } : {}
84291
+ },
84292
+ layout: params.get("layout") === "crops" ? "crops" : "frames"
84293
+ };
84294
+ }
84295
+ /** The query string `parseRetrainExportRequest` reads back. */
84296
+ function retrainExportQuery(request) {
84297
+ const params = new URLSearchParams();
84298
+ const { filter } = request;
84299
+ if (filter.deviceIds !== void 0 && filter.deviceIds.length > 0) params.set("deviceIds", filter.deviceIds.join(","));
84300
+ if (filter.frameIds !== void 0 && filter.frameIds.length > 0) params.set("frameIds", filter.frameIds.join(","));
84301
+ if (filter.collectionIds !== void 0 && filter.collectionIds.length > 0) params.set("collectionIds", filter.collectionIds.join(","));
84302
+ if (filter.batchId !== void 0) params.set("batchId", filter.batchId);
84303
+ if (filter.completeOnly === true) params.set("completeOnly", "1");
84304
+ if (request.layout === "crops") params.set("layout", "crops");
84305
+ const text = params.toString();
84306
+ return text.length > 0 ? `?${text}` : "";
84307
+ }
82737
84308
  function parseRetrainDeviceIds(url) {
82738
84309
  const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
82739
84310
  const raw = new URLSearchParams(query).get("deviceIds");
@@ -82756,10 +84327,10 @@ function createRetrainExportHandler(deps) {
82756
84327
  res.end();
82757
84328
  return;
82758
84329
  }
82759
- const deviceIds = parseRetrainDeviceIds(req.url ?? "");
84330
+ const request = parseRetrainExportRequest(req.url ?? "");
82760
84331
  let plan;
82761
84332
  try {
82762
- plan = await deps.buildPlan(deviceIds);
84333
+ plan = await deps.buildPlan(request);
82763
84334
  } catch (err) {
82764
84335
  deps.logger.warn("retrain export plan failed", { meta: { error: String(err) } });
82765
84336
  res.writeHead(500, { "content-type": "text/plain" });
@@ -82773,7 +84344,8 @@ function createRetrainExportHandler(deps) {
82773
84344
  "x-camstack-retrain-frames": String(plan.manifest.frameCount),
82774
84345
  "x-camstack-retrain-annotations": String(plan.manifest.annotationCount),
82775
84346
  "x-camstack-retrain-model-errors": String(plan.manifest.modelErrorCount),
82776
- "x-camstack-retrain-batch": plan.manifest.batchId
84347
+ "x-camstack-retrain-batch": plan.manifest.batchId,
84348
+ "x-camstack-retrain-layout": plan.manifest.layout
82777
84349
  });
82778
84350
  if (method === "HEAD") {
82779
84351
  res.end();
@@ -82786,64 +84358,193 @@ function createRetrainExportHandler(deps) {
82786
84358
  });
82787
84359
  if (!ok) res.once("drain", resolve);
82788
84360
  });
82789
- const mtimeMs = deps.now();
82790
- for (const block of tarBlocks({
82791
- name: RETRAIN_MANIFEST_ENTRY,
82792
- body: Buffer.from(JSON.stringify(plan.manifest, null, 2), "utf8"),
82793
- mtimeMs
82794
- })) await write(block);
82795
- const shipped = [];
82796
- let missing = 0;
82797
- for (const frame of plan.frames) {
82798
- const entry = plan.manifest.frames.find((f) => f.frameId === frame.frameId);
82799
- if (entry === void 0) continue;
82800
- if (tarNameTooLong(entry.path)) {
82801
- missing += 1;
82802
- deps.logger.warn("retrain export skipped a frame — path too long for the archive", {
82803
- tags: { deviceId: frame.deviceId },
82804
- meta: {
82805
- trackId: frame.trackId,
82806
- frameId: frame.frameId,
82807
- path: entry.path
82808
- }
82809
- });
82810
- continue;
82811
- }
82812
- const bytes = await deps.readFrame(frame);
82813
- if (bytes === null) {
82814
- missing += 1;
82815
- deps.logger.warn("retrain export skipped a frame — the COPY is unreadable", {
82816
- tags: { deviceId: frame.deviceId },
82817
- meta: {
82818
- trackId: frame.trackId,
82819
- frameId: frame.frameId
82820
- }
82821
- });
82822
- continue;
82823
- }
82824
- for (const block of tarBlocks({
82825
- name: entry.path,
82826
- body: bytes,
82827
- mtimeMs
82828
- })) await write(block);
82829
- shipped.push(frame.frameId);
82830
- await new Promise((resolve) => setTimeout(resolve, PACING_MS));
82831
- }
82832
- await write(tarTrailer());
84361
+ await writeRetrainArchiveTo({
84362
+ plan,
84363
+ readFrame: deps.readFrame,
84364
+ cutCrop: deps.cutCrop,
84365
+ sink: { write },
84366
+ markExported: deps.markExported,
84367
+ logger: deps.logger,
84368
+ now: deps.now
84369
+ });
82833
84370
  await new Promise((resolve) => {
82834
84371
  res.end(resolve);
82835
84372
  });
82836
- await deps.markExported(shipped, plan.manifest.batchId);
82837
- deps.logger.info("retrain export complete", { meta: {
82838
- batchId: plan.manifest.batchId,
82839
- frames: shipped.length,
82840
- missing,
82841
- annotations: plan.manifest.annotationCount,
82842
- modelErrors: plan.manifest.modelErrorCount
82843
- } });
82844
84373
  };
82845
84374
  }
82846
84375
  //#endregion
84376
+ //#region src/pipeline-analytics/retrain/retrain-pipeline-boxes.ts
84377
+ /**
84378
+ * The boxes the PIPELINE drew on a copied frame — Step 1 of the training
84379
+ * screen.
84380
+ *
84381
+ * A correction has to start from the decision being corrected. Proposing with
84382
+ * a fresh model run answers a different question ("what would a model say
84383
+ * now"), and the frame the operator is fixing is precisely the one where the
84384
+ * live answer was wrong — a re-run that happens to be right hides the phantom
84385
+ * the operator came to remove. So the canvas opens with what the track store
84386
+ * says the pipeline had on that frame, and the operator accepts, corrects,
84387
+ * relabels or deletes it.
84388
+ *
84389
+ * ## Where a box comes from, best first
84390
+ *
84391
+ * 1. **The subject box recorded ON the source media row** (D510). Exact by
84392
+ * construction: the box was measured on the frame the blob shows. Only the
84393
+ * copy's own track can have one.
84394
+ * 2. **The track position nearest the capture time**, within
84395
+ * {@link PIPELINE_BOX_TOLERANCE_MS}. Positions are frame-rate, so the
84396
+ * nearest one is normally the same detection; the delta is reported so a
84397
+ * reviewer can see how far it was.
84398
+ *
84399
+ * Every OTHER track alive on the camera at that instant contributes its own
84400
+ * nearest position the same way — a frame with three people is three boxes,
84401
+ * and a recording still (no track of its own) gets everything the pipeline
84402
+ * was tracking at that moment, which is exactly what a missed-object review
84403
+ * needs to see is NOT there.
84404
+ *
84405
+ * Nothing here does I/O. The caller reads the tracks and the subject box.
84406
+ */
84407
+ /** How far from the capture time a position may be and still be "this frame".
84408
+ * Positions are recorded at the analysis rate (several per second), so half
84409
+ * a second is a generous match and still not a different moment. */
84410
+ var PIPELINE_BOX_TOLERANCE_MS = 500;
84411
+ /**
84412
+ * How far back a track may have STARTED and still be alive at the frame. The
84413
+ * track store windows on `firstSeen`, so a track born before this is not
84414
+ * asked about — a vehicle parked for hours is the case it misses, and the
84415
+ * operator draws that box by hand.
84416
+ */
84417
+ var PIPELINE_TRACK_LOOKBACK_MS = 30 * 6e4;
84418
+ /** At most this many tracks of one camera are read around one frame. */
84419
+ var PIPELINE_TRACK_AROUND_LIMIT = 200;
84420
+ /** The least-wrong macro tier for a class the macro map does not know. The
84421
+ * operator corrects it; what must not happen is the raw string being
84422
+ * promoted into the macro tier. */
84423
+ var FALLBACK_MACRO_CLASS = "person";
84424
+ /**
84425
+ * Split a detector's class into the two tiers.
84426
+ *
84427
+ * A macro class stays in the macro tier; anything else maps through the COCO
84428
+ * macro map, and the class itself becomes the LABEL. A macro word is never a
84429
+ * label, in any casing — the tier rule the annotation write enforces.
84430
+ */
84431
+ function toRetrainClassTiers(className, label) {
84432
+ const cls = className.trim().toLowerCase();
84433
+ const usableLabel = (value) => {
84434
+ const trimmed = value?.trim();
84435
+ if (trimmed === void 0 || trimmed === "") return void 0;
84436
+ return isRetrainMacroClass(trimmed.toLowerCase()) ? void 0 : trimmed;
84437
+ };
84438
+ if (isRetrainMacroClass(cls)) {
84439
+ const kept = usableLabel(label);
84440
+ return kept !== void 0 ? {
84441
+ macroClass: cls,
84442
+ label: kept
84443
+ } : { macroClass: cls };
84444
+ }
84445
+ const mapped = COCO_TO_MACRO.mapping[cls];
84446
+ const macroClass = mapped !== void 0 && isRetrainMacroClass(mapped) ? mapped : FALLBACK_MACRO_CLASS;
84447
+ const kept = usableLabel(className);
84448
+ return kept !== void 0 ? {
84449
+ macroClass,
84450
+ label: kept
84451
+ } : { macroClass };
84452
+ }
84453
+ /** A pixel box as a clamped normalised one, or `null` when nothing is left. */
84454
+ function normalise(box, frameWidth, frameHeight) {
84455
+ if (frameWidth === void 0 || frameHeight === void 0) return null;
84456
+ if (!(frameWidth > 0) || !(frameHeight > 0)) return null;
84457
+ const x = clampSpan(box.x, box.w, frameWidth);
84458
+ const y = clampSpan(box.y, box.h, frameHeight);
84459
+ if (x === null || y === null) return null;
84460
+ return {
84461
+ x: x.start,
84462
+ y: y.start,
84463
+ w: x.size,
84464
+ h: y.size
84465
+ };
84466
+ }
84467
+ /** One axis of a pixel box, normalised and clamped into `[0,1]`. The size is
84468
+ * divided directly when nothing was clamped, so an exact box stays exact. */
84469
+ function clampSpan(start, size, extent) {
84470
+ const lo = start / extent;
84471
+ const hi = (start + size) / extent;
84472
+ const s = Math.max(0, lo);
84473
+ const n = lo >= 0 && hi <= 1 ? size / extent : Math.min(1, hi) - s;
84474
+ return n > 0 ? {
84475
+ start: s,
84476
+ size: n
84477
+ } : null;
84478
+ }
84479
+ function nearestPosition(track, at, tolerance) {
84480
+ let best = null;
84481
+ let bestDelta = Number.POSITIVE_INFINITY;
84482
+ for (const p of track.positions) {
84483
+ const delta = Math.abs(p.timestamp - at);
84484
+ if (delta < bestDelta) {
84485
+ best = p;
84486
+ bestDelta = delta;
84487
+ }
84488
+ }
84489
+ if (best === null || bestDelta > tolerance) return null;
84490
+ return {
84491
+ position: best,
84492
+ deltaMs: bestDelta
84493
+ };
84494
+ }
84495
+ function boxFor(track, bbox, basis, deltaMs, score) {
84496
+ const tiers = toRetrainClassTiers(track.className, track.label);
84497
+ const subLabel = track.subLabel?.trim();
84498
+ return {
84499
+ bbox,
84500
+ macroClass: tiers.macroClass,
84501
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
84502
+ ...subLabel !== void 0 && subLabel !== "" && !isRetrainMacroClass(subLabel.toLowerCase()) ? { subLabel } : {},
84503
+ ...score !== void 0 ? { score } : {},
84504
+ ...track.modelId !== void 0 ? { modelId: track.modelId } : {},
84505
+ trackId: track.trackId,
84506
+ basis,
84507
+ deltaMs
84508
+ };
84509
+ }
84510
+ /** The own track's box: the recorded subject box first, the nearest position second. */
84511
+ function ownBox(input, tolerance) {
84512
+ const track = input.ownTrack;
84513
+ if (track === null) return null;
84514
+ const at = input.frame.capturedAt;
84515
+ const nearest = at !== void 0 ? nearestPosition(track, at, tolerance) : null;
84516
+ if (input.ownSubjectBox !== null) {
84517
+ const recorded = normalise(input.ownSubjectBox, input.ownSubjectBox.frameWidth, input.ownSubjectBox.frameHeight);
84518
+ if (recorded !== null) return boxFor(track, recorded, "subject-box", 0, nearest?.position.score);
84519
+ }
84520
+ if (nearest === null) return null;
84521
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
84522
+ return placed === null ? null : boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score);
84523
+ }
84524
+ /**
84525
+ * Every box the pipeline had on this frame, own track first. Pure.
84526
+ */
84527
+ function pipelineBoxesForFrame(input) {
84528
+ const tolerance = input.toleranceMs ?? PIPELINE_BOX_TOLERANCE_MS;
84529
+ const out = [];
84530
+ const own = ownBox(input, tolerance);
84531
+ if (own !== null) out.push(own);
84532
+ const at = input.frame.capturedAt;
84533
+ if (at === void 0) return out;
84534
+ const ownId = input.frame.trackId;
84535
+ const seen = new Set(ownId !== void 0 ? [ownId] : []);
84536
+ for (const track of input.neighbours) {
84537
+ if (seen.has(track.trackId) || track.deviceId !== input.frame.deviceId) continue;
84538
+ seen.add(track.trackId);
84539
+ const nearest = nearestPosition(track, at, tolerance);
84540
+ if (nearest === null) continue;
84541
+ const placed = normalise(nearest.position.bbox, track.frameWidth, track.frameHeight);
84542
+ if (placed === null) continue;
84543
+ out.push(boxFor(track, placed, "position", nearest.deltaMs, nearest.position.score));
84544
+ }
84545
+ return out;
84546
+ }
84547
+ //#endregion
82847
84548
  //#region src/pipeline-analytics/retrain/retrain-assist.ts
82848
84549
  /** The whole frame, as a normalised rectangle. */
82849
84550
  var WHOLE_FRAME = {
@@ -82914,12 +84615,11 @@ function mapAssistDetails(input) {
82914
84615
  withoutBox += 1;
82915
84616
  continue;
82916
84617
  }
82917
- const macroClass = isRetrainMacroClass(d.className) ? d.className : FALLBACK_MACRO_CLASS;
82918
- const label = isRetrainMacroClass(d.className) ? d.label : d.className;
84618
+ const tiers = toRetrainClassTiers(d.className, d.label);
82919
84619
  proposals.push({
82920
84620
  bbox,
82921
- macroClass,
82922
- ...label !== void 0 && label !== "" ? { label } : {},
84621
+ macroClass: tiers.macroClass,
84622
+ ...tiers.label !== void 0 ? { label: tiers.label } : {},
82923
84623
  score: d.score,
82924
84624
  modelId
82925
84625
  });
@@ -82930,16 +84630,6 @@ function mapAssistDetails(input) {
82930
84630
  withoutBox
82931
84631
  };
82932
84632
  }
82933
- /**
82934
- * Macro tier for a detector class we do not carry as a macro class.
82935
- *
82936
- * The class itself becomes the LABEL (see above) and the macro tier gets a
82937
- * value the operator will almost certainly correct — which is fine, because it
82938
- * is a PROPOSAL and correcting it is the job. What must not happen is the
82939
- * detector's own string being promoted into the macro tier: that is how
82940
- * `postman` becomes a class with one example in it.
82941
- */
82942
- var FALLBACK_MACRO_CLASS = "person";
82943
84633
  /** A proposal, as a draft annotation ready to store if the operator accepts it. */
82944
84634
  function proposalToDraft(proposal, owner) {
82945
84635
  return {
@@ -83148,23 +84838,31 @@ async function restageRetrainTrack(deps, input) {
83148
84838
  var DEFAULT_STAGING_LIMIT = 100;
83149
84839
  /** A macro class that failed validation — the write is refused, never coerced. */
83150
84840
  var RetrainAnnotationError = class extends Error {};
84841
+ /** A store frame on the wire. A recording still carries no track and no
84842
+ * source media row, and says so by leaving both out. */
83151
84843
  function toWireFrame(f) {
83152
84844
  return {
83153
84845
  frameId: f.frameId,
83154
84846
  deviceId: f.deviceId,
83155
- trackId: f.trackId,
83156
- sourceMediaId: f.sourceMediaId,
84847
+ origin: f.origin,
84848
+ ...f.origin === "track" ? {
84849
+ trackId: f.trackId,
84850
+ sourceMediaId: f.sourceMediaId
84851
+ } : {},
84852
+ ...f.epochMs !== void 0 ? { epochMs: f.epochMs } : {},
83157
84853
  sourceKind: f.sourceKind,
83158
84854
  sizeBytes: f.sizeBytes,
83159
84855
  width: f.width,
83160
84856
  height: f.height,
83161
- copiedAt: f.copiedAt
84857
+ copiedAt: f.copiedAt,
84858
+ ...f.capturedAt !== void 0 ? { capturedAt: f.capturedAt } : {},
84859
+ complete: f.complete
83162
84860
  };
83163
84861
  }
83164
84862
  function toWireAnnotation(a) {
83165
84863
  return {
83166
84864
  id: a.id,
83167
- trackId: a.trackId,
84865
+ ...a.trackId !== "" ? { trackId: a.trackId } : {},
83168
84866
  deviceId: a.deviceId,
83169
84867
  mediaKey: a.mediaKey,
83170
84868
  bbox: a.bbox,
@@ -83231,11 +84929,116 @@ var RetrainService = class {
83231
84929
  if (candidates.length === 0) await this.logNoAnnotatableFrame(trackId, media, copies.length);
83232
84930
  return {
83233
84931
  candidates,
83234
- copies: copies.map(toWireFrame),
84932
+ copies: await this.toWireFrames(copies),
83235
84933
  ...autoPick !== null ? { autoPickMediaId: autoPick.mediaId } : {}
83236
84934
  };
83237
84935
  }
83238
84936
  /**
84937
+ * The wire frames, each with the boxes the pipeline drew on it (Step 1) and
84938
+ * the collections it belongs to.
84939
+ *
84940
+ * Index reads only — the track rows and the media rows' subject boxes, no
84941
+ * blob and no inference. One read per distinct track and per distinct
84942
+ * instant, not per box. A failed read degrades to fewer boxes, logged, and
84943
+ * never to a failed listing: the frames are the operator's work and must
84944
+ * stay reachable when the evidence is not.
84945
+ */
84946
+ async toWireFrames(copies) {
84947
+ const collections = this.deps.memberships !== void 0 && copies.length > 0 ? await this.deps.memberships.collectionsOf(copies.map((c) => c.frameId)).catch((err) => {
84948
+ this.deps.logger.warn("retrain frames: collection membership unreadable", { meta: {
84949
+ frames: copies.length,
84950
+ error: String(err)
84951
+ } });
84952
+ return /* @__PURE__ */ new Map();
84953
+ }) : null;
84954
+ const evidence = this.deps.pipelineEvidence;
84955
+ const ownTracks = /* @__PURE__ */ new Map();
84956
+ const subjectBoxes = /* @__PURE__ */ new Map();
84957
+ const out = [];
84958
+ for (const copy of copies) {
84959
+ const wire = toWireFrame(copy);
84960
+ const collectionIds = collections?.get(copy.frameId);
84961
+ const withCollections = collections !== null ? {
84962
+ ...wire,
84963
+ collectionIds: collectionIds ?? []
84964
+ } : wire;
84965
+ if (evidence === void 0) {
84966
+ out.push(withCollections);
84967
+ continue;
84968
+ }
84969
+ const trackId = copy.origin === "track" ? copy.trackId : "";
84970
+ if (trackId !== "" && !ownTracks.has(trackId)) {
84971
+ ownTracks.set(trackId, await this.deps.tracks.getPersistedByTrackId(trackId).catch(() => null));
84972
+ subjectBoxes.set(trackId, await this.readSubjectBoxes(evidence, trackId, copy.deviceId));
84973
+ }
84974
+ const neighbours = copy.capturedAt !== void 0 ? await this.readTracksAround(evidence, copy.deviceId, copy.capturedAt) : [];
84975
+ const pipelineBoxes = pipelineBoxesForFrame({
84976
+ frame: {
84977
+ deviceId: copy.deviceId,
84978
+ ...trackId !== "" ? { trackId } : {},
84979
+ ...copy.capturedAt !== void 0 ? { capturedAt: copy.capturedAt } : {}
84980
+ },
84981
+ ownTrack: trackId !== "" ? ownTracks.get(trackId) ?? null : null,
84982
+ ownSubjectBox: trackId !== "" ? subjectBoxes.get(trackId)?.get(copy.sourceMediaId) ?? null : null,
84983
+ neighbours
84984
+ });
84985
+ out.push({
84986
+ ...withCollections,
84987
+ pipelineBoxes
84988
+ });
84989
+ }
84990
+ return out;
84991
+ }
84992
+ /**
84993
+ * The frame a frame-scoped call names, or a refusal. The frame is the
84994
+ * authority: its camera must be the caller's, and a `trackId`, when the
84995
+ * caller sends one, must be the frame's own — a recording still has none.
84996
+ */
84997
+ async requireFrame(ref, action) {
84998
+ const frame = await this.deps.frames.get(ref.frameId);
84999
+ const trackMismatch = frame !== null && ref.trackId !== void 0 && (frame.origin !== "track" || frame.trackId !== ref.trackId);
85000
+ if (frame === null || frame.deviceId !== ref.deviceId || trackMismatch) {
85001
+ this.deps.logger.warn(`retrain ${action} refused: the frame does not belong to that camera or track`, {
85002
+ tags: { deviceId: ref.deviceId },
85003
+ meta: {
85004
+ frameId: ref.frameId,
85005
+ ...ref.trackId !== void 0 ? { trackId: ref.trackId } : {}
85006
+ }
85007
+ });
85008
+ 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)}`}`);
85009
+ }
85010
+ return frame;
85011
+ }
85012
+ async readSubjectBoxes(evidence, trackId, deviceId) {
85013
+ try {
85014
+ const rows = await evidence.subjectBoxes(trackId, deviceId);
85015
+ return new Map(rows.map((r) => [r.mediaId, r.subjectBox]));
85016
+ } catch (err) {
85017
+ this.deps.logger.warn("retrain pipeline boxes: subject boxes unreadable", {
85018
+ tags: { deviceId },
85019
+ meta: {
85020
+ trackId,
85021
+ error: String(err)
85022
+ }
85023
+ });
85024
+ return /* @__PURE__ */ new Map();
85025
+ }
85026
+ }
85027
+ async readTracksAround(evidence, deviceId, atMs) {
85028
+ try {
85029
+ return await evidence.tracksAround(deviceId, atMs);
85030
+ } catch (err) {
85031
+ this.deps.logger.warn("retrain pipeline boxes: tracks around the frame unreadable", {
85032
+ tags: { deviceId },
85033
+ meta: {
85034
+ atMs,
85035
+ error: String(err)
85036
+ }
85037
+ });
85038
+ return [];
85039
+ }
85040
+ }
85041
+ /**
83239
85042
  * A track with no annotatable frame is a REAL state, and the page has to say
83240
85043
  * so rather than render an empty strip that reads as a slow load. Two causes,
83241
85044
  * named apart: the whole-frame media AGED OUT, or the track only ever held
@@ -83261,6 +85064,7 @@ var RetrainService = class {
83261
85064
  async selectFrames(input) {
83262
85065
  const media = await this.deps.media.listTrackMedia(input.trackId);
83263
85066
  const kindOf = new Map(media.map((m) => [m.mediaId, m.kind]));
85067
+ const capturedAtOf = new Map(media.map((m) => [m.mediaId, m.timestamp]));
83264
85068
  const copied = [];
83265
85069
  const refused = [];
83266
85070
  for (const mediaId of input.mediaIds) {
@@ -83268,7 +85072,8 @@ var RetrainService = class {
83268
85072
  deviceId: input.deviceId,
83269
85073
  trackId: input.trackId,
83270
85074
  sourceMediaId: mediaId,
83271
- sourceKind: kindOf.get(mediaId) ?? "keyFrame"
85075
+ sourceKind: kindOf.get(mediaId) ?? "keyFrame",
85076
+ ...capturedAtOf.has(mediaId) ? { capturedAt: capturedAtOf.get(mediaId) } : {}
83272
85077
  });
83273
85078
  if (result.kind === "copied") copied.push(toWireFrame(result.frame));
83274
85079
  else refused.push({
@@ -83281,37 +85086,60 @@ var RetrainService = class {
83281
85086
  refused
83282
85087
  };
83283
85088
  }
85089
+ /**
85090
+ * The operator's "every subject in this frame is boxed". Scoped to the
85091
+ * track the caller names: a frame of another track is refused, loudly.
85092
+ */
85093
+ async setFrameComplete(input) {
85094
+ const frame = await this.requireFrame(input, "complete");
85095
+ const updated = await this.deps.frames.setComplete(frame.frameId, input.complete);
85096
+ if (updated === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} vanished`);
85097
+ this.deps.logger.info("retrain frame completeness set", {
85098
+ tags: { deviceId: input.deviceId },
85099
+ meta: {
85100
+ trackId: frame.trackId,
85101
+ frameId: input.frameId,
85102
+ complete: input.complete
85103
+ }
85104
+ });
85105
+ return toWireFrame(updated);
85106
+ }
83284
85107
  /** Un-select: annotations first, then the copy. Never the other way round —
83285
- * an annotation whose frame is gone is unopenable and unfixable. */
85108
+ * an annotation whose frame is gone is unopenable and unfixable. The frame's
85109
+ * collection memberships go with it. */
83286
85110
  async deselectFrame(input) {
83287
85111
  const frame = await this.deps.frames.get(input.frameId);
83288
85112
  if (frame === null) {
83289
85113
  this.deps.logger.warn("retrain deselect: no such copied frame", {
83290
85114
  tags: { deviceId: input.deviceId },
83291
- meta: {
83292
- trackId: input.trackId,
83293
- frameId: input.frameId
83294
- }
85115
+ meta: { frameId: input.frameId }
83295
85116
  });
83296
85117
  return {
83297
85118
  removed: false,
83298
85119
  removedAnnotations: 0
83299
85120
  };
83300
85121
  }
83301
- const removedAnnotations = await this.deps.annotations.deleteForFrame(input.frameId);
85122
+ await this.requireFrame(input, "deselect");
85123
+ return {
85124
+ removed: true,
85125
+ removedAnnotations: await this.removeFrame(frame)
85126
+ };
85127
+ }
85128
+ /** Remove a frame from the dataset: annotations, memberships, then the copy. */
85129
+ async removeFrame(frame) {
85130
+ const removedAnnotations = await this.deps.annotations.deleteForFrame(frame.frameId);
85131
+ await this.deps.memberships?.forgetFrame(frame.frameId);
83302
85132
  await this.deps.frames.deleteFrame(frame);
83303
85133
  this.deps.logger.info("retrain frame removed from the dataset", {
83304
- tags: { deviceId: input.deviceId },
85134
+ tags: { deviceId: frame.deviceId },
83305
85135
  meta: {
83306
- trackId: input.trackId,
83307
- frameId: input.frameId,
85136
+ origin: frame.origin,
85137
+ ...frame.origin === "track" ? { trackId: frame.trackId } : {},
85138
+ frameId: frame.frameId,
83308
85139
  removedAnnotations
83309
85140
  }
83310
85141
  });
83311
- return {
83312
- removed: true,
83313
- removedAnnotations
83314
- };
85142
+ return removedAnnotations;
83315
85143
  }
83316
85144
  /** The copy's pixels plus its exact dimensions — the canvas needs both. */
83317
85145
  async getFrameImage(frameId) {
@@ -83351,7 +85179,7 @@ var RetrainService = class {
83351
85179
  };
83352
85180
  }
83353
85181
  const frame = await this.deps.frames.get(input.frameId);
83354
- if (frame === null) return {
85182
+ if (frame === null || frame.deviceId !== input.deviceId || input.trackId !== void 0 && frame.trackId !== input.trackId) return {
83355
85183
  kind: "refused",
83356
85184
  reason: "unknown-frame"
83357
85185
  };
@@ -83406,8 +85234,8 @@ var RetrainService = class {
83406
85234
  }
83407
85235
  });
83408
85236
  const owner = {
83409
- trackId: input.trackId,
83410
- deviceId: input.deviceId,
85237
+ trackId: frame.trackId,
85238
+ deviceId: frame.deviceId,
83411
85239
  mediaKey: input.frameId
83412
85240
  };
83413
85241
  return {
@@ -83430,8 +85258,12 @@ var RetrainService = class {
83430
85258
  belowThreshold: mapping.belowThreshold
83431
85259
  };
83432
85260
  }
83433
- async listAnnotations(trackId) {
83434
- return (await this.deps.annotations.listByTrack(trackId)).map(toWireAnnotation);
85261
+ /** The annotations of a track, or of named frames (a recording still has
85262
+ * no track). One of the two must be given. */
85263
+ async listAnnotations(input) {
85264
+ if (input.trackId !== void 0 && input.trackId !== "") return (await this.deps.annotations.listByTrack(input.trackId)).map(toWireAnnotation);
85265
+ if (input.frameIds !== void 0) return (await this.deps.annotations.listByFrames(input.frameIds)).map(toWireAnnotation);
85266
+ throw new RetrainAnnotationError("listRetrainAnnotations needs a trackId or frameIds");
83435
85267
  }
83436
85268
  /**
83437
85269
  * Replace every annotation on one frame.
@@ -83443,15 +85275,15 @@ var RetrainService = class {
83443
85275
  * believes they stored.
83444
85276
  */
83445
85277
  async saveAnnotations(input) {
83446
- if (await this.deps.frames.get(input.frameId) === null) throw new RetrainAnnotationError(`retrain frame ${input.frameId} does not exist — select it before annotating it`);
85278
+ const frame = await this.requireFrame(input, "save");
83447
85279
  for (const a of input.annotations) {
83448
85280
  if (!isRetrainMacroClass(a.macroClass)) throw new RetrainAnnotationError(`"${a.macroClass}" is not a macro class`);
83449
85281
  const violation = checkTierSeparation(a);
83450
85282
  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)`);
83451
85283
  }
83452
85284
  const drafts = input.annotations.map((a) => ({
83453
- trackId: input.trackId,
83454
- deviceId: input.deviceId,
85285
+ trackId: frame.trackId,
85286
+ deviceId: frame.deviceId,
83455
85287
  mediaKey: input.frameId,
83456
85288
  bbox: a.bbox,
83457
85289
  macroClass: a.macroClass,
@@ -83467,7 +85299,8 @@ var RetrainService = class {
83467
85299
  this.deps.logger.info("retrain annotations saved", {
83468
85300
  tags: { deviceId: input.deviceId },
83469
85301
  meta: {
83470
- trackId: input.trackId,
85302
+ origin: frame.origin,
85303
+ trackId: frame.trackId,
83471
85304
  frameId: input.frameId,
83472
85305
  subjects: saved.length - errors,
83473
85306
  modelErrors: errors
@@ -84493,7 +86326,8 @@ function selectsRow(mode, toLocationId, data, isRetrain, fromLocationId) {
84493
86326
  if (mode === "seal") return rowIsUnstamped(data);
84494
86327
  if (mediaRowLocation(data) === toLocationId) return false;
84495
86328
  if (mode === "gallery") return !isRetrain && rowIsGallery(data);
84496
- return isRetrain || !rowIsGallery(data);
86329
+ if (isRetrain) return !rowIsDurableRetrainCopy(data);
86330
+ return !rowIsGallery(data);
84497
86331
  }
84498
86332
  var MediaRelocateEngine = class {
84499
86333
  deps;
@@ -92868,6 +94702,14 @@ var RECORDED_STILL_PROFILE = "high";
92868
94702
  */
92869
94703
  var RECOVERED_KEY_FRAME_MAX_WIDTH = 1920;
92870
94704
  /**
94705
+ * Width cap for a still added to the retrain dataset from the recording.
94706
+ *
94707
+ * Effectively none: a training frame is wanted at the camera's own resolution
94708
+ * (the same pixels a native key frame has), so the bound is only a ceiling
94709
+ * against a pathological stream, far above any camera on the fleet.
94710
+ */
94711
+ var RETRAIN_STILL_MAX_WIDTH = 7680;
94712
+ /**
92871
94713
  * Build the production ports.
92872
94714
  *
92873
94715
  * Every port swallows its own failure into the `null` the policy expects: a
@@ -97152,7 +98994,7 @@ var ANALYTICS_COLLECTIONS = [
97152
98994
  collection: RETRAIN_FRAMES_COLLECTION,
97153
98995
  classification: {
97154
98996
  kind: "never-orphaned",
97155
- 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."
98997
+ 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."
97156
98998
  }
97157
98999
  },
97158
99000
  {
@@ -106563,6 +108405,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
106563
108405
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
106564
108406
  * until the stores are built. */
106565
108407
  retrain = null;
108408
+ retrainCollections = null;
108409
+ retrainLlmAssist = null;
108410
+ retrainCollectionStore = null;
106566
108411
  retrainFrames = null;
106567
108412
  retrainAnnotations = null;
106568
108413
  /** The NC artifact plane (signed public links for notification media) and its
@@ -108419,6 +110264,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
108419
110264
  resolveRoot: (locationId) => locationId === LEGACY_ADDON_DATA_GALLERY_LOCATION ? Promise.resolve(path.join(this.ctx.dataDir, "gallery")) : locationId === GALLERY_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
108420
110265
  location: ARTIFACTS_MEDIA_LOCATION,
108421
110266
  relativePath: GALLERY_TENANT_SUBPATH
110267
+ }) : locationId === RETRAIN_MEDIA_LOCATION ? this.ctx.api.storage.resolve.query({
110268
+ location: ARTIFACTS_MEDIA_LOCATION,
110269
+ relativePath: ""
108422
110270
  }) : this.ctx.api.storage.resolve.query({
108423
110271
  location: locationId,
108424
110272
  relativePath: ""
@@ -108597,7 +110445,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
108597
110445
  logger: logger.child("RetrainFrames"),
108598
110446
  now: () => Date.now(),
108599
110447
  writeGate: this.mediaWriteGate,
108600
- resolveEventMediaLocation: (deviceId) => this.resolveEventMediaWriteLocation(api, logger, deviceId),
110448
+ measureChroma: (bytes) => chromaSpread(bytes),
108601
110449
  readSource: async (mediaKey) => {
108602
110450
  const file = await mediaStore.getById(mediaKey);
108603
110451
  return file === null ? null : Buffer.from(file.base64, "base64");
@@ -108611,7 +110459,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
108611
110459
  });
108612
110460
  this.retrainFrames = retrainFrames;
108613
110461
  this.retrainAnnotations = retrainAnnotations;
108614
- this.retrain = new RetrainService({
110462
+ const retrainCollectionStore = new RetrainCollectionStore({
110463
+ store: api.settingsStore,
110464
+ logger: logger.child("RetrainCollections"),
110465
+ now: () => Date.now(),
110466
+ newId: () => randomUUID()
110467
+ });
110468
+ this.retrainCollectionStore = retrainCollectionStore;
110469
+ const retrain = new RetrainService({
108615
110470
  tracks: {
108616
110471
  listStaging: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
108617
110472
  listDeviceIds: () => trackStore.listDeviceIds(),
@@ -108629,7 +110484,70 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
108629
110484
  frames: retrainFrames,
108630
110485
  annotations: retrainAnnotations,
108631
110486
  runner: (input, nodeId) => nodeId === void 0 ? api.pipelineRunner.runStatelessStep.mutate(input) : api.pipelineRunner.runStatelessStep.mutate(input, nodePin(nodeId)),
108632
- logger: logger.child("Retrain")
110487
+ logger: logger.child("Retrain"),
110488
+ pipelineEvidence: {
110489
+ tracksAround: (deviceId, atMs) => trackStore.queryHistorical({
110490
+ deviceId,
110491
+ since: atMs - PIPELINE_TRACK_LOOKBACK_MS,
110492
+ until: atMs + PIPELINE_BOX_TOLERANCE_MS,
110493
+ limit: PIPELINE_TRACK_AROUND_LIMIT
110494
+ }),
110495
+ subjectBoxes: (trackId, deviceId) => mediaStore.listSubjectBoxesByOwner("track", trackId, deviceId)
110496
+ },
110497
+ memberships: retrainCollectionStore
110498
+ });
110499
+ this.retrain = retrain;
110500
+ const retrainStills = new RecordedStillService(buildRecordedStillPorts({
110501
+ api: this.ctx.api.recordingArchive,
110502
+ logger: logger.child("RetrainStills"),
110503
+ resolveFfmpeg: this.pinnedFfmpeg
110504
+ }));
110505
+ this.retrainCollections = new RetrainCollectionService({
110506
+ collections: retrainCollectionStore,
110507
+ frames: retrainFrames,
110508
+ annotations: retrainAnnotations,
110509
+ retrain,
110510
+ captureStill: (deviceId, epochMs) => retrainStills.recover(deviceId, epochMs, RETRAIN_STILL_MAX_WIDTH),
110511
+ logger: logger.child("RetrainCollections")
110512
+ });
110513
+ this.retrainLlmAssist = new RetrainLlmAssistService({
110514
+ frames: retrainFrames,
110515
+ llm: {
110516
+ listProfiles: async () => (await api.llm.listProfiles.query({})).map((p) => ({
110517
+ id: p.id,
110518
+ name: p.name,
110519
+ model: p.model,
110520
+ kind: p.kind,
110521
+ ...p.baseUrl !== void 0 ? { baseUrl: p.baseUrl } : {},
110522
+ enabled: p.enabled,
110523
+ supportsVision: p.supportsVision
110524
+ })),
110525
+ generateVision: async (request) => {
110526
+ const result = await api.llm.generateVision.mutate({
110527
+ profileId: request.profileId,
110528
+ consumer: RETRAIN_LLM_CONSUMER,
110529
+ system: request.system,
110530
+ prompt: request.prompt,
110531
+ jsonSchema: request.jsonSchema,
110532
+ images: [{
110533
+ bytes: new Uint8Array(request.image),
110534
+ mimeType: "image/jpeg"
110535
+ }],
110536
+ temperature: 0,
110537
+ timeoutMs: RETRAIN_LLM_TIMEOUT_MS
110538
+ });
110539
+ return result.ok ? {
110540
+ ok: true,
110541
+ text: result.text,
110542
+ model: result.model
110543
+ } : {
110544
+ ok: false,
110545
+ code: result.code,
110546
+ message: result.message
110547
+ };
110548
+ }
110549
+ },
110550
+ logger: logger.child("RetrainLlmAssist")
108633
110551
  });
108634
110552
  this.mediaRelocate = new MediaRelocateEngine({
108635
110553
  store: api.settingsStore,
@@ -109813,14 +111731,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
109813
111731
  };
109814
111732
  return this.requireRetrain().propose({
109815
111733
  deviceId: input.deviceId,
109816
- trackId: input.trackId,
111734
+ ...input.trackId !== void 0 ? { trackId: input.trackId } : {},
109817
111735
  frameId: input.frameId,
109818
111736
  subject,
109819
111737
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
109820
111738
  });
109821
111739
  }
109822
111740
  async listRetrainAnnotations(input) {
109823
- return this.requireRetrain().listAnnotations(input.trackId);
111741
+ return this.requireRetrain().listAnnotations(input);
109824
111742
  }
109825
111743
  async saveRetrainAnnotations(input) {
109826
111744
  return this.requireRetrain().saveAnnotations(input);
@@ -109834,10 +111752,99 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
109834
111752
  /** Where to stream the ANNOTATED dataset from. Bytes never cross the cap. */
109835
111753
  async getRetrainExportUrl(input) {
109836
111754
  if (this.retrainExportDataPlane === null) throw new Error("retrain export: data plane unavailable on this node");
109837
- const ids = input.deviceIds ?? [];
109838
- const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
111755
+ const { layout, ...filter } = input;
111756
+ const query = retrainExportQuery({
111757
+ filter,
111758
+ layout: layout ?? "frames"
111759
+ });
109839
111760
  return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
109840
111761
  }
111762
+ async setRetrainFrameComplete(input) {
111763
+ return this.requireRetrain().setFrameComplete(input);
111764
+ }
111765
+ requireRetrainLlmAssist() {
111766
+ const service = this.retrainLlmAssist;
111767
+ if (service === null) throw new Error("retrain AI assist: analytics stores unavailable");
111768
+ return service;
111769
+ }
111770
+ async listRetrainAssistProfiles(_input) {
111771
+ return this.requireRetrainLlmAssist().listProfiles();
111772
+ }
111773
+ async proposeRetrainAnnotationsWithLlm(input) {
111774
+ return this.requireRetrainLlmAssist().propose(input);
111775
+ }
111776
+ requireRetrainCollections() {
111777
+ const service = this.retrainCollections;
111778
+ if (service === null) throw new Error("retrain collections: analytics stores unavailable");
111779
+ return service;
111780
+ }
111781
+ async createRetrainCollection(input) {
111782
+ return this.requireRetrainCollections().create(input);
111783
+ }
111784
+ async listRetrainCollections(_input) {
111785
+ return this.requireRetrainCollections().list();
111786
+ }
111787
+ async renameRetrainCollection(input) {
111788
+ return this.requireRetrainCollections().rename(input);
111789
+ }
111790
+ async deleteRetrainCollection(input) {
111791
+ return this.requireRetrainCollections().delete(input);
111792
+ }
111793
+ async addRetrainFrameFromRecording(input) {
111794
+ return this.requireRetrainCollections().addFrameFromRecording(input);
111795
+ }
111796
+ async addRetrainFramesToCollection(input) {
111797
+ return this.requireRetrainCollections().addFrames(input);
111798
+ }
111799
+ async removeRetrainFrameFromCollection(input) {
111800
+ return this.requireRetrainCollections().removeFrame(input);
111801
+ }
111802
+ async listRetrainCollectionFrames(input) {
111803
+ return this.requireRetrainCollections().listFrames(input);
111804
+ }
111805
+ /**
111806
+ * Stream the annotated archive into ONE HTTP request to `target` — the
111807
+ * training upload. Same bytes as the download; never on the hub's disk.
111808
+ */
111809
+ async writeRetrainArchive(input) {
111810
+ const frameStore = this.retrainFrames;
111811
+ if (frameStore === null) throw new Error("retrain export: analytics stores unavailable");
111812
+ const plan = await this.buildRetrainExportPlan({
111813
+ filter: input.filter,
111814
+ layout: input.layout
111815
+ });
111816
+ const upload = openRetrainUploadSink(input.target);
111817
+ const host = new URL(input.target.url).host;
111818
+ try {
111819
+ const stats = await writeRetrainArchiveTo({
111820
+ plan,
111821
+ readFrame: (frame) => frameStore.readFrame(frame),
111822
+ cutCrop: this.retrainCropCutter(),
111823
+ sink: upload.sink,
111824
+ markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
111825
+ logger: this.ctx.logger,
111826
+ now: () => Date.now()
111827
+ });
111828
+ const status = await upload.finish();
111829
+ this.ctx.logger.info("retrain archive uploaded", { meta: {
111830
+ host,
111831
+ status,
111832
+ ...stats,
111833
+ layout: input.layout
111834
+ } });
111835
+ return {
111836
+ ...stats,
111837
+ status
111838
+ };
111839
+ } catch (err) {
111840
+ upload.abort(errMsg(err));
111841
+ this.ctx.logger.warn("retrain archive upload failed", { meta: {
111842
+ host,
111843
+ error: errMsg(err)
111844
+ } });
111845
+ throw err;
111846
+ }
111847
+ }
109841
111848
  /**
109842
111849
  * The store seam the `debug.*` actions and the debug bundle both read through.
109843
111850
  *
@@ -110119,15 +112126,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
110119
112126
  async serveRetrainExportPlane() {
110120
112127
  try {
110121
112128
  const handler = createRetrainExportHandler({
110122
- buildPlan: (deviceIds) => this.buildRetrainExportPlan(deviceIds),
112129
+ buildPlan: (request) => this.buildRetrainExportPlan(request),
110123
112130
  readFrame: (frame) => this.retrainFrames?.readFrame(frame) ?? Promise.resolve(null),
110124
- markExported: async (frameIds, batchId) => {
110125
- const store = this.retrainAnnotations;
110126
- if (!store || frameIds.length === 0) return;
110127
- const ids = [];
110128
- for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
110129
- await store.markExported(ids, batchId);
110130
- },
112131
+ cutCrop: this.retrainCropCutter(),
112132
+ markExported: (frameIds, batchId) => this.markRetrainExported(frameIds, batchId),
110131
112133
  logger: this.ctx.logger,
110132
112134
  now: () => Date.now()
110133
112135
  });
@@ -110149,13 +112151,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
110149
112151
  * reference rectangle is implicit is a box nobody can check. A camera with no
110150
112152
  * zones simply has no `zoneCrop` geometry — absent, never a clamped guess.
110151
112153
  */
110152
- async buildRetrainExportPlan(deviceIds) {
112154
+ async buildRetrainExportPlan(request) {
110153
112155
  const frameStore = this.retrainFrames;
110154
112156
  const annotationStore = this.retrainAnnotations;
110155
112157
  const trackStore = this.trackStore;
110156
112158
  if (!frameStore || !annotationStore || !trackStore) throw new Error("retrain export: analytics stores unavailable");
110157
- const devices = deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds();
110158
- const frames = [];
112159
+ const deviceIds = request.filter.deviceIds ?? [];
112160
+ const devices = deviceIds.length > 0 ? deviceIds : [...new Set([...await trackStore.listDeviceIds(), ...await frameStore.listDeviceIds()])];
112161
+ const candidates = /* @__PURE__ */ new Map();
110159
112162
  const annotationsByFrame = /* @__PURE__ */ new Map();
110160
112163
  const zones = /* @__PURE__ */ new Map();
110161
112164
  for (const deviceId of devices) {
@@ -110164,22 +112167,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
110164
112167
  const existing = annotationsByFrame.get(annotation.mediaKey) ?? [];
110165
112168
  annotationsByFrame.set(annotation.mediaKey, [...existing, annotation]);
110166
112169
  }
112170
+ for (const frame of await frameStore.listCompleteByDevice(deviceId, RETRAIN_EXPORT_MAX)) candidates.set(frame.frameId, frame);
110167
112171
  }
110168
112172
  for (const frameId of annotationsByFrame.keys()) {
112173
+ if (candidates.has(frameId)) continue;
110169
112174
  const frame = await frameStore.get(frameId);
110170
- if (frame !== null) frames.push(frame);
112175
+ if (frame !== null) candidates.set(frameId, frame);
110171
112176
  }
112177
+ const collectionsByFrame = await this.retrainCollectionStore?.collectionsOf([...candidates.keys()]) ?? /* @__PURE__ */ new Map();
112178
+ const frames = filterRetrainExport([...candidates.values()], annotationsByFrame, request.filter, collectionsByFrame);
110172
112179
  return {
110173
112180
  manifest: buildRetrainManifest({
110174
112181
  frames,
110175
112182
  annotationsByFrame,
112183
+ collectionsByFrame,
110176
112184
  zoneForDevice: (deviceId) => zones.get(deviceId) ?? null,
110177
112185
  generatedAt: Date.now(),
110178
- batchId: randomUUID()
112186
+ batchId: randomUUID(),
112187
+ layout: request.layout,
112188
+ coordinates: await this.siteCoordinates()
110179
112189
  }),
110180
112190
  frames
110181
112191
  };
110182
112192
  }
112193
+ /** Stamp the batch on the annotations of the frames an archive carried. */
112194
+ async markRetrainExported(frameIds, batchId) {
112195
+ const store = this.retrainAnnotations;
112196
+ if (!store || frameIds.length === 0) return;
112197
+ const ids = [];
112198
+ for (const frameId of frameIds) for (const a of await store.listByFrame(frameId)) ids.push(a.id);
112199
+ await store.markExported(ids, batchId);
112200
+ }
112201
+ /** The `crops` layout's cutter, with the convention the manifest records. */
112202
+ retrainCropCutter() {
112203
+ return (frameBytes, frame, annotation) => cutRetrainCrop({
112204
+ frameBytes,
112205
+ frameWidth: frame.width,
112206
+ frameHeight: frame.height,
112207
+ bbox: annotation.bbox,
112208
+ convention: RETRAIN_CROP_CONVENTION,
112209
+ maxSide: RETRAIN_CROP_MAX_SIDE
112210
+ }).catch((err) => {
112211
+ this.ctx.logger.warn("retrain crop could not be cut", {
112212
+ tags: { deviceId: frame.deviceId },
112213
+ meta: {
112214
+ frameId: frame.frameId,
112215
+ annotationId: annotation.id,
112216
+ error: errMsg(err)
112217
+ }
112218
+ });
112219
+ return null;
112220
+ });
112221
+ }
110183
112222
  /** Bounding box of a camera's zones, or null when it declares none. */
110184
112223
  async retrainZoneForDevice(deviceId) {
110185
112224
  try {
@@ -110327,6 +112366,28 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
110327
112366
  }, LTS_FLUSH_INTERVAL_MS);
110328
112367
  this.startOrphanAuditTimer();
110329
112368
  this.reconcileGalleryMediaLocation();
112369
+ this.reconcileRetrainCopiesLocation();
112370
+ }
112371
+ /**
112372
+ * Move pre-D781 retrain copies off the footage class onto the durable
112373
+ * `retrainMedia` tenant, and say how many have no bytes left.
112374
+ */
112375
+ async reconcileRetrainCopiesLocation() {
112376
+ const retrainFrames = this.retrainFrames;
112377
+ if (!retrainFrames) return;
112378
+ try {
112379
+ const result = await retrainFrames.relocateLegacyCopies();
112380
+ if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
112381
+ this.ctx.logger.info("retrain copies reconciled onto the durable retrain tenant", { meta: {
112382
+ ...result,
112383
+ location: RETRAIN_MEDIA_LOCATION
112384
+ } });
112385
+ } catch (err) {
112386
+ this.ctx.logger.warn("retrain copies reconcile failed — the copies stay on eventMedia", { meta: {
112387
+ error: errMsg(err),
112388
+ location: ARTIFACTS_MEDIA_LOCATION
112389
+ } });
112390
+ }
110330
112391
  }
110331
112392
  /**
110332
112393
  * Move enrolled-gallery blobs off the footage volume, and say out loud how