@camstack/addon-post-analysis 1.2.293 → 1.2.295
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.
- package/dist/{clip-model-registry-D2zqXamW.mjs → clip-model-registry-BrHhUIfN.mjs} +276 -156
- package/dist/{clip-model-registry-BAuTHS_s.js → clip-model-registry-Dxlc9JV3.js} +275 -155
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/_stub.js +2 -2
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DW6-zerK.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B28yTZRG.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-vs3KH9py.mjs +26 -0
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-KXwf-qxG.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-CAJh3aX8.mjs → hostInit-CLLZtXTH.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +331 -141
- package/dist/pipeline-analytics/index.mjs +331 -141
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +2 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boh18P08.mjs +0 -26
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bs5QFgmh.mjs +0 -26
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as NcRuleTargetSchema, $n as EventCategory, $t as isVectorIndexDimConflict, A as LockStateSchema, An as DeviceType, At as audioModeOf, B as NC_SENSOR_TRANSITION_KINDS, C as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Cn as templateVarsFor, Ct as TimelapseRulePatchSchema, D as FULL_IMAGE_BBOX, Dn as canonicalHash, Dt as alarmPanelCapability, E as FIRST_LEVEL_MACRO_CLASSES, Et as addonWidgetsSourceCapability, F as MediaFileKindEnum, Fn as customAction, Ft as describeSystemEventDeliveryProblem, G as NC_TEMPLATE_VARS, Gn as discriminatedUnion, Gt as failureContributionCapability, H as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, Hn as _enum, Ht as encodeVectorBase64, I as NC_ALARM_SYSTEM_EVENT_KINDS, In as defineCustomActions, It as describeSystemEventFamilyProblem, J as NcConditionDescriptorSchema, Jn as object, Jt as isKnownTemplateVar, K as NcAlarmConfigSchema, Kn as literal, Kt as isDetectionMacroClass, L as NC_CONDITION_CATALOG, Ln as hydrateSchema, M as MAX_BIRTH_DECISION_RECORDS, Mn as BaseAddon, Mt as consumableLowPercentOf, Nn as CamProfileSchema, Nt as cosineSimilarity$1, O as FailureCounters, On as evaluateSensorEdge, Ot as assertTimelapseCadences, P as MOTION_CLOSE_AFTER_MS, Pn as createEvent, Pt as deriveRecordingMode, Q as NcRuleSchema, Qn as unknown, Qt as isSystemEventSummaryRule, R as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, Rn as isDeviceScopedCap, S as EMBEDDING_REQUIREMENTS_FAILED, Sn as templateCarriesSummary, St as TimelapseRuleInputSchema, T as EVENT_OWNER_TYPES, Tn as zoneAnalyticsCapability, Tt as TrackSourceSchema, U as NC_TANK_SYSTEM_EVENT_KINDS, Un as array, Ut as evictionPolicyOfLocation, Vn as sleep$1, W as NC_TAXONOMY, Wn as boolean, Wt as faceGalleryCapability, X as NcRuleInputSchema, Xn as record, Xt as isScheduleActive, Y as NcConditionsSchema, Yn as partialRecord, Yt as isMediaOwnerType, Z as NcRulePatchSchema, Zn as string, Zt as isSystemDelivery, _n as subKindsOf, _t as SCENE_DEFAULT_ANCHOR_THRESHOLD, a as CLIP_EMBEDDING_STEP_ID, an as pipelineAnalyticsCapability, at as NcSystemEventKindSchema, b as DeclaredDevices, bn as systemEventFilterApplies, bt as SceneMonitorSchema, c as resolveClusterModelPin, cn as readTimelapseGeneratedAt, ct as NcTemplatePreviewSchema, d as ArchivedDebugNoteSchema, en as kebabToCamel, et as NcRuleUpdateInputSchema, f as BaseDevice, fn as ruleBelongsToAlarm$1, ft as RECORDING_EXPORT_MAX_READ_BYTES, g as DEFAULT_EVENT_COLOR, gn as storageOccupancyCapability, h as CoverStateSchema, hn as servedTemplateVars, ht as RetrainStatusSchema, it as NcSnoozeSuppressedSchema, j as MAX_ARCHIVED_DEBUG_NOTES, jn as errMsg$1, jt as buildEventKindDescriptor, k as LabelAttributionSchema, kn as isSourceCap, kt as audioMetricsCapability, l as AUDIO_MACRO_LABELS, ln as resolveClipSearchMinScore, lt as NcTemplateVarDescriptorSchema, m as COCO_TO_MACRO, mn as sensorRaisedOf, mt as RebuildObjectEmbeddingsRefusalSchema, nn as notificationRulesCapability, nt as NcSnoozeInputSchema, o as FACE_EMBEDDING_STEP_ID, on as plateGalleryCapability, ot as NcTaxonomySchema, p as BirthDecisionRecordSchema, pn as sceneMonitorCapability, pt as RECORDING_SOURCE_CAMSTACK_ADDON, q as NcAlarmSettingsPatchSchema, qn as number, qt as isEventOwnerType, rn as occupancyScope, rt as NcSnoozeSchema, s as readClusterModelPinStrict, sn as readDeviceStateFrom, st as NcTemplatePreviewInputSchema, t as BUILTIN_CLIP_MODELS, tn as mayWriteToLocation, tt as NcScheduleSchema, u as AlarmArmModeSchema, un as resolveLocationMode, ut as OpsLogEntrySchema, v as DETECTION_MACRO_CLASSES, vn as systemEventDeliveryProblems, vt as SCENE_DEFAULT_UNCOVERED_POLICY, w as EVENT_KIND_BY_CAP, wn as vectorDimFromBase64, wt as TimelapseRuleSchema, x as EMBEDDING_MODEL_FAILED, xn as systemEventSummaryScheduleOf, xt as TIMELAPSE_DENSE_FLOOR_SEC, yn as systemEventFamilyProblems, yt as SCENE_DIVERGED, z as NC_DEFAULT_SNOOZE_MINUTES, zn as nodePin, zt as embeddingRefusalOf } from "../clip-model-registry-BrHhUIfN.mjs";
|
|
2
2
|
import { t as __exportAll } from "../embedding-encoder/index.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { promises } from "node:fs";
|
|
@@ -67511,15 +67511,17 @@ function resolveMediaSettings(raw) {
|
|
|
67511
67511
|
* `recording` became a device collection on 2026-09-24 (D625): a camera may
|
|
67512
67512
|
* have several sources of recorded coverage and every read names one. Every
|
|
67513
67513
|
* read in this addon wants the same one — the footage OUR recorder wrote,
|
|
67514
|
-
* because everything downstream of it (a recorded clip cut, a timelapse
|
|
67515
|
-
*
|
|
67516
|
-
*
|
|
67514
|
+
* because everything downstream of it (a recorded clip cut, a timelapse) is
|
|
67515
|
+
* about bytes we hold and can export. The retention sweep's earliest-footage
|
|
67516
|
+
* floor is NOT asked here any more: it is one number the archive's ledger
|
|
67517
|
+
* already folds, and asking it as coverage from the epoch walked every hour
|
|
67518
|
+
* since 1970 (D674, `retention/archive-footage-floor.ts`). A camera's own SD card is not an answer to any of those questions.
|
|
67517
67519
|
*
|
|
67518
67520
|
* It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
|
|
67519
67521
|
* throws here rather than arriving downstream as an empty range list. Every
|
|
67520
67522
|
* caller of this module treats "no ranges" as "recording is off for this
|
|
67521
|
-
* camera" and acts on it — dropping the attachment, skipping the render
|
|
67522
|
-
*
|
|
67523
|
+
* camera" and acts on it — dropping the attachment, skipping the render —
|
|
67524
|
+
* so folding a failed read into that shape would
|
|
67523
67525
|
* make an unreachable recorder look exactly like a camera nobody records
|
|
67524
67526
|
* (D393). The callers already handle a throw and log it as a read failure.
|
|
67525
67527
|
*/
|
|
@@ -67538,6 +67540,248 @@ async function ourRecordingAvailability(api, input) {
|
|
|
67538
67540
|
profilesWithFootage: answer.profilesWithFootage
|
|
67539
67541
|
};
|
|
67540
67542
|
}
|
|
67543
|
+
//#endregion
|
|
67544
|
+
//#region src/pipeline-analytics/retention-policy.ts
|
|
67545
|
+
/**
|
|
67546
|
+
* Analytics retention policy — **ONE window per device** (D62).
|
|
67547
|
+
*
|
|
67548
|
+
* A device has one retention window, and everything scoped to that device is
|
|
67549
|
+
* deleted on it: the track (which cascades to its object events, their child
|
|
67550
|
+
* crops, its media, its unassigned face/plate rows and crops, and its CLIP
|
|
67551
|
+
* vector) AND the trackless roots (motion, audio, sensor events). The only
|
|
67552
|
+
* thing outside it is the ops log, which is global and longer than any device
|
|
67553
|
+
* window because it records what retention did — including for devices that no
|
|
67554
|
+
* longer exist.
|
|
67555
|
+
*
|
|
67556
|
+
* Two modes:
|
|
67557
|
+
*
|
|
67558
|
+
* - `follow-recordings` (the DEFAULT): the window is the instant of the oldest
|
|
67559
|
+
* segment still on disk. That follows the recorder's age retention AND its
|
|
67560
|
+
* disk-pressure eviction automatically, with no second policy to keep in
|
|
67561
|
+
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
67562
|
+
* back to `trackRetentionDays`, so analytics stay bounded either way.
|
|
67563
|
+
*
|
|
67564
|
+
* - `custom`: `trackRetentionDays`, and nothing else. `0` = keep forever, and
|
|
67565
|
+
* that is the ONE way retention is off for a device.
|
|
67566
|
+
*
|
|
67567
|
+
* ## Why there is only one number here
|
|
67568
|
+
*
|
|
67569
|
+
* There used to be four: `trackRetentionDays` plus a day window per event
|
|
67570
|
+
* family (motion / object / audio). [D61](../../../../docs/decisions/adr-0061.md)
|
|
67571
|
+
* made object events, faces, plates, crops and vectors TRACK-OWNED — they leave
|
|
67572
|
+
* through the cascade and by no other path — which silently disconnected
|
|
67573
|
+
* `retentionObjectDays` from the thing it was named after. An operator could set
|
|
67574
|
+
* object retention to 30 days, the form would save it, and the events would die
|
|
67575
|
+
* with their track at 7. A knob that decides nothing is worse than a missing
|
|
67576
|
+
* one: it reads as a decision that was made.
|
|
67577
|
+
*
|
|
67578
|
+
* So the per-family windows are gone (D62), not merely ignored, and the
|
|
67579
|
+
* surviving surface is ENUMERATED in {@link RETENTION_SETTING_KEYS} so
|
|
67580
|
+
* `scripts/check-retention-settings-consumed.ts` can fail the build the next
|
|
67581
|
+
* time a knob and its reader come apart.
|
|
67582
|
+
*
|
|
67583
|
+
* Follow-mode floor: nothing younger than {@link FOLLOW_FLOOR_DAYS} is ever
|
|
67584
|
+
* pruned, even when footage is younger (a camera that STARTED recording
|
|
67585
|
+
* yesterday must not nuke its whole history back to the first segment).
|
|
67586
|
+
*/
|
|
67587
|
+
var DAY_MS$1 = 1440 * 60 * 1e3;
|
|
67588
|
+
/**
|
|
67589
|
+
* Per-device analytics retention settings as stored in the device blob.
|
|
67590
|
+
* `retentionMode` absent = follow-recordings (the default).
|
|
67591
|
+
*/
|
|
67592
|
+
var RetentionSettingsSchema = object({
|
|
67593
|
+
retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
67594
|
+
trackRetentionDays: number().min(0).default(7)
|
|
67595
|
+
});
|
|
67596
|
+
/** The default retention window, in days. The form's default reads from here so
|
|
67597
|
+
* the schema is the single source. */
|
|
67598
|
+
var TRACK_RETENTION_DEFAULT_DAYS = RetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
67599
|
+
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
67600
|
+
* fields fall back to defaults, never throw. */
|
|
67601
|
+
function resolveRetentionSettings(raw) {
|
|
67602
|
+
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
67603
|
+
if (parsed.success) return parsed.data;
|
|
67604
|
+
const shape = RetentionSettingsSchema.shape;
|
|
67605
|
+
const defaults = RetentionSettingsSchema.parse({});
|
|
67606
|
+
const mode = shape.retentionMode.safeParse(raw["retentionMode"]);
|
|
67607
|
+
const days = shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
67608
|
+
return {
|
|
67609
|
+
retentionMode: mode.success ? mode.data : defaults.retentionMode,
|
|
67610
|
+
trackRetentionDays: days.success ? days.data : defaults.trackRetentionDays
|
|
67611
|
+
};
|
|
67612
|
+
}
|
|
67613
|
+
/**
|
|
67614
|
+
* Follow-mode cutoff: the oldest retained footage instant, floored so anything
|
|
67615
|
+
* younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage (none on
|
|
67616
|
+
* disk) → `null`, and the caller falls back to the custom window.
|
|
67617
|
+
*/
|
|
67618
|
+
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
67619
|
+
if (earliestFootageMs === null) return null;
|
|
67620
|
+
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS$1);
|
|
67621
|
+
}
|
|
67622
|
+
/** Compute the effective cutoff for one device at `nowMs`. */
|
|
67623
|
+
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
67624
|
+
if (settings.retentionMode === "follow-recordings") {
|
|
67625
|
+
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
67626
|
+
if (cutoff !== null) return {
|
|
67627
|
+
cutoffMs: cutoff,
|
|
67628
|
+
effectiveMode: "follow-recordings"
|
|
67629
|
+
};
|
|
67630
|
+
}
|
|
67631
|
+
return {
|
|
67632
|
+
cutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS$1,
|
|
67633
|
+
effectiveMode: "custom"
|
|
67634
|
+
};
|
|
67635
|
+
}
|
|
67636
|
+
//#endregion
|
|
67637
|
+
//#region src/pipeline-analytics/retention/archive-footage-floor.ts
|
|
67638
|
+
/**
|
|
67639
|
+
* How long one archive answer serves every camera. The ledger's floor moves
|
|
67640
|
+
* only when retention deletes an hour (minutes-to-days granularity), and the
|
|
67641
|
+
* sweep it feeds runs every 5 min — the same 10 min the walk was cached for.
|
|
67642
|
+
*/
|
|
67643
|
+
var FOOTAGE_FLOOR_TTL_MS = 10 * 6e4;
|
|
67644
|
+
/**
|
|
67645
|
+
* How long a FAILED read is reused before it is asked again. Short, so the
|
|
67646
|
+
* first sweep after the recorder answers is a real one; long enough that one
|
|
67647
|
+
* pass over ~24 cameras is one failed call, not twenty-four.
|
|
67648
|
+
*/
|
|
67649
|
+
var FOOTAGE_FLOOR_RETRY_MS = 6e4;
|
|
67650
|
+
/** What a follow-recordings camera gets when its floor could not be read:
|
|
67651
|
+
* retention OFF for this pass — nothing is deleted on a guess. */
|
|
67652
|
+
var UNREADABLE_FLOOR_CUTOFFS = {
|
|
67653
|
+
cutoffMs: null,
|
|
67654
|
+
effectiveMode: "follow-recordings",
|
|
67655
|
+
floorUnreadable: true
|
|
67656
|
+
};
|
|
67657
|
+
var NO_FOOTAGE = {
|
|
67658
|
+
kind: "read",
|
|
67659
|
+
oldestMs: null
|
|
67660
|
+
};
|
|
67661
|
+
/**
|
|
67662
|
+
* Turn one `getStorageUsage` answer into per-camera floors — or say why it
|
|
67663
|
+
* cannot be.
|
|
67664
|
+
*
|
|
67665
|
+
* - `devicesRead !== 'archive'`: the rows are UNKNOWN (`unavailable`: no
|
|
67666
|
+
* ledger, a ledger not loaded yet, no location resolved) or come from a
|
|
67667
|
+
* recorder predating D418, whose rows are RESIDENCY. No floor may be read.
|
|
67668
|
+
* - `complete !== true`: the ledger load was cut at its row cap, or the
|
|
67669
|
+
* recorder predates the field. A camera ABSENT from such an answer may hold
|
|
67670
|
+
* footage, and a PRESENT row's `oldestMs` is the oldest of a fraction (the
|
|
67671
|
+
* load keeps the newest hours) — too young, i.e. a delete. So the whole
|
|
67672
|
+
* answer is unreadable, not only the absent cameras.
|
|
67673
|
+
* - A row whose `oldestMs` is ABSENT comes from a recorder predating the
|
|
67674
|
+
* field — unknown for that camera, never "no footage".
|
|
67675
|
+
* - A camera with no row in a COMPLETE answer, or a row whose `oldestMs` is
|
|
67676
|
+
* `null`: the archive holds no footage for it — the custom window applies.
|
|
67677
|
+
*/
|
|
67678
|
+
function footageFloorsOf(usage) {
|
|
67679
|
+
if (usage.devicesRead !== "archive") return `recordingArchive.getStorageUsage answered devicesRead=${usage.devicesRead ?? "absent"} — the per-camera rows are not the archive`;
|
|
67680
|
+
if (usage.complete !== true) return `recordingArchive.getStorageUsage answered complete=${String(usage.complete ?? "absent")} — the per-camera rows may be a fraction of the archive`;
|
|
67681
|
+
const floors = /* @__PURE__ */ new Map();
|
|
67682
|
+
for (const row of usage.devices) floors.set(row.deviceId, row.oldestMs === void 0 ? {
|
|
67683
|
+
kind: "unreadable",
|
|
67684
|
+
reason: "the recorder omitted oldestMs for this camera"
|
|
67685
|
+
} : {
|
|
67686
|
+
kind: "read",
|
|
67687
|
+
oldestMs: row.oldestMs
|
|
67688
|
+
});
|
|
67689
|
+
return floors;
|
|
67690
|
+
}
|
|
67691
|
+
/**
|
|
67692
|
+
* Per-camera floors, one archive call per {@link FOOTAGE_FLOOR_TTL_MS} for the
|
|
67693
|
+
* whole fleet, single-flight.
|
|
67694
|
+
*/
|
|
67695
|
+
var ArchiveFootageFloors = class {
|
|
67696
|
+
deps;
|
|
67697
|
+
answer = null;
|
|
67698
|
+
inFlight = null;
|
|
67699
|
+
/** Cameras whose sweep is currently skipped — one line per episode, not per
|
|
67700
|
+
* pass: two sweeps × every camera × every 5 min would be a flood. */
|
|
67701
|
+
skipped = /* @__PURE__ */ new Set();
|
|
67702
|
+
/** Is the archive currently unreadable? The read-failure line is said once
|
|
67703
|
+
* per EPISODE (and its end once), not once per ~sweep per runner. */
|
|
67704
|
+
readFailing = false;
|
|
67705
|
+
constructor(deps) {
|
|
67706
|
+
this.deps = deps;
|
|
67707
|
+
}
|
|
67708
|
+
nowMs() {
|
|
67709
|
+
return this.deps.now?.() ?? Date.now();
|
|
67710
|
+
}
|
|
67711
|
+
async floorOf(deviceId) {
|
|
67712
|
+
const answer = await this.current();
|
|
67713
|
+
if ("unreadable" in answer) return {
|
|
67714
|
+
kind: "unreadable",
|
|
67715
|
+
reason: answer.unreadable
|
|
67716
|
+
};
|
|
67717
|
+
return answer.floors.get(deviceId) ?? NO_FOOTAGE;
|
|
67718
|
+
}
|
|
67719
|
+
/**
|
|
67720
|
+
* The per-device effective cutoffs (mode + footage → numbers). A follow
|
|
67721
|
+
* camera whose floor is unreadable is NOT swept this pass, and that is logged.
|
|
67722
|
+
*/
|
|
67723
|
+
async cutoffsFor(deviceId, settings, nowMs) {
|
|
67724
|
+
if (settings.retentionMode !== "follow-recordings") return resolveRetentionCutoffs(settings, nowMs, null);
|
|
67725
|
+
const floor = await this.floorOf(deviceId);
|
|
67726
|
+
if (floor.kind === "unreadable") {
|
|
67727
|
+
if (!this.skipped.has(deviceId)) {
|
|
67728
|
+
this.skipped.add(deviceId);
|
|
67729
|
+
this.deps.logger.warn("retention: follow-recordings floor unreadable — this camera is not swept until the archive answers", {
|
|
67730
|
+
tags: { deviceId },
|
|
67731
|
+
meta: { reason: floor.reason }
|
|
67732
|
+
});
|
|
67733
|
+
}
|
|
67734
|
+
return UNREADABLE_FLOOR_CUTOFFS;
|
|
67735
|
+
}
|
|
67736
|
+
if (this.skipped.delete(deviceId)) this.deps.logger.info("retention: follow-recordings floor readable again — sweeping", {
|
|
67737
|
+
tags: { deviceId },
|
|
67738
|
+
meta: { oldestMs: floor.oldestMs }
|
|
67739
|
+
});
|
|
67740
|
+
return resolveRetentionCutoffs(settings, nowMs, floor.oldestMs);
|
|
67741
|
+
}
|
|
67742
|
+
async current() {
|
|
67743
|
+
const held = this.answer;
|
|
67744
|
+
const now = this.nowMs();
|
|
67745
|
+
if (held !== null) {
|
|
67746
|
+
const ttl = "unreadable" in held ? FOOTAGE_FLOOR_RETRY_MS : FOOTAGE_FLOOR_TTL_MS;
|
|
67747
|
+
if (now - held.atMs < ttl) return held;
|
|
67748
|
+
}
|
|
67749
|
+
this.inFlight ??= this.read().finally(() => {
|
|
67750
|
+
this.inFlight = null;
|
|
67751
|
+
});
|
|
67752
|
+
return this.inFlight;
|
|
67753
|
+
}
|
|
67754
|
+
async read() {
|
|
67755
|
+
let answer;
|
|
67756
|
+
try {
|
|
67757
|
+
const floors = footageFloorsOf(await this.deps.api.recordingArchive.getStorageUsage.query({}));
|
|
67758
|
+
answer = typeof floors === "string" ? {
|
|
67759
|
+
atMs: this.nowMs(),
|
|
67760
|
+
unreadable: floors
|
|
67761
|
+
} : {
|
|
67762
|
+
atMs: this.nowMs(),
|
|
67763
|
+
floors
|
|
67764
|
+
};
|
|
67765
|
+
} catch (err) {
|
|
67766
|
+
answer = {
|
|
67767
|
+
atMs: this.nowMs(),
|
|
67768
|
+
unreadable: `recordingArchive.getStorageUsage failed: ${err instanceof Error ? err.message : String(err)}`
|
|
67769
|
+
};
|
|
67770
|
+
}
|
|
67771
|
+
if ("unreadable" in answer && !this.readFailing) {
|
|
67772
|
+
this.readFailing = true;
|
|
67773
|
+
this.deps.logger.warn("retention: the archive floor could not be read — follow-recordings cameras are not swept on a guess", { meta: {
|
|
67774
|
+
reason: answer.unreadable,
|
|
67775
|
+
retryMs: FOOTAGE_FLOOR_RETRY_MS
|
|
67776
|
+
} });
|
|
67777
|
+
} else if (!("unreadable" in answer) && this.readFailing) {
|
|
67778
|
+
this.readFailing = false;
|
|
67779
|
+
this.deps.logger.info("retention: the archive floor is readable again", { meta: { cameras: answer.floors.size } });
|
|
67780
|
+
}
|
|
67781
|
+
this.answer = answer;
|
|
67782
|
+
return answer;
|
|
67783
|
+
}
|
|
67784
|
+
};
|
|
67541
67785
|
function toDetectionBbox(bbox) {
|
|
67542
67786
|
return {
|
|
67543
67787
|
x: bbox.x,
|
|
@@ -78966,7 +79210,7 @@ async function sweepTracklessRoots(deps) {
|
|
|
78966
79210
|
const cutoffs = await deps.resolveCutoffs(deviceId, deps.nowMs);
|
|
78967
79211
|
const cutoffMs = cutoffs.cutoffMs;
|
|
78968
79212
|
if (cutoffMs === null) {
|
|
78969
|
-
deps.onRetentionDisabled(deviceId);
|
|
79213
|
+
if (cutoffs.floorUnreadable !== true) deps.onRetentionDisabled(deviceId);
|
|
78970
79214
|
continue;
|
|
78971
79215
|
}
|
|
78972
79216
|
const events = await deps.evictTracklessEvents({
|
|
@@ -78988,99 +79232,6 @@ async function sweepTracklessRoots(deps) {
|
|
|
78988
79232
|
}
|
|
78989
79233
|
}
|
|
78990
79234
|
//#endregion
|
|
78991
|
-
//#region src/pipeline-analytics/retention-policy.ts
|
|
78992
|
-
/**
|
|
78993
|
-
* Analytics retention policy — **ONE window per device** (D62).
|
|
78994
|
-
*
|
|
78995
|
-
* A device has one retention window, and everything scoped to that device is
|
|
78996
|
-
* deleted on it: the track (which cascades to its object events, their child
|
|
78997
|
-
* crops, its media, its unassigned face/plate rows and crops, and its CLIP
|
|
78998
|
-
* vector) AND the trackless roots (motion, audio, sensor events). The only
|
|
78999
|
-
* thing outside it is the ops log, which is global and longer than any device
|
|
79000
|
-
* window because it records what retention did — including for devices that no
|
|
79001
|
-
* longer exist.
|
|
79002
|
-
*
|
|
79003
|
-
* Two modes:
|
|
79004
|
-
*
|
|
79005
|
-
* - `follow-recordings` (the DEFAULT): the window is the instant of the oldest
|
|
79006
|
-
* segment still on disk. That follows the recorder's age retention AND its
|
|
79007
|
-
* disk-pressure eviction automatically, with no second policy to keep in
|
|
79008
|
-
* sync. A camera with no footage at all (recording off / brand new) falls
|
|
79009
|
-
* back to `trackRetentionDays`, so analytics stay bounded either way.
|
|
79010
|
-
*
|
|
79011
|
-
* - `custom`: `trackRetentionDays`, and nothing else. `0` = keep forever, and
|
|
79012
|
-
* that is the ONE way retention is off for a device.
|
|
79013
|
-
*
|
|
79014
|
-
* ## Why there is only one number here
|
|
79015
|
-
*
|
|
79016
|
-
* There used to be four: `trackRetentionDays` plus a day window per event
|
|
79017
|
-
* family (motion / object / audio). [D61](../../../../docs/decisions/adr-0061.md)
|
|
79018
|
-
* made object events, faces, plates, crops and vectors TRACK-OWNED — they leave
|
|
79019
|
-
* through the cascade and by no other path — which silently disconnected
|
|
79020
|
-
* `retentionObjectDays` from the thing it was named after. An operator could set
|
|
79021
|
-
* object retention to 30 days, the form would save it, and the events would die
|
|
79022
|
-
* with their track at 7. A knob that decides nothing is worse than a missing
|
|
79023
|
-
* one: it reads as a decision that was made.
|
|
79024
|
-
*
|
|
79025
|
-
* So the per-family windows are gone (D62), not merely ignored, and the
|
|
79026
|
-
* surviving surface is ENUMERATED in {@link RETENTION_SETTING_KEYS} so
|
|
79027
|
-
* `scripts/check-retention-settings-consumed.ts` can fail the build the next
|
|
79028
|
-
* time a knob and its reader come apart.
|
|
79029
|
-
*
|
|
79030
|
-
* Follow-mode floor: nothing younger than {@link FOLLOW_FLOOR_DAYS} is ever
|
|
79031
|
-
* pruned, even when footage is younger (a camera that STARTED recording
|
|
79032
|
-
* yesterday must not nuke its whole history back to the first segment).
|
|
79033
|
-
*/
|
|
79034
|
-
var DAY_MS$1 = 1440 * 60 * 1e3;
|
|
79035
|
-
/**
|
|
79036
|
-
* Per-device analytics retention settings as stored in the device blob.
|
|
79037
|
-
* `retentionMode` absent = follow-recordings (the default).
|
|
79038
|
-
*/
|
|
79039
|
-
var RetentionSettingsSchema = object({
|
|
79040
|
-
retentionMode: _enum(["follow-recordings", "custom"]).default("follow-recordings"),
|
|
79041
|
-
trackRetentionDays: number().min(0).default(7)
|
|
79042
|
-
});
|
|
79043
|
-
/** The default retention window, in days. The form's default reads from here so
|
|
79044
|
-
* the schema is the single source. */
|
|
79045
|
-
var TRACK_RETENTION_DEFAULT_DAYS = RetentionSettingsSchema.parse({}).trackRetentionDays;
|
|
79046
|
-
/** Resolve the settings from a raw device-store blob — invalid or missing
|
|
79047
|
-
* fields fall back to defaults, never throw. */
|
|
79048
|
-
function resolveRetentionSettings(raw) {
|
|
79049
|
-
const parsed = RetentionSettingsSchema.safeParse(raw);
|
|
79050
|
-
if (parsed.success) return parsed.data;
|
|
79051
|
-
const shape = RetentionSettingsSchema.shape;
|
|
79052
|
-
const defaults = RetentionSettingsSchema.parse({});
|
|
79053
|
-
const mode = shape.retentionMode.safeParse(raw["retentionMode"]);
|
|
79054
|
-
const days = shape.trackRetentionDays.safeParse(raw["trackRetentionDays"]);
|
|
79055
|
-
return {
|
|
79056
|
-
retentionMode: mode.success ? mode.data : defaults.retentionMode,
|
|
79057
|
-
trackRetentionDays: days.success ? days.data : defaults.trackRetentionDays
|
|
79058
|
-
};
|
|
79059
|
-
}
|
|
79060
|
-
/**
|
|
79061
|
-
* Follow-mode cutoff: the oldest retained footage instant, floored so anything
|
|
79062
|
-
* younger than {@link FOLLOW_FLOOR_DAYS} survives. `null` footage (none on
|
|
79063
|
-
* disk) → `null`, and the caller falls back to the custom window.
|
|
79064
|
-
*/
|
|
79065
|
-
function followCutoffMs(nowMs, earliestFootageMs) {
|
|
79066
|
-
if (earliestFootageMs === null) return null;
|
|
79067
|
-
return Math.min(earliestFootageMs, nowMs - 7 * DAY_MS$1);
|
|
79068
|
-
}
|
|
79069
|
-
/** Compute the effective cutoff for one device at `nowMs`. */
|
|
79070
|
-
function resolveRetentionCutoffs(settings, nowMs, earliestFootageMs) {
|
|
79071
|
-
if (settings.retentionMode === "follow-recordings") {
|
|
79072
|
-
const cutoff = followCutoffMs(nowMs, earliestFootageMs);
|
|
79073
|
-
if (cutoff !== null) return {
|
|
79074
|
-
cutoffMs: cutoff,
|
|
79075
|
-
effectiveMode: "follow-recordings"
|
|
79076
|
-
};
|
|
79077
|
-
}
|
|
79078
|
-
return {
|
|
79079
|
-
cutoffMs: settings.trackRetentionDays <= 0 ? null : nowMs - settings.trackRetentionDays * DAY_MS$1,
|
|
79080
|
-
effectiveMode: "custom"
|
|
79081
|
-
};
|
|
79082
|
-
}
|
|
79083
|
-
//#endregion
|
|
79084
79235
|
//#region src/pipeline-analytics/runtime/binding-cache.ts
|
|
79085
79236
|
var BindingCache = class {
|
|
79086
79237
|
api;
|
|
@@ -82466,7 +82617,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
82466
82617
|
return 0;
|
|
82467
82618
|
}
|
|
82468
82619
|
const producingDeviceName = await deps.producingDeviceName(data.deviceId);
|
|
82469
|
-
const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.
|
|
82620
|
+
const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materializeOrShare({
|
|
82470
82621
|
cameraId,
|
|
82471
82622
|
sourceDeviceId: data.deviceId,
|
|
82472
82623
|
source: "sensor",
|
|
@@ -82479,8 +82630,8 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
82479
82630
|
settled.forEach((outcome, i) => {
|
|
82480
82631
|
const cameraId = cameraIds[i];
|
|
82481
82632
|
if (cameraId === void 0) return;
|
|
82482
|
-
const
|
|
82483
|
-
if (
|
|
82633
|
+
const result = outcome.status === "fulfilled" ? outcome.value : null;
|
|
82634
|
+
if (result === null || result.kind === "not-materialized") {
|
|
82484
82635
|
deps.onSkipped({
|
|
82485
82636
|
cameraId,
|
|
82486
82637
|
sourceDeviceId: data.deviceId,
|
|
@@ -82497,9 +82648,10 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
82497
82648
|
sourceDeviceId: data.deviceId,
|
|
82498
82649
|
capName: data.capName,
|
|
82499
82650
|
kind: descriptor.kind,
|
|
82500
|
-
trackId: track.trackId,
|
|
82651
|
+
trackId: result.track.trackId,
|
|
82501
82652
|
selfCamera: cameraId === data.deviceId,
|
|
82502
|
-
hasMedia: track.snapshots.length > 0
|
|
82653
|
+
hasMedia: result.track.snapshots.length > 0,
|
|
82654
|
+
via: result.kind
|
|
82503
82655
|
});
|
|
82504
82656
|
});
|
|
82505
82657
|
return landed;
|
|
@@ -82818,7 +82970,8 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
|
82818
82970
|
/**
|
|
82819
82971
|
* What the debounce treats as "the same event happening again".
|
|
82820
82972
|
*
|
|
82821
|
-
* `(camera, kind)` — plus, for an audio row, the class HEARD
|
|
82973
|
+
* `(camera, kind)` — plus, for an audio row, the class HEARD, and for a sensor
|
|
82974
|
+
* row, the SENSOR. Since D189 every
|
|
82822
82975
|
* audio row carries `kind: 'audio'` and puts the class on `audioLabels`, so
|
|
82823
82976
|
* keying on `kind` alone would collapse this to one bucket per camera. The
|
|
82824
82977
|
* episode hysteresis cools down PER CLASS, so a bark three seconds after speech
|
|
@@ -82830,7 +82983,8 @@ var DEFAULT_DEBOUNCE_MS = 1e4;
|
|
|
82830
82983
|
*/
|
|
82831
82984
|
function debounceKey(input) {
|
|
82832
82985
|
const heard = input.audioLabels?.[0]?.label;
|
|
82833
|
-
|
|
82986
|
+
if (heard !== void 0) return `${input.cameraId}:${input.kind}:${heard}`;
|
|
82987
|
+
return input.source === "sensor" ? `${input.cameraId}:${input.kind}:${input.sourceDeviceId}` : `${input.cameraId}:${input.kind}`;
|
|
82834
82988
|
}
|
|
82835
82989
|
/** A hanging snapshot cap must not stall the sensor state-change path. */
|
|
82836
82990
|
var DEFAULT_SNAPSHOT_TIMEOUT_MS = 1e4;
|
|
@@ -82874,8 +83028,8 @@ var SyntheticTrackMaterializer = class {
|
|
|
82874
83028
|
snapshotTimeoutMs;
|
|
82875
83029
|
makeId;
|
|
82876
83030
|
now;
|
|
82877
|
-
/**
|
|
82878
|
-
|
|
83031
|
+
/** The open window per {@link debounceKey}: its start and the marker it lands. */
|
|
83032
|
+
windows = /* @__PURE__ */ new Map();
|
|
82879
83033
|
constructor(deps) {
|
|
82880
83034
|
this.deps = deps;
|
|
82881
83035
|
this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
@@ -82907,16 +83061,50 @@ var SyntheticTrackMaterializer = class {
|
|
|
82907
83061
|
return null;
|
|
82908
83062
|
}
|
|
82909
83063
|
}
|
|
83064
|
+
/** The window `input` falls in, or null when it opens a new one. */
|
|
83065
|
+
openWindow(input) {
|
|
83066
|
+
const window = this.windows.get(debounceKey(input));
|
|
83067
|
+
return window !== void 0 && this.now() - window.at < this.debounceMs ? window : null;
|
|
83068
|
+
}
|
|
83069
|
+
startWindow(input) {
|
|
83070
|
+
const landing = this.land(input);
|
|
83071
|
+
this.windows.set(debounceKey(input), {
|
|
83072
|
+
at: this.now(),
|
|
83073
|
+
landing
|
|
83074
|
+
});
|
|
83075
|
+
return landing;
|
|
83076
|
+
}
|
|
82910
83077
|
/**
|
|
82911
83078
|
* Materialize a synthetic track for a non-spatial event on `cameraId`.
|
|
82912
83079
|
* Returns the persisted track, or null when debounced. Snapshot failure
|
|
82913
83080
|
* still lands a track (with no media) — it never blocks the durable record.
|
|
83081
|
+
*
|
|
83082
|
+
* The AUDIO contract: a debounced call is a second episode that must not
|
|
83083
|
+
* adopt the first one's row, so it answers null. A SENSOR projection wants
|
|
83084
|
+
* the window's marker instead — {@link materializeOrShare}.
|
|
82914
83085
|
*/
|
|
82915
83086
|
async materialize(input) {
|
|
82916
|
-
|
|
82917
|
-
|
|
82918
|
-
|
|
82919
|
-
|
|
83087
|
+
if (this.openWindow(input) !== null) return null;
|
|
83088
|
+
return this.startWindow(input);
|
|
83089
|
+
}
|
|
83090
|
+
/**
|
|
83091
|
+
* Like {@link materialize}, but a call inside an open window answers the
|
|
83092
|
+
* window's OWN marker (awaiting it while it is still landing) with kind
|
|
83093
|
+
* `debounced`. Two state changes of one doorbell press are one event: the
|
|
83094
|
+
* second must name the same media owner, not none (2026-09-29, camera 615 —
|
|
83095
|
+
* the notification that won the rule was the debounced one, and shipped
|
|
83096
|
+
* without a still).
|
|
83097
|
+
*/
|
|
83098
|
+
async materializeOrShare(input) {
|
|
83099
|
+
const window = this.openWindow(input);
|
|
83100
|
+
const track = await (window === null ? this.startWindow(input) : window.landing);
|
|
83101
|
+
if (track === null) return { kind: "not-materialized" };
|
|
83102
|
+
return {
|
|
83103
|
+
kind: window === null ? "materialized" : "debounced",
|
|
83104
|
+
track
|
|
83105
|
+
};
|
|
83106
|
+
}
|
|
83107
|
+
async land(input) {
|
|
82920
83108
|
const trackId = this.makeId();
|
|
82921
83109
|
const ts = input.timestamp;
|
|
82922
83110
|
let mediaId = null;
|
|
@@ -95334,37 +95522,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
95334
95522
|
await this.trackCloser.sweep();
|
|
95335
95523
|
await this.summarySealer?.sweep();
|
|
95336
95524
|
}
|
|
95337
|
-
/**
|
|
95338
|
-
*
|
|
95339
|
-
*
|
|
95340
|
-
*
|
|
95341
|
-
|
|
95342
|
-
|
|
95343
|
-
|
|
95344
|
-
|
|
95345
|
-
|
|
95346
|
-
|
|
95347
|
-
|
|
95348
|
-
deviceId,
|
|
95349
|
-
fromMs: 0,
|
|
95350
|
-
toMs: Date.now()
|
|
95351
|
-
});
|
|
95352
|
-
let min = Number.POSITIVE_INFINITY;
|
|
95353
|
-
for (const r of res.ranges) if (r.startMs < min) min = r.startMs;
|
|
95354
|
-
value = Number.isFinite(min) ? min : null;
|
|
95355
|
-
} catch {
|
|
95356
|
-
value = null;
|
|
95357
|
-
}
|
|
95358
|
-
this.earliestFootageCache.set(deviceId, {
|
|
95359
|
-
at: Date.now(),
|
|
95360
|
-
value
|
|
95525
|
+
/**
|
|
95526
|
+
* The follow-recordings floor per camera, read from the ARCHIVE's hour ledger
|
|
95527
|
+
* (`recordingArchive.getStorageUsage`, one call for every camera) — never an
|
|
95528
|
+
* availability walk from the epoch, which cost the recorder 2.2 GB and an OOM
|
|
95529
|
+
* (D674). LAZY: `ctx` is not assigned when field initialisers run.
|
|
95530
|
+
*/
|
|
95531
|
+
footageFloorsInstance = null;
|
|
95532
|
+
get footageFloors() {
|
|
95533
|
+
this.footageFloorsInstance ??= new ArchiveFootageFloors({
|
|
95534
|
+
api: this.ctx.api,
|
|
95535
|
+
logger: this.ctx.logger
|
|
95361
95536
|
});
|
|
95362
|
-
return
|
|
95537
|
+
return this.footageFloorsInstance;
|
|
95363
95538
|
}
|
|
95364
|
-
/** The per-device effective cutoffs (mode + footage → numbers).
|
|
95539
|
+
/** The per-device effective cutoffs (mode + footage → numbers). A
|
|
95540
|
+
* follow-recordings camera whose floor could not be read gets NO cutoff this
|
|
95541
|
+
* pass (logged in `ArchiveFootageFloors`), never the custom window. */
|
|
95365
95542
|
async deviceRetentionCutoffs(deviceId, nowMs) {
|
|
95366
|
-
const
|
|
95367
|
-
return
|
|
95543
|
+
const raw = await this.readDeviceBlob(deviceId);
|
|
95544
|
+
return this.footageFloors.cutoffsFor(deviceId, resolveRetentionSettings(raw), nowMs);
|
|
95368
95545
|
}
|
|
95369
95546
|
/**
|
|
95370
95547
|
* The devices one retention pass covers — see `retention/retention-scope.ts`
|
|
@@ -96822,6 +96999,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
96822
96999
|
capName: r.capName,
|
|
96823
97000
|
kind: r.kind,
|
|
96824
97001
|
trackId: r.trackId,
|
|
97002
|
+
reason: r.via,
|
|
96825
97003
|
sourceDeviceId: r.sourceDeviceId,
|
|
96826
97004
|
selfCamera: r.selfCamera,
|
|
96827
97005
|
hasMedia: r.hasMedia
|
|
@@ -96842,6 +97020,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
96842
97020
|
});
|
|
96843
97021
|
return;
|
|
96844
97022
|
}
|
|
97023
|
+
if (r.cameraId !== null && r.reason === "not-materialized") {
|
|
97024
|
+
this.ctx.logger.warn("synthetic sensor-track NOT materialized", {
|
|
97025
|
+
tags: { deviceId: r.cameraId },
|
|
97026
|
+
meta: {
|
|
97027
|
+
capName: r.capName,
|
|
97028
|
+
kind: r.kind,
|
|
97029
|
+
reason: r.reason,
|
|
97030
|
+
sourceDeviceId: r.sourceDeviceId
|
|
97031
|
+
}
|
|
97032
|
+
});
|
|
97033
|
+
return;
|
|
97034
|
+
}
|
|
96845
97035
|
this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
96846
97036
|
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
96847
97037
|
meta: {
|
|
@@ -30,7 +30,7 @@ async function d(e) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
async function f() {
|
|
33
|
-
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-
|
|
33
|
+
return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B28yTZRG.mjs")).catch((e) => {
|
|
34
34
|
throw l = void 0, e;
|
|
35
35
|
}), l;
|
|
36
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-post-analysis",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.295",
|
|
4
4
|
"description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"build:widgets": "vite build --config vite.widgets.pipeline-analytics.config.ts",
|
|
133
133
|
"dev": "vite build --watch",
|
|
134
134
|
"typecheck": "tsc --noEmit",
|
|
135
|
+
"test": "vitest run",
|
|
135
136
|
"publish:npm": "npm publish --access public"
|
|
136
137
|
},
|
|
137
138
|
"peerDependencies": {
|