@camstack/addon-post-analysis 1.2.48 → 1.2.50
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/{dist-4NhN1aPx.mjs → dist-BhadCxSQ.mjs} +800 -38
- package/dist/{dist-DKhwOB5L.js → dist-Bm5GpbGn.js} +811 -37
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-Bjc_xs4R.mjs → node-Co0YpEFr.mjs} +1 -0
- package/dist/{node-XPzbKGXi.js → node-HEkeoPPw.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CD55gKkr.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B0B7ZGiM.mjs} +2 -2
- package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BfiNBOyJ.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CJ3AVT6B.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-DnhLbxcA.mjs → hostInit-BahNk1yZ.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1562 -508
- package/dist/pipeline-analytics/index.mjs +1561 -507
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-Bm5GpbGn.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_dist.__toESM(node_path);
|
|
@@ -1583,72 +1583,12 @@ function evaluateRule(rule, subject, deviceState) {
|
|
|
1583
1583
|
if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
|
|
1584
1584
|
if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
|
|
1585
1585
|
}
|
|
1586
|
-
if (!isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
|
|
1586
|
+
if (!require_dist.isScheduleActive(rule.schedule, subject.timestamp)) return fail("schedule");
|
|
1587
1587
|
return {
|
|
1588
1588
|
matched: true,
|
|
1589
1589
|
matchedOn: presentConditionIds(c)
|
|
1590
1590
|
};
|
|
1591
1591
|
}
|
|
1592
|
-
var WEEKDAY_TO_DAY = {
|
|
1593
|
-
Sun: 0,
|
|
1594
|
-
Mon: 1,
|
|
1595
|
-
Tue: 2,
|
|
1596
|
-
Wed: 3,
|
|
1597
|
-
Thu: 4,
|
|
1598
|
-
Fri: 5,
|
|
1599
|
-
Sat: 6
|
|
1600
|
-
};
|
|
1601
|
-
/** Resolve (weekday, minute-of-day) of `atMs` in the schedule's timezone.
|
|
1602
|
-
* An invalid/unknown IANA name falls back to the host timezone. */
|
|
1603
|
-
function localDayMinute(atMs, timezone) {
|
|
1604
|
-
const d = new Date(atMs);
|
|
1605
|
-
if (timezone !== void 0) try {
|
|
1606
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1607
|
-
timeZone: timezone,
|
|
1608
|
-
weekday: "short",
|
|
1609
|
-
hour: "numeric",
|
|
1610
|
-
minute: "numeric",
|
|
1611
|
-
hourCycle: "h23"
|
|
1612
|
-
}).formatToParts(d);
|
|
1613
|
-
let weekday;
|
|
1614
|
-
let hour;
|
|
1615
|
-
let minute;
|
|
1616
|
-
for (const p of parts) if (p.type === "weekday") weekday = p.value;
|
|
1617
|
-
else if (p.type === "hour") hour = Number(p.value);
|
|
1618
|
-
else if (p.type === "minute") minute = Number(p.value);
|
|
1619
|
-
const day = weekday !== void 0 ? WEEKDAY_TO_DAY[weekday] : void 0;
|
|
1620
|
-
if (day !== void 0 && hour !== void 0 && minute !== void 0) return {
|
|
1621
|
-
day,
|
|
1622
|
-
minute: hour * 60 + minute
|
|
1623
|
-
};
|
|
1624
|
-
} catch {}
|
|
1625
|
-
return {
|
|
1626
|
-
day: d.getDay(),
|
|
1627
|
-
minute: d.getHours() * 60 + d.getMinutes()
|
|
1628
|
-
};
|
|
1629
|
-
}
|
|
1630
|
-
/**
|
|
1631
|
-
* Is the schedule active at `atMs`? No schedule = always active. Windows
|
|
1632
|
-
* are OR'd; a window with `startMinute > endMinute` crosses midnight (it
|
|
1633
|
-
* starts on a listed day and spills into the next). `invert` flips the
|
|
1634
|
-
* result (active OUTSIDE the windows).
|
|
1635
|
-
*/
|
|
1636
|
-
function isScheduleActive(schedule, atMs) {
|
|
1637
|
-
if (schedule === void 0) return true;
|
|
1638
|
-
const { day, minute } = localDayMinute(atMs, schedule.timezone);
|
|
1639
|
-
const prevDay = (day + 6) % 7;
|
|
1640
|
-
let inside = false;
|
|
1641
|
-
for (const w of schedule.windows) if (w.startMinute <= w.endMinute) {
|
|
1642
|
-
if (w.days.includes(day) && minute >= w.startMinute && minute < w.endMinute) {
|
|
1643
|
-
inside = true;
|
|
1644
|
-
break;
|
|
1645
|
-
}
|
|
1646
|
-
} else if (w.days.includes(day) && minute >= w.startMinute || w.days.includes(prevDay) && minute < w.endMinute) {
|
|
1647
|
-
inside = true;
|
|
1648
|
-
break;
|
|
1649
|
-
}
|
|
1650
|
-
return schedule.invert === true ? !inside : inside;
|
|
1651
|
-
}
|
|
1652
1592
|
/** Normalize a plate string: uppercase, alphanumeric only. */
|
|
1653
1593
|
function normalizePlate$1(value) {
|
|
1654
1594
|
return value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
@@ -6812,6 +6752,34 @@ function isEdgeAwareNewBest(current, candidate, hysteresis) {
|
|
|
6812
6752
|
//#endregion
|
|
6813
6753
|
//#region src/pipeline-analytics/pipeline/label-utils.ts
|
|
6814
6754
|
/**
|
|
6755
|
+
* Every macro class the pipeline can carry on a detection. A `label` exists to
|
|
6756
|
+
* say something FINER than the class — a species, a vehicle type, a plate read,
|
|
6757
|
+
* an identity — so a value drawn from this set is by definition not a label,
|
|
6758
|
+
* whatever produced it.
|
|
6759
|
+
*
|
|
6760
|
+
* Typed against the macro unions so a new macro class is a compile error here
|
|
6761
|
+
* rather than a silent hole. `package` is a first-level macro of the default
|
|
6762
|
+
* detector that predates `FirstLevelMacro`, hence the explicit member.
|
|
6763
|
+
*/
|
|
6764
|
+
var MACRO_CLASSES = new Set([
|
|
6765
|
+
"person",
|
|
6766
|
+
"vehicle",
|
|
6767
|
+
"animal",
|
|
6768
|
+
"package",
|
|
6769
|
+
"face",
|
|
6770
|
+
"plate",
|
|
6771
|
+
"audio"
|
|
6772
|
+
]);
|
|
6773
|
+
/**
|
|
6774
|
+
* Whether a class token is a macro class. EXACT match, no case folding: plate
|
|
6775
|
+
* OCR text is upper-case and identities are capitalised, so `PLATE` (a real
|
|
6776
|
+
* read) and `Person` (a real name) must survive while `plate` / `person` (the
|
|
6777
|
+
* taxonomy tokens) must not.
|
|
6778
|
+
*/
|
|
6779
|
+
function isMacroClass(value) {
|
|
6780
|
+
return MACRO_CLASSES.has(value);
|
|
6781
|
+
}
|
|
6782
|
+
/**
|
|
6815
6783
|
* Opaque internal label markers that carry no human-readable meaning and must
|
|
6816
6784
|
* never surface on an event/track. The face-embedding (arcface) step pushes a
|
|
6817
6785
|
* `embedding:<dim>d` marker (score 1.0) onto the parent's labels — it is a
|
|
@@ -6842,12 +6810,26 @@ function pickTopLabel(labels, excludeClass) {
|
|
|
6842
6810
|
/**
|
|
6843
6811
|
* Resolve the label to stamp on an ObjectEvent / track: an enrichment label
|
|
6844
6812
|
* (identity, plate text) takes precedence; otherwise the raw detector class
|
|
6845
|
-
* (`originalClass`) when it
|
|
6813
|
+
* (`originalClass`) when it is genuinely FINER than the macro class; otherwise
|
|
6814
|
+
* nothing.
|
|
6815
|
+
*
|
|
6816
|
+
* "Finer" is load-bearing, and `originalClass !== className` alone does not
|
|
6817
|
+
* establish it. The two fields come from different time bases — `className` is
|
|
6818
|
+
* the tracker's confidence-weighted LIFETIME class vote, `originalClass` is the
|
|
6819
|
+
* class of the detection matched into the track THIS frame — and outside
|
|
6820
|
+
* `verbosity === 'full'` the runner ships no `debug.originalClass` at all, so
|
|
6821
|
+
* the FrameProcessor substitutes `det.macroClass`. A person track that a single
|
|
6822
|
+
* animal detection matched into therefore arrived here as
|
|
6823
|
+
* `{ className: 'person', originalClass: 'animal' }` and got `label: 'animal'`
|
|
6824
|
+
* written on it: a COARSE class in the slot reserved for finer information.
|
|
6825
|
+
* A macro class is never an answer, so it is never a label.
|
|
6846
6826
|
*/
|
|
6847
6827
|
function resolveDetectionLabel(input) {
|
|
6848
6828
|
const top = pickTopLabel(input.labels, input.className);
|
|
6849
6829
|
if (top) return top;
|
|
6850
|
-
|
|
6830
|
+
const raw = input.originalClass;
|
|
6831
|
+
if (raw === void 0 || raw === input.className || isMacroClass(raw)) return void 0;
|
|
6832
|
+
return raw;
|
|
6851
6833
|
}
|
|
6852
6834
|
var DEFAULT_ZONE_TRANSITION_CONFIG = {
|
|
6853
6835
|
enterFrames: 2,
|
|
@@ -11840,9 +11822,24 @@ var TrackDetailDispatcher = class {
|
|
|
11840
11822
|
dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
|
|
11841
11823
|
}
|
|
11842
11824
|
/**
|
|
11843
|
-
*
|
|
11844
|
-
*
|
|
11845
|
-
*
|
|
11825
|
+
* Walk the crop-source ladder for one request, stopping at the first rung that
|
|
11826
|
+
* produces a result. Returns the detail list, or `null` when every rung failed.
|
|
11827
|
+
*
|
|
11828
|
+
* 1. `frameHandle` — cheapest when it hits: no payload moves and the runner's
|
|
11829
|
+
* leaf children re-cut their ROI at native resolution. Tried first, always.
|
|
11830
|
+
* 2. `frameJpeg` — the freshest frame's FULL raster, fetched from the node that
|
|
11831
|
+
* owns the handle and handed to the runner UNCUT, so the rectangle still
|
|
11832
|
+
* comes from the single derivation (D52). This rung exists because rung 1 is
|
|
11833
|
+
* structurally unreachable for most dispatches: the decode worker's native
|
|
11834
|
+
* lease is 1.2 s and a tick + inference routinely lands outside it, while
|
|
11835
|
+
* the retained store holds the frame for 3 s.
|
|
11836
|
+
* 3. `cropJpeg` — the pre-cut tile. Last, because the caller decided its
|
|
11837
|
+
* rectangle and because in practice the same native miss that emptied rung 1
|
|
11838
|
+
* empties this one too.
|
|
11839
|
+
*
|
|
11840
|
+
* On D9/D18: the payload that crosses in rung 2 is a COMPRESSED JPEG fetched on
|
|
11841
|
+
* demand, per TRACK per dispatch (≤2 in flight per device, ~1 Hz) and only after
|
|
11842
|
+
* rung 1 missed — never a frame-rate stream, and never raw pixels.
|
|
11846
11843
|
*/
|
|
11847
11844
|
async runOnce(deviceId, dev, req, frame) {
|
|
11848
11845
|
const parent = {
|
|
@@ -11859,7 +11856,39 @@ var TrackDetailDispatcher = class {
|
|
|
11859
11856
|
}, frame.nodeId);
|
|
11860
11857
|
if (primary !== null) return primary.details;
|
|
11861
11858
|
} catch (err) {
|
|
11862
|
-
this.deps.logger.debug("detail primary call failed — trying
|
|
11859
|
+
this.deps.logger.debug("detail primary call failed — trying frame fallback", {
|
|
11860
|
+
tags: { deviceId },
|
|
11861
|
+
meta: {
|
|
11862
|
+
trackId: req.trackId,
|
|
11863
|
+
stepId: req.stepId,
|
|
11864
|
+
error: String(err)
|
|
11865
|
+
}
|
|
11866
|
+
});
|
|
11867
|
+
}
|
|
11868
|
+
if (this.deps.captureFullFrameBase64 !== void 0 && frame.frameHandle !== void 0) try {
|
|
11869
|
+
const full = await this.deps.captureFullFrameBase64(frame);
|
|
11870
|
+
if (full !== null) if (full.width !== frame.frameWidth || full.height !== frame.frameHeight) this.deps.logger.warn("detail full-frame fallback refused — frame is not the bbox scale", {
|
|
11871
|
+
tags: { deviceId },
|
|
11872
|
+
meta: {
|
|
11873
|
+
trackId: req.trackId,
|
|
11874
|
+
stepId: req.stepId,
|
|
11875
|
+
frameWidth: full.width,
|
|
11876
|
+
frameHeight: full.height,
|
|
11877
|
+
bboxFrameWidth: frame.frameWidth,
|
|
11878
|
+
bboxFrameHeight: frame.frameHeight
|
|
11879
|
+
}
|
|
11880
|
+
});
|
|
11881
|
+
else {
|
|
11882
|
+
const derived = await this.deps.runDetailSubtree({
|
|
11883
|
+
deviceId,
|
|
11884
|
+
frameJpeg: full.base64,
|
|
11885
|
+
parent,
|
|
11886
|
+
steps
|
|
11887
|
+
}, frame.nodeId);
|
|
11888
|
+
if (derived !== null) return derived.details;
|
|
11889
|
+
}
|
|
11890
|
+
} catch (err) {
|
|
11891
|
+
this.deps.logger.debug("detail full-frame fallback call failed", {
|
|
11863
11892
|
tags: { deviceId },
|
|
11864
11893
|
meta: {
|
|
11865
11894
|
trackId: req.trackId,
|
|
@@ -11896,12 +11925,14 @@ var TrackDetailDispatcher = class {
|
|
|
11896
11925
|
const now = Date.now();
|
|
11897
11926
|
if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
|
|
11898
11927
|
dev.lastFailWarnAt = now;
|
|
11899
|
-
this.deps.logger.warn("runDetailSubtree produced no result (
|
|
11928
|
+
this.deps.logger.warn("runDetailSubtree produced no result (every crop source missed)", {
|
|
11900
11929
|
tags: { deviceId },
|
|
11901
11930
|
meta: {
|
|
11902
11931
|
trackId: req.trackId,
|
|
11903
11932
|
stepId: req.stepId,
|
|
11904
|
-
reason: req.reason
|
|
11933
|
+
reason: req.reason,
|
|
11934
|
+
triedFullFrame: this.deps.captureFullFrameBase64 !== void 0,
|
|
11935
|
+
triedCropJpeg: this.deps.captureCropBase64 !== void 0
|
|
11905
11936
|
}
|
|
11906
11937
|
});
|
|
11907
11938
|
}
|
|
@@ -13424,6 +13455,27 @@ var StoragePressureTracker = class {
|
|
|
13424
13455
|
};
|
|
13425
13456
|
//#endregion
|
|
13426
13457
|
//#region src/pipeline-analytics/store/media-store.ts
|
|
13458
|
+
var OWNER_KINDS = [
|
|
13459
|
+
"event",
|
|
13460
|
+
"track",
|
|
13461
|
+
"face",
|
|
13462
|
+
"identity",
|
|
13463
|
+
"plate",
|
|
13464
|
+
"vehicle"
|
|
13465
|
+
];
|
|
13466
|
+
/**
|
|
13467
|
+
* Narrow an arbitrary string to an `OwnerKind`.
|
|
13468
|
+
*
|
|
13469
|
+
* Exists so a caller that builds owner scopes from data (the training-export
|
|
13470
|
+
* plan walks `track` / `face` / `plate` / `event` as strings) can reach the
|
|
13471
|
+
* store without a cast. The list is derived from `OWNER_KINDS`, so a new owner
|
|
13472
|
+
* kind is admitted here the moment it is declared there — a hand-written value
|
|
13473
|
+
* list would silently reject it, which is the failure `TrackSourceSchema` was
|
|
13474
|
+
* already bitten by on the track row.
|
|
13475
|
+
*/
|
|
13476
|
+
function isMediaOwnerKind(value) {
|
|
13477
|
+
return OWNER_KINDS.some((kind) => kind === value);
|
|
13478
|
+
}
|
|
13427
13479
|
var MEDIA_COLLECTION = "pipeline-analytics:media";
|
|
13428
13480
|
/** Owner-id prefix for a track's buffered FACE crop. Invariant established by
|
|
13429
13481
|
* the face recognizer (`face-recognizer.ts`): `faceId === 'face-' + trackId`
|
|
@@ -14647,6 +14699,7 @@ async function rankKeyEvents(candidates, options, peakLookup) {
|
|
|
14647
14699
|
bestEventId: bestEventId ?? t.trackId,
|
|
14648
14700
|
windowMs: t.lastSeen - t.firstSeen,
|
|
14649
14701
|
...t.markForTrain !== void 0 ? { markForTrain: t.markForTrain } : {},
|
|
14702
|
+
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
14650
14703
|
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
14651
14704
|
});
|
|
14652
14705
|
}
|
|
@@ -15882,8 +15935,35 @@ function classifyTrackAppearance(input) {
|
|
|
15882
15935
|
if (input.inPrevActive) return "continuing";
|
|
15883
15936
|
return input.positionsCount > 1 ? "resurrection" : "birth";
|
|
15884
15937
|
}
|
|
15885
|
-
|
|
15886
|
-
|
|
15938
|
+
/** Thrown when the device is already holding its full staging budget. Distinct
|
|
15939
|
+
* type so a surface can tell "you are out of room" from "that track is gone". */
|
|
15940
|
+
var StagingBudgetExceededError = class extends Error {
|
|
15941
|
+
deviceId;
|
|
15942
|
+
staging;
|
|
15943
|
+
constructor(deviceId, staging) {
|
|
15944
|
+
super(`setTrackFlags: device ${deviceId} already has ${staging} tracks in retrain staging (limit 500). Un-mark some tracks before marking more.`);
|
|
15945
|
+
this.deviceId = deviceId;
|
|
15946
|
+
this.staging = staging;
|
|
15947
|
+
this.name = "StagingBudgetExceededError";
|
|
15948
|
+
}
|
|
15949
|
+
};
|
|
15950
|
+
/**
|
|
15951
|
+
* Thrown when a `markForTrain` write — in EITHER direction — targets a track
|
|
15952
|
+
* that has already been trained.
|
|
15953
|
+
*
|
|
15954
|
+
* Its own error type, next to the budget's, because the two refusals need
|
|
15955
|
+
* different sentences on the surface: one says "make room", the other says "this
|
|
15956
|
+
* track is already in the dataset". Collapsing them into a generic failure is
|
|
15957
|
+
* how a toggle ends up flipping back with nothing to explain it.
|
|
15958
|
+
*/
|
|
15959
|
+
var TrackAlreadyTrainedError = class extends Error {
|
|
15960
|
+
trackId;
|
|
15961
|
+
constructor(trackId) {
|
|
15962
|
+
super(`setTrackFlags: track ${trackId} is already trained — its frames are copies in the retrain dataset. Re-staging it is an action of the retrain page, not this toggle.`);
|
|
15963
|
+
this.trackId = trackId;
|
|
15964
|
+
this.name = "TrackAlreadyTrainedError";
|
|
15965
|
+
}
|
|
15966
|
+
};
|
|
15887
15967
|
/**
|
|
15888
15968
|
* Apply a partial flag patch and report the RESOLVED state of both flags.
|
|
15889
15969
|
*
|
|
@@ -15896,19 +15976,63 @@ function classifyTrackAppearance(input) {
|
|
|
15896
15976
|
* An empty patch is a pure read, not an error: the three calling surfaces build
|
|
15897
15977
|
* the patch from their own toggle, and "nothing changed" is a legitimate outcome
|
|
15898
15978
|
* that must not surface as a failure.
|
|
15899
|
-
|
|
15900
|
-
|
|
15979
|
+
*
|
|
15980
|
+
* **`trained` is TERMINAL for this toggle, in both directions.** A trained
|
|
15981
|
+
* track's chosen frames are already copies inside the retrain dataset; staging
|
|
15982
|
+
* it again from a generic checkbox is how the same material gets annotated twice
|
|
15983
|
+
* under two different ground truths, and un-marking it would claim to undo a
|
|
15984
|
+
* training pass it cannot undo. The deliberate return to staging belongs to the
|
|
15985
|
+
* retrain page, which is also the only thing that produces `trained`. A `debug`
|
|
15986
|
+
* patch on a trained track is untouched by this — `debug` is not the lifecycle.
|
|
15987
|
+
*
|
|
15988
|
+
* **The budget is checked only when STAGING is being entered.** Un-marking is
|
|
15989
|
+
* always allowed and so is a `debug` patch — a limit that blocked the way OUT of
|
|
15990
|
+
* the limit would be a trap, and a device at its ceiling would become unable to
|
|
15991
|
+
* release the very storage the ceiling exists to protect. Re-marking a track
|
|
15992
|
+
* that is already staging is not a new hold either, so it does not consume
|
|
15993
|
+
* budget and cannot be refused by it.
|
|
15994
|
+
*/
|
|
15995
|
+
async function applyTrackFlags(deps, input) {
|
|
15996
|
+
const { store, logger } = deps;
|
|
15901
15997
|
const current = await store.readFlags(input.trackId);
|
|
15902
15998
|
if (current === null) throw new Error(`setTrackFlags: unknown track ${input.trackId}`);
|
|
15999
|
+
if (input.flags.markForTrain !== void 0 && current.retrainStatus === "trained") {
|
|
16000
|
+
logger.warn("setTrackFlags refused — track already trained", {
|
|
16001
|
+
tags: { deviceId: input.deviceId },
|
|
16002
|
+
meta: {
|
|
16003
|
+
deviceId: input.deviceId,
|
|
16004
|
+
trackId: input.trackId,
|
|
16005
|
+
requested: input.flags.markForTrain
|
|
16006
|
+
}
|
|
16007
|
+
});
|
|
16008
|
+
throw new TrackAlreadyTrainedError(input.trackId);
|
|
16009
|
+
}
|
|
16010
|
+
if (input.flags.markForTrain === true && current.retrainStatus !== "staging") {
|
|
16011
|
+
const staging = await store.countStaging({ deviceId: input.deviceId });
|
|
16012
|
+
if (staging >= 500) {
|
|
16013
|
+
logger.warn("setTrackFlags refused — device staging budget full", {
|
|
16014
|
+
tags: { deviceId: input.deviceId },
|
|
16015
|
+
meta: {
|
|
16016
|
+
deviceId: input.deviceId,
|
|
16017
|
+
trackId: input.trackId,
|
|
16018
|
+
staging,
|
|
16019
|
+
limit: 500
|
|
16020
|
+
}
|
|
16021
|
+
});
|
|
16022
|
+
throw new StagingBudgetExceededError(input.deviceId, staging);
|
|
16023
|
+
}
|
|
16024
|
+
}
|
|
15903
16025
|
const patch = {
|
|
15904
16026
|
...input.flags.markForTrain !== void 0 ? { markForTrain: input.flags.markForTrain } : {},
|
|
15905
16027
|
...input.flags.debug !== void 0 ? { debug: input.flags.debug } : {}
|
|
15906
16028
|
};
|
|
15907
16029
|
if (Object.keys(patch).length > 0) await store.setFlags(input.trackId, patch);
|
|
16030
|
+
const retrainStatus = patch.markForTrain === void 0 ? current.retrainStatus : patch.markForTrain ? "staging" : "none";
|
|
15908
16031
|
return {
|
|
15909
16032
|
trackId: input.trackId,
|
|
15910
|
-
markForTrain:
|
|
15911
|
-
debug: patch.debug ?? current.debug
|
|
16033
|
+
markForTrain: retrainStatus === "staging",
|
|
16034
|
+
debug: patch.debug ?? current.debug,
|
|
16035
|
+
retrainStatus
|
|
15912
16036
|
};
|
|
15913
16037
|
}
|
|
15914
16038
|
//#endregion
|
|
@@ -18625,6 +18749,20 @@ function rowToSensorEvent(id, data) {
|
|
|
18625
18749
|
function isRecord(x) {
|
|
18626
18750
|
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
18627
18751
|
}
|
|
18752
|
+
/**
|
|
18753
|
+
* May the interval this attempt consumed be given back?
|
|
18754
|
+
*
|
|
18755
|
+
* Pure, and deliberately takes the CURRENT clock as well as the mark: a rollback
|
|
18756
|
+
* is only correct while this attempt is still the one the gate is standing on. A
|
|
18757
|
+
* later frame's pending mark, or a snapshot that landed in between, has already
|
|
18758
|
+
* moved the reference forward and restoring an older one would re-open a window
|
|
18759
|
+
* that is legitimately closed.
|
|
18760
|
+
*/
|
|
18761
|
+
function canRollbackSnapshot(mark, currentLastSnapshotAt) {
|
|
18762
|
+
if (mark === void 0) return false;
|
|
18763
|
+
if (mark.at !== currentLastSnapshotAt) return false;
|
|
18764
|
+
return mark.priorRollbacks < 3;
|
|
18765
|
+
}
|
|
18628
18766
|
//#endregion
|
|
18629
18767
|
//#region src/pipeline-analytics/store/recent-cursor.ts
|
|
18630
18768
|
function encodeRecentCursor(cursor) {
|
|
@@ -18785,8 +18923,10 @@ var TRACKS_COLUMNS = [
|
|
|
18785
18923
|
type: "JSON"
|
|
18786
18924
|
},
|
|
18787
18925
|
{
|
|
18788
|
-
name: "
|
|
18789
|
-
type: "
|
|
18926
|
+
name: "retrainStatus",
|
|
18927
|
+
type: "TEXT",
|
|
18928
|
+
notNull: true,
|
|
18929
|
+
defaultValue: "none"
|
|
18790
18930
|
},
|
|
18791
18931
|
{
|
|
18792
18932
|
name: "debug",
|
|
@@ -18863,7 +19003,10 @@ function cloneTrack(t) {
|
|
|
18863
19003
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
18864
19004
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
18865
19005
|
...t.audioLabels !== void 0 && t.audioLabels.size > 0 ? { audioLabels: audioLabelsToArray(t.audioLabels) } : {},
|
|
18866
|
-
...t.
|
|
19006
|
+
...t.retrainStatus !== void 0 ? {
|
|
19007
|
+
retrainStatus: t.retrainStatus,
|
|
19008
|
+
markForTrain: t.retrainStatus === "staging"
|
|
19009
|
+
} : {},
|
|
18867
19010
|
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
18868
19011
|
};
|
|
18869
19012
|
}
|
|
@@ -18958,6 +19101,8 @@ var TrackStore = class {
|
|
|
18958
19101
|
t.snapshots.push(snapshot);
|
|
18959
19102
|
t.lastSnapshotAt = snapshot.timestamp;
|
|
18960
19103
|
t.lastSnapshotBbox = { ...snapshot.position.bbox };
|
|
19104
|
+
delete t.snapshotPending;
|
|
19105
|
+
t.snapshotRollbacks = 0;
|
|
18961
19106
|
}
|
|
18962
19107
|
/**
|
|
18963
19108
|
* Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
|
|
@@ -18969,18 +19114,57 @@ var TrackStore = class {
|
|
|
18969
19114
|
* snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
|
|
18970
19115
|
* rolling `lastFrame`.
|
|
18971
19116
|
*
|
|
18972
|
-
*
|
|
18973
|
-
*
|
|
18974
|
-
*
|
|
18975
|
-
*
|
|
18976
|
-
*
|
|
19117
|
+
* The advance is REVERSIBLE. It used to be permanent — this docblock said
|
|
19118
|
+
* "if the write later fails the slot is simply lost" — which made `snapshot`
|
|
19119
|
+
* the only media kind with no retry of any shape while every sibling advanced
|
|
19120
|
+
* its bookkeeping from the ids that actually persisted. See
|
|
19121
|
+
* {@link rollbackSnapshotPending} and `pipeline/snapshot-retry.ts`.
|
|
19122
|
+
*
|
|
19123
|
+
* `addSnapshot` re-stamps the same clock/bbox when the real snapshot lands, so
|
|
19124
|
+
* the two stay consistent. No-op for an unknown/expired track (the active entry
|
|
19125
|
+
* is dropped on expiry, so there is no separate map to leak).
|
|
18977
19126
|
*/
|
|
18978
19127
|
markSnapshotPending(trackId, timestamp, bbox) {
|
|
18979
19128
|
const t = this.active.get(trackId);
|
|
18980
19129
|
if (!t) return;
|
|
19130
|
+
t.snapshotPending = {
|
|
19131
|
+
at: timestamp,
|
|
19132
|
+
previous: {
|
|
19133
|
+
at: t.lastSnapshotAt,
|
|
19134
|
+
bbox: t.lastSnapshotBbox ? { ...t.lastSnapshotBbox } : void 0
|
|
19135
|
+
},
|
|
19136
|
+
priorRollbacks: t.snapshotRollbacks ?? 0
|
|
19137
|
+
};
|
|
18981
19138
|
t.lastSnapshotAt = timestamp;
|
|
18982
19139
|
t.lastSnapshotBbox = { ...bbox };
|
|
18983
19140
|
}
|
|
19141
|
+
/**
|
|
19142
|
+
* The capture decided at `timestamp` did NOT land: restore the gate reference
|
|
19143
|
+
* it displaced so the next frame re-evaluates instead of waiting out an
|
|
19144
|
+
* interval that was never used. Returns whether the slot was given back.
|
|
19145
|
+
*
|
|
19146
|
+
* Refused — deliberately — when the attempt is no longer the one the gate
|
|
19147
|
+
* stands on (a snapshot landed, or a newer frame already marked its own
|
|
19148
|
+
* pending), and once the bounded run of non-landings is spent
|
|
19149
|
+
* ({@link SNAPSHOT_MAX_ROLLBACKS}): a camera whose frames are permanently
|
|
19150
|
+
* unresolvable must not re-decide a snapshot on every frame forever.
|
|
19151
|
+
*/
|
|
19152
|
+
rollbackSnapshotPending(trackId, timestamp) {
|
|
19153
|
+
const t = this.active.get(trackId);
|
|
19154
|
+
if (!t) return false;
|
|
19155
|
+
const mark = t.snapshotPending;
|
|
19156
|
+
if (mark?.at !== timestamp) return false;
|
|
19157
|
+
if (!canRollbackSnapshot(mark, t.lastSnapshotAt)) {
|
|
19158
|
+
delete t.snapshotPending;
|
|
19159
|
+
return false;
|
|
19160
|
+
}
|
|
19161
|
+
t.lastSnapshotAt = mark.previous.at;
|
|
19162
|
+
if (mark.previous.bbox) t.lastSnapshotBbox = { ...mark.previous.bbox };
|
|
19163
|
+
else delete t.lastSnapshotBbox;
|
|
19164
|
+
t.snapshotRollbacks = mark.priorRollbacks + 1;
|
|
19165
|
+
delete t.snapshotPending;
|
|
19166
|
+
return true;
|
|
19167
|
+
}
|
|
18984
19168
|
lastSnapshotAt(trackId) {
|
|
18985
19169
|
return this.active.get(trackId)?.lastSnapshotAt ?? 0;
|
|
18986
19170
|
}
|
|
@@ -19104,24 +19288,30 @@ var TrackStore = class {
|
|
|
19104
19288
|
}
|
|
19105
19289
|
}
|
|
19106
19290
|
/**
|
|
19107
|
-
* Apply a PARTIAL patch of the per-track operator
|
|
19291
|
+
* Apply a PARTIAL patch of the per-track operator state (`markForTrain`,
|
|
19108
19292
|
* `debug`). Mirrors `setLabel`'s dual write — in-RAM active entry AND the
|
|
19109
|
-
* persisted row — so a
|
|
19293
|
+
* persisted row — so a mark set on a LIVE track is carried into
|
|
19110
19294
|
* `persistCompleted` at expiry instead of evaporating with the RAM state.
|
|
19111
19295
|
*
|
|
19296
|
+
* `markForTrain` is a LIFECYCLE move, not a boolean write: `true` is
|
|
19297
|
+
* `→ staging`, `false` is `→ none`. This method does not police the move —
|
|
19298
|
+
* the `trained` refusal lives in `applyTrackFlags`, the single cap-level write
|
|
19299
|
+
* surface, which is the only caller that has already read the current state.
|
|
19300
|
+
*
|
|
19112
19301
|
* An omitted key is left untouched; `false` is WRITTEN, not dropped, so an
|
|
19113
|
-
* explicitly un-
|
|
19302
|
+
* explicitly un-marked track stays distinguishable from one never touched.
|
|
19114
19303
|
* An empty patch is a no-op (an update with no columns is not a useful
|
|
19115
19304
|
* statement, and the store would reject or silently ignore it).
|
|
19116
19305
|
*/
|
|
19117
19306
|
async setFlags(trackId, patch) {
|
|
19118
19307
|
const data = {};
|
|
19119
|
-
|
|
19308
|
+
const nextStatus = patch.markForTrain === void 0 ? void 0 : patch.markForTrain ? "staging" : "none";
|
|
19309
|
+
if (nextStatus !== void 0) data["retrainStatus"] = nextStatus;
|
|
19120
19310
|
if (patch.debug !== void 0) data["debug"] = patch.debug;
|
|
19121
19311
|
if (Object.keys(data).length === 0) return;
|
|
19122
19312
|
const active = this.active.get(trackId);
|
|
19123
19313
|
if (active) {
|
|
19124
|
-
if (
|
|
19314
|
+
if (nextStatus !== void 0) active.retrainStatus = nextStatus;
|
|
19125
19315
|
if (patch.debug !== void 0) active.debug = patch.debug;
|
|
19126
19316
|
}
|
|
19127
19317
|
try {
|
|
@@ -19138,20 +19328,25 @@ var TrackStore = class {
|
|
|
19138
19328
|
}
|
|
19139
19329
|
}
|
|
19140
19330
|
/**
|
|
19141
|
-
* Resolved
|
|
19142
|
-
* row.
|
|
19143
|
-
*
|
|
19331
|
+
* Resolved operator state for a track — active entry first, then the
|
|
19332
|
+
* persisted row. `null` when the track is unknown to both.
|
|
19333
|
+
*
|
|
19334
|
+
* Reports the LIFECYCLE STATUS, not the derived boolean, because the one
|
|
19335
|
+
* caller that matters decides on it: `applyTrackFlags` must tell `trained`
|
|
19336
|
+
* (refuse the write) from `none` (accept it), and both report
|
|
19337
|
+
* `markForTrain: false`. Absent collapses to `'none'` / `false` — the
|
|
19338
|
+
* "never touched" tri-state exists only in RAM and carries no decision.
|
|
19144
19339
|
*/
|
|
19145
19340
|
async readFlags(trackId) {
|
|
19146
19341
|
const active = this.active.get(trackId);
|
|
19147
19342
|
if (active) return {
|
|
19148
|
-
|
|
19343
|
+
retrainStatus: active.retrainStatus ?? "none",
|
|
19149
19344
|
debug: active.debug === true
|
|
19150
19345
|
};
|
|
19151
19346
|
const persisted = await this.getPersistedByTrackId(trackId);
|
|
19152
19347
|
if (!persisted) return null;
|
|
19153
19348
|
return {
|
|
19154
|
-
|
|
19349
|
+
retrainStatus: persisted.retrainStatus ?? "none",
|
|
19155
19350
|
debug: persisted.debug === true
|
|
19156
19351
|
};
|
|
19157
19352
|
}
|
|
@@ -19216,10 +19411,11 @@ var TrackStore = class {
|
|
|
19216
19411
|
* `offset` by the number of rows a page did NOT remove, which terminates
|
|
19217
19412
|
* because the offset only grows and the result set is finite.
|
|
19218
19413
|
*
|
|
19219
|
-
* This is the seam
|
|
19220
|
-
*
|
|
19221
|
-
*
|
|
19222
|
-
*
|
|
19414
|
+
* This is the seam the retention PIN lands on, and it lands here ONLY: after
|
|
19415
|
+
* D61 the track row is the single cascading root, so one exemption on this one
|
|
19416
|
+
* query holds the track's events, crops, keyframes and CLIP vector with it —
|
|
19417
|
+
* where D59 had to plan for four exemptions across four disagreeing sweeps.
|
|
19418
|
+
* The pin is `whereIn: { retrainStatus: ['none','trained'] }` below (D81).
|
|
19223
19419
|
*
|
|
19224
19420
|
* Filter shape mirrors `EventStore.pruneBefore`: inclusive `[0, cutoffMs - 1]`
|
|
19225
19421
|
* → strictly `< cutoffMs`. Best-effort — a query failure yields [].
|
|
@@ -19230,6 +19426,7 @@ var TrackStore = class {
|
|
|
19230
19426
|
collection: TRACKS_COLLECTION,
|
|
19231
19427
|
filter: {
|
|
19232
19428
|
where: { deviceId },
|
|
19429
|
+
whereIn: { retrainStatus: ["none", "trained"] },
|
|
19233
19430
|
whereBetween: { lastSeen: [0, cutoffMs - 1] },
|
|
19234
19431
|
orderBy: {
|
|
19235
19432
|
field: "lastSeen",
|
|
@@ -19253,6 +19450,87 @@ var TrackStore = class {
|
|
|
19253
19450
|
}
|
|
19254
19451
|
}
|
|
19255
19452
|
/**
|
|
19453
|
+
* The device's STAGING tracks, newest first — the training-export selection.
|
|
19454
|
+
*
|
|
19455
|
+
* `retrainStatus = 'staging'` is exactly the complement of the sweep's
|
|
19456
|
+
* `whereIn ['none','trained']` (all three safe against NULL because the column
|
|
19457
|
+
* is NOT NULL DEFAULT 'none') — the load-bearing part: this is the set
|
|
19458
|
+
* retention has promised to still be there. `trained` is deliberately NOT in
|
|
19459
|
+
* it: the sweep is free to delete a trained track mid-pass, and exporting one
|
|
19460
|
+
* is how a manifest ends up describing files the archive does not contain.
|
|
19461
|
+
*
|
|
19462
|
+
* `projection: 'slim'` because an export enumerates media; it has no use for
|
|
19463
|
+
* the per-frame trajectory JSON, which is the heaviest column on the row.
|
|
19464
|
+
*/
|
|
19465
|
+
async listStaging(deviceId, limit) {
|
|
19466
|
+
try {
|
|
19467
|
+
return (await this.store.query.query({
|
|
19468
|
+
collection: TRACKS_COLLECTION,
|
|
19469
|
+
filter: {
|
|
19470
|
+
where: {
|
|
19471
|
+
deviceId,
|
|
19472
|
+
retrainStatus: "staging"
|
|
19473
|
+
},
|
|
19474
|
+
orderBy: {
|
|
19475
|
+
field: "lastSeen",
|
|
19476
|
+
direction: "desc"
|
|
19477
|
+
},
|
|
19478
|
+
limit
|
|
19479
|
+
}
|
|
19480
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
|
|
19481
|
+
} catch (err) {
|
|
19482
|
+
this.logger.warn("TrackStore.listStaging failed", {
|
|
19483
|
+
tags: { deviceId },
|
|
19484
|
+
meta: {
|
|
19485
|
+
deviceId,
|
|
19486
|
+
limit,
|
|
19487
|
+
error: String(err)
|
|
19488
|
+
}
|
|
19489
|
+
});
|
|
19490
|
+
return [];
|
|
19491
|
+
}
|
|
19492
|
+
}
|
|
19493
|
+
/**
|
|
19494
|
+
* How many of this device's tracks are STAGING — optionally only those older
|
|
19495
|
+
* than a cutoff, i.e. exactly the rows a sweep just declined to touch.
|
|
19496
|
+
*
|
|
19497
|
+
* Two callers, one query. The sweep uses `olderThanMs` for its report; the
|
|
19498
|
+
* `setTrackFlags` body uses the unbounded form to enforce the per-device
|
|
19499
|
+
* staging budget, because staging is unbounded in TIME by construction and
|
|
19500
|
+
* something has to bound it in COUNT (D81, and D59 asked for exactly this).
|
|
19501
|
+
* `trained` tracks are NOT counted — they are evictable again, so they cost
|
|
19502
|
+
* the budget nothing.
|
|
19503
|
+
*
|
|
19504
|
+
* Best-effort — a failure reports 0 rather than disturbing the caller. The
|
|
19505
|
+
* budget check reads that as "there is room", which is the safe direction: a
|
|
19506
|
+
* failed count must not stop an operator marking a track.
|
|
19507
|
+
*/
|
|
19508
|
+
async countStaging(query) {
|
|
19509
|
+
const { deviceId, olderThanMs } = query;
|
|
19510
|
+
try {
|
|
19511
|
+
return await this.store.count.query({
|
|
19512
|
+
collection: TRACKS_COLLECTION,
|
|
19513
|
+
filter: {
|
|
19514
|
+
where: {
|
|
19515
|
+
deviceId,
|
|
19516
|
+
retrainStatus: "staging"
|
|
19517
|
+
},
|
|
19518
|
+
...olderThanMs !== void 0 ? { whereBetween: { lastSeen: [0, olderThanMs - 1] } } : {}
|
|
19519
|
+
}
|
|
19520
|
+
});
|
|
19521
|
+
} catch (err) {
|
|
19522
|
+
this.logger.warn("TrackStore.countStaging failed", {
|
|
19523
|
+
tags: { deviceId },
|
|
19524
|
+
meta: {
|
|
19525
|
+
deviceId,
|
|
19526
|
+
olderThanMs,
|
|
19527
|
+
error: String(err)
|
|
19528
|
+
}
|
|
19529
|
+
});
|
|
19530
|
+
return 0;
|
|
19531
|
+
}
|
|
19532
|
+
}
|
|
19533
|
+
/**
|
|
19256
19534
|
* The SUBSET of `trackIds` that still have a persisted row — the orphan
|
|
19257
19535
|
* audit's liveness probe.
|
|
19258
19536
|
*
|
|
@@ -19592,7 +19870,7 @@ var TrackStore = class {
|
|
|
19592
19870
|
...t.bestEventId !== void 0 ? { bestEventId: t.bestEventId } : {},
|
|
19593
19871
|
...t.importanceReason !== void 0 ? { importanceReason: t.importanceReason } : {},
|
|
19594
19872
|
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
19595
|
-
...t.
|
|
19873
|
+
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
19596
19874
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
19597
19875
|
...envelope !== null && dims !== void 0 ? {
|
|
19598
19876
|
envMinX: envelope.minX,
|
|
@@ -19626,7 +19904,7 @@ var TrackStore = class {
|
|
|
19626
19904
|
const bestEventId = data["bestEventId"];
|
|
19627
19905
|
const importanceReason = data["importanceReason"];
|
|
19628
19906
|
const audioLabels = data["audioLabels"];
|
|
19629
|
-
const
|
|
19907
|
+
const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
|
|
19630
19908
|
const debug = data["debug"];
|
|
19631
19909
|
const envMinX = data["envMinX"];
|
|
19632
19910
|
const envMinY = data["envMinY"];
|
|
@@ -19658,7 +19936,10 @@ var TrackStore = class {
|
|
|
19658
19936
|
...typeof bestEventId === "string" ? { bestEventId } : {},
|
|
19659
19937
|
...typeof importanceReason === "string" ? { importanceReason } : {},
|
|
19660
19938
|
...Array.isArray(audioLabels) && audioLabels.length > 0 ? { audioLabels } : {},
|
|
19661
|
-
...
|
|
19939
|
+
...retrainParsed.success ? {
|
|
19940
|
+
retrainStatus: retrainParsed.data,
|
|
19941
|
+
markForTrain: retrainParsed.data === "staging"
|
|
19942
|
+
} : {},
|
|
19662
19943
|
...typeof debug === "boolean" ? { debug } : {},
|
|
19663
19944
|
...envelope !== null ? { envelope } : {}
|
|
19664
19945
|
};
|
|
@@ -21101,6 +21382,35 @@ function createNativeFrameTransport(deps) {
|
|
|
21101
21382
|
};
|
|
21102
21383
|
};
|
|
21103
21384
|
const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
|
|
21385
|
+
const getFullFrameJpegBase64 = async (handle, maxWidth) => {
|
|
21386
|
+
if (!pipelineRunnerApi?.getNativeCrop) return null;
|
|
21387
|
+
try {
|
|
21388
|
+
const full = await pipelineRunnerApi.getNativeCrop.query({
|
|
21389
|
+
handle,
|
|
21390
|
+
bbox: {
|
|
21391
|
+
x: 0,
|
|
21392
|
+
y: 0,
|
|
21393
|
+
w: 1,
|
|
21394
|
+
h: 1
|
|
21395
|
+
},
|
|
21396
|
+
maxWidth,
|
|
21397
|
+
encodeJpeg: true
|
|
21398
|
+
}, require_dist.nodePin(handle.nodeId));
|
|
21399
|
+
if (!full?.jpeg || full.width <= 0 || full.height <= 0) return null;
|
|
21400
|
+
return {
|
|
21401
|
+
base64: full.jpeg,
|
|
21402
|
+
width: full.width,
|
|
21403
|
+
height: full.height,
|
|
21404
|
+
tier: full.tier === "ram-fullframe" ? "ram-fullframe" : "native"
|
|
21405
|
+
};
|
|
21406
|
+
} catch (err) {
|
|
21407
|
+
logger.debug("full-frame jpeg fetch failed", { meta: {
|
|
21408
|
+
nodeId: handle.nodeId,
|
|
21409
|
+
error: require_dist.errMsg(err)
|
|
21410
|
+
} });
|
|
21411
|
+
return null;
|
|
21412
|
+
}
|
|
21413
|
+
};
|
|
21104
21414
|
const cropMetricLogger = logger.child("NativeCrop");
|
|
21105
21415
|
let nativeHits = 0;
|
|
21106
21416
|
let nativeFallbacks = 0;
|
|
@@ -21151,6 +21461,7 @@ function createNativeFrameTransport(deps) {
|
|
|
21151
21461
|
getRemoteFrame,
|
|
21152
21462
|
getNativeFullFrameRgb,
|
|
21153
21463
|
getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
|
|
21464
|
+
getFullFrameJpegBase64,
|
|
21154
21465
|
getNativeCropJpeg,
|
|
21155
21466
|
captureCrop: createCaptureCrop({
|
|
21156
21467
|
tryNativeCrop,
|
|
@@ -21507,69 +21818,389 @@ function wireSubscriptions(deps) {
|
|
|
21507
21818
|
}));
|
|
21508
21819
|
return unsubs;
|
|
21509
21820
|
}
|
|
21510
|
-
//#endregion
|
|
21511
|
-
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
21512
21821
|
/**
|
|
21513
|
-
*
|
|
21514
|
-
*
|
|
21515
|
-
*
|
|
21516
|
-
*
|
|
21517
|
-
*
|
|
21518
|
-
* (c) sensor kinds contributed by LINKED devices (device-manager
|
|
21519
|
-
* `getLinkedDevices`), one descriptor per bound sensor cap present in
|
|
21520
|
-
* the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
|
|
21521
|
-
* (`getBindings`) — never a global cap enumeration (D12).
|
|
21522
|
-
*
|
|
21523
|
-
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
21524
|
-
* linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
|
|
21525
|
-
* cheap at bus rate.
|
|
21822
|
+
* A baseline whose last sample is older than `baselineMs × this` no longer
|
|
21823
|
+
* describes the camera — an on-motion camera detaches for hours and comes
|
|
21824
|
+
* back to a different scene. Decaying it forward would make the first frame
|
|
21825
|
+
* back either a guaranteed marker (mean still holds the old quiet) or a
|
|
21826
|
+
* guaranteed miss; discarding it is the honest option and costs one warm-up.
|
|
21526
21827
|
*/
|
|
21828
|
+
var STALE_BASELINE_FACTOR = 5;
|
|
21527
21829
|
/**
|
|
21528
|
-
*
|
|
21529
|
-
*
|
|
21530
|
-
*
|
|
21531
|
-
*
|
|
21532
|
-
* (d) package kinds when a package zone rule is enabled;
|
|
21533
|
-
* (e) sensor/control kinds from LINKED devices (binding-driven), deduped
|
|
21534
|
-
* per (kind, source deviceId).
|
|
21535
|
-
* Every descriptor carries parentKind/level/iconId/labelKey via the single
|
|
21536
|
-
* taxonomy dictionary — no color/icon is declared here.
|
|
21830
|
+
* Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
|
|
21831
|
+
* has MAD 0 and would make every deviation infinite. 1 dB is below the
|
|
21832
|
+
* quantisation of any real level meter, so it only ever binds on a synthetic
|
|
21833
|
+
* or a genuinely dead-steady signal — where bar 2 is what should decide.
|
|
21537
21834
|
*/
|
|
21835
|
+
var MIN_MAD_DB = 1;
|
|
21836
|
+
function emptyAudioBaseline() {
|
|
21837
|
+
return {
|
|
21838
|
+
count: 0,
|
|
21839
|
+
mean: 0,
|
|
21840
|
+
mad: 0,
|
|
21841
|
+
firstMs: 0,
|
|
21842
|
+
lastMs: 0
|
|
21843
|
+
};
|
|
21844
|
+
}
|
|
21845
|
+
/** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
|
|
21846
|
+
function seed(sample) {
|
|
21847
|
+
return {
|
|
21848
|
+
count: 1,
|
|
21849
|
+
mean: sample.dbfs,
|
|
21850
|
+
mad: 0,
|
|
21851
|
+
firstMs: sample.timestampMs,
|
|
21852
|
+
lastMs: sample.timestampMs
|
|
21853
|
+
};
|
|
21854
|
+
}
|
|
21538
21855
|
/**
|
|
21539
|
-
*
|
|
21856
|
+
* Fold `sample` into `state` and decide whether it is a marker candidate.
|
|
21540
21857
|
*
|
|
21541
|
-
*
|
|
21542
|
-
*
|
|
21543
|
-
*
|
|
21544
|
-
*
|
|
21545
|
-
* is an unhandled rejection.
|
|
21858
|
+
* The verdict is measured against the PRIOR baseline (this frame excluded) so
|
|
21859
|
+
* a lone spike is not diluted by itself — the same reasoning
|
|
21860
|
+
* `audio-level-detector` uses for its `priorMean`. The state returned always
|
|
21861
|
+
* includes the sample, whatever the verdict.
|
|
21546
21862
|
*/
|
|
21547
|
-
|
|
21548
|
-
|
|
21549
|
-
|
|
21550
|
-
|
|
21551
|
-
|
|
21552
|
-
|
|
21553
|
-
|
|
21554
|
-
}
|
|
21555
|
-
async function composeEventKinds(deps, deviceId) {
|
|
21556
|
-
const out = [];
|
|
21557
|
-
const cameraSource = {
|
|
21558
|
-
capName: "pipeline-analytics",
|
|
21559
|
-
deviceId
|
|
21863
|
+
function observeAudioAnomaly(state, sample, cfg) {
|
|
21864
|
+
const isFirst = state.count === 0;
|
|
21865
|
+
const gapMs = sample.timestampMs - state.lastMs;
|
|
21866
|
+
const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
|
|
21867
|
+
if (isFirst || isStale) return {
|
|
21868
|
+
verdict: { kind: "warmup" },
|
|
21869
|
+
nextState: seed(sample)
|
|
21560
21870
|
};
|
|
21561
|
-
const
|
|
21562
|
-
|
|
21563
|
-
|
|
21871
|
+
const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
|
|
21872
|
+
const priorMean = state.mean;
|
|
21873
|
+
const priorMad = state.mad;
|
|
21874
|
+
const nextState = {
|
|
21875
|
+
count: state.count + 1,
|
|
21876
|
+
mean: priorMean + alpha * (sample.dbfs - priorMean),
|
|
21877
|
+
mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
|
|
21878
|
+
firstMs: state.firstMs,
|
|
21879
|
+
lastMs: sample.timestampMs
|
|
21564
21880
|
};
|
|
21565
|
-
|
|
21566
|
-
|
|
21567
|
-
|
|
21568
|
-
|
|
21569
|
-
|
|
21570
|
-
|
|
21571
|
-
|
|
21572
|
-
|
|
21881
|
+
if (sample.dbfs <= cfg.silenceFloorDbfs) return {
|
|
21882
|
+
verdict: { kind: "silent" },
|
|
21883
|
+
nextState
|
|
21884
|
+
};
|
|
21885
|
+
if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
|
|
21886
|
+
verdict: { kind: "warmup" },
|
|
21887
|
+
nextState
|
|
21888
|
+
};
|
|
21889
|
+
if (sample.dbfs < cfg.minDbfs) return {
|
|
21890
|
+
verdict: {
|
|
21891
|
+
kind: "not-loud",
|
|
21892
|
+
dbfs: sample.dbfs
|
|
21893
|
+
},
|
|
21894
|
+
nextState
|
|
21895
|
+
};
|
|
21896
|
+
const deviationDb = sample.dbfs - priorMean;
|
|
21897
|
+
const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
|
|
21898
|
+
const measured = {
|
|
21899
|
+
dbfs: sample.dbfs,
|
|
21900
|
+
mean: priorMean,
|
|
21901
|
+
deviationDb,
|
|
21902
|
+
deviations
|
|
21903
|
+
};
|
|
21904
|
+
return {
|
|
21905
|
+
verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
|
|
21906
|
+
kind: "anomalous",
|
|
21907
|
+
...measured
|
|
21908
|
+
} : {
|
|
21909
|
+
kind: "not-anomalous",
|
|
21910
|
+
...measured
|
|
21911
|
+
},
|
|
21912
|
+
nextState
|
|
21913
|
+
};
|
|
21914
|
+
}
|
|
21915
|
+
//#endregion
|
|
21916
|
+
//#region src/pipeline-analytics/services/audio-marker-projector.ts
|
|
21917
|
+
/** One info line per camera per minute, and only when something qualified. */
|
|
21918
|
+
var METRICS_WINDOW_MS = 6e4;
|
|
21919
|
+
var HOUR_MS = 60 * 6e4;
|
|
21920
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
21921
|
+
function emptyCounters(atMs) {
|
|
21922
|
+
return {
|
|
21923
|
+
windowStartMs: atMs,
|
|
21924
|
+
enabled: false,
|
|
21925
|
+
observed: 0,
|
|
21926
|
+
notLoud: 0,
|
|
21927
|
+
notAnomalous: 0,
|
|
21928
|
+
candidates: 0,
|
|
21929
|
+
suppressedMotion: 0,
|
|
21930
|
+
suppressedCooldown: 0,
|
|
21931
|
+
suppressedHourly: 0,
|
|
21932
|
+
suppressedDaily: 0,
|
|
21933
|
+
suppressedInFlight: 0,
|
|
21934
|
+
wouldFire: 0,
|
|
21935
|
+
materialized: 0,
|
|
21936
|
+
materializeFailed: 0
|
|
21937
|
+
};
|
|
21938
|
+
}
|
|
21939
|
+
var AudioMarkerProjector = class {
|
|
21940
|
+
deps;
|
|
21941
|
+
baselines = /* @__PURE__ */ new Map();
|
|
21942
|
+
rates = /* @__PURE__ */ new Map();
|
|
21943
|
+
counters = /* @__PURE__ */ new Map();
|
|
21944
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
21945
|
+
constructor(deps) {
|
|
21946
|
+
this.deps = deps;
|
|
21947
|
+
}
|
|
21948
|
+
/**
|
|
21949
|
+
* Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
|
|
21950
|
+
* the common path must not allocate a promise, so the decision is taken
|
|
21951
|
+
* inline and only an actual marker goes async (fire-and-forget, bounded by
|
|
21952
|
+
* the in-flight guard and the rate ceilings).
|
|
21953
|
+
*
|
|
21954
|
+
* Call this BEFORE the classification/level route split so the baseline
|
|
21955
|
+
* sees every frame. A baseline fed only by the level route is blind to
|
|
21956
|
+
* exactly the loud, confidently-classified frames a marker is for.
|
|
21957
|
+
*/
|
|
21958
|
+
observe(input, cfg) {
|
|
21959
|
+
const { deviceId, timestampMs } = input;
|
|
21960
|
+
const counters = this.countersFor(deviceId, timestampMs);
|
|
21961
|
+
counters.observed += 1;
|
|
21962
|
+
counters.enabled = cfg.enabled;
|
|
21963
|
+
const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
|
|
21964
|
+
timestampMs,
|
|
21965
|
+
dbfs: input.dbfs
|
|
21966
|
+
}, cfg);
|
|
21967
|
+
this.baselines.set(deviceId, res.nextState);
|
|
21968
|
+
this.maybeFlush(deviceId, timestampMs);
|
|
21969
|
+
switch (res.verdict.kind) {
|
|
21970
|
+
case "silent":
|
|
21971
|
+
case "warmup": return;
|
|
21972
|
+
case "not-loud":
|
|
21973
|
+
counters.notLoud += 1;
|
|
21974
|
+
return;
|
|
21975
|
+
case "not-anomalous":
|
|
21976
|
+
counters.notAnomalous += 1;
|
|
21977
|
+
return;
|
|
21978
|
+
case "anomalous": break;
|
|
21979
|
+
}
|
|
21980
|
+
counters.candidates += 1;
|
|
21981
|
+
if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
|
|
21982
|
+
counters.suppressedMotion += 1;
|
|
21983
|
+
return;
|
|
21984
|
+
}
|
|
21985
|
+
const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
|
|
21986
|
+
const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
|
|
21987
|
+
if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
|
|
21988
|
+
counters.suppressedCooldown += 1;
|
|
21989
|
+
return;
|
|
21990
|
+
}
|
|
21991
|
+
const recent = fired.filter((t) => timestampMs - t < DAY_MS);
|
|
21992
|
+
if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
|
|
21993
|
+
counters.suppressedHourly += 1;
|
|
21994
|
+
this.rates.set(deviceId, { firedAtMs: recent });
|
|
21995
|
+
return;
|
|
21996
|
+
}
|
|
21997
|
+
if (recent.length >= cfg.maxPerDay) {
|
|
21998
|
+
counters.suppressedDaily += 1;
|
|
21999
|
+
this.rates.set(deviceId, { firedAtMs: recent });
|
|
22000
|
+
return;
|
|
22001
|
+
}
|
|
22002
|
+
if (!cfg.enabled) {
|
|
22003
|
+
counters.wouldFire += 1;
|
|
22004
|
+
return;
|
|
22005
|
+
}
|
|
22006
|
+
if (this.inFlight.has(deviceId)) {
|
|
22007
|
+
counters.suppressedInFlight += 1;
|
|
22008
|
+
return;
|
|
22009
|
+
}
|
|
22010
|
+
this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
|
|
22011
|
+
counters.materialized += 1;
|
|
22012
|
+
this.inFlight.add(deviceId);
|
|
22013
|
+
this.materialize(input, counters);
|
|
22014
|
+
}
|
|
22015
|
+
/**
|
|
22016
|
+
* Emit and reset a camera's window if it has anything to say. Exposed so a
|
|
22017
|
+
* shutdown (or a test) can force the tally out rather than losing it.
|
|
22018
|
+
*/
|
|
22019
|
+
flush(nowMs) {
|
|
22020
|
+
for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
|
|
22021
|
+
}
|
|
22022
|
+
/** Drop all per-camera state for a device (unbind / device removal). */
|
|
22023
|
+
forgetDevice(deviceId) {
|
|
22024
|
+
this.baselines.delete(deviceId);
|
|
22025
|
+
this.rates.delete(deviceId);
|
|
22026
|
+
this.counters.delete(deviceId);
|
|
22027
|
+
this.inFlight.delete(deviceId);
|
|
22028
|
+
}
|
|
22029
|
+
async materialize(input, counters) {
|
|
22030
|
+
const { deviceId, timestampMs } = input;
|
|
22031
|
+
try {
|
|
22032
|
+
const audioLabels = buildAudioLabels(input);
|
|
22033
|
+
await this.deps.sink.materialize({
|
|
22034
|
+
cameraId: deviceId,
|
|
22035
|
+
sourceDeviceId: deviceId,
|
|
22036
|
+
source: "audio",
|
|
22037
|
+
kind: "audio",
|
|
22038
|
+
timestamp: timestampMs,
|
|
22039
|
+
...audioLabels !== void 0 ? { audioLabels } : {}
|
|
22040
|
+
});
|
|
22041
|
+
} catch (err) {
|
|
22042
|
+
counters.materialized -= 1;
|
|
22043
|
+
counters.materializeFailed += 1;
|
|
22044
|
+
this.deps.onError?.("materializeAudioMarker", err);
|
|
22045
|
+
this.deps.logger.warn("audio marker materialize failed", {
|
|
22046
|
+
tags: { deviceId },
|
|
22047
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
22048
|
+
});
|
|
22049
|
+
} finally {
|
|
22050
|
+
this.inFlight.delete(deviceId);
|
|
22051
|
+
}
|
|
22052
|
+
}
|
|
22053
|
+
countersFor(deviceId, atMs) {
|
|
22054
|
+
const existing = this.counters.get(deviceId);
|
|
22055
|
+
if (existing !== void 0) return existing;
|
|
22056
|
+
const fresh = emptyCounters(atMs);
|
|
22057
|
+
this.counters.set(deviceId, fresh);
|
|
22058
|
+
return fresh;
|
|
22059
|
+
}
|
|
22060
|
+
maybeFlush(deviceId, nowMs) {
|
|
22061
|
+
const c = this.counters.get(deviceId);
|
|
22062
|
+
if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22063
|
+
this.emitWindow(deviceId, nowMs, false);
|
|
22064
|
+
}
|
|
22065
|
+
/**
|
|
22066
|
+
* One line per camera per window — but ONLY when a frame reached the
|
|
22067
|
+
* candidate gate. A camera that heard nothing extraordinary has nothing to
|
|
22068
|
+
* report, and a line per camera per minute forever would bury the ones that
|
|
22069
|
+
* do. `force` still honours that: it flushes the window, it does not invent
|
|
22070
|
+
* a reason to speak.
|
|
22071
|
+
*/
|
|
22072
|
+
emitWindow(deviceId, nowMs, force) {
|
|
22073
|
+
const c = this.counters.get(deviceId);
|
|
22074
|
+
if (c === void 0) return;
|
|
22075
|
+
if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22076
|
+
if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
|
|
22077
|
+
tags: { deviceId },
|
|
22078
|
+
meta: {
|
|
22079
|
+
enabled: c.enabled,
|
|
22080
|
+
windowMs: nowMs - c.windowStartMs,
|
|
22081
|
+
observed: c.observed,
|
|
22082
|
+
notLoud: c.notLoud,
|
|
22083
|
+
notAnomalous: c.notAnomalous,
|
|
22084
|
+
candidates: c.candidates,
|
|
22085
|
+
suppressedMotion: c.suppressedMotion,
|
|
22086
|
+
suppressedCooldown: c.suppressedCooldown,
|
|
22087
|
+
suppressedHourly: c.suppressedHourly,
|
|
22088
|
+
suppressedDaily: c.suppressedDaily,
|
|
22089
|
+
suppressedInFlight: c.suppressedInFlight,
|
|
22090
|
+
wouldFire: c.wouldFire,
|
|
22091
|
+
materialized: c.materialized,
|
|
22092
|
+
materializeFailed: c.materializeFailed
|
|
22093
|
+
}
|
|
22094
|
+
});
|
|
22095
|
+
this.counters.set(deviceId, emptyCounters(nowMs));
|
|
22096
|
+
}
|
|
22097
|
+
};
|
|
22098
|
+
/**
|
|
22099
|
+
* The class heard on the anomalous frame, in the shape `Track.audioLabels`
|
|
22100
|
+
* already defines. Using that field is what lets a marker label itself
|
|
22101
|
+
* ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
|
|
22102
|
+
* the audio macro vocabulary collides with existing subs (`dog` is a COCO
|
|
22103
|
+
* animal, `siren` is a control), so a sub per audio class would put a barking
|
|
22104
|
+
* dog in the ANIMAL lane.
|
|
22105
|
+
*/
|
|
22106
|
+
function buildAudioLabels(input) {
|
|
22107
|
+
if (input.className === void 0) return void 0;
|
|
22108
|
+
return [{
|
|
22109
|
+
label: input.className,
|
|
22110
|
+
peakScore: input.score ?? 0,
|
|
22111
|
+
count: 1,
|
|
22112
|
+
firstAt: input.timestampMs,
|
|
22113
|
+
lastAt: input.timestampMs
|
|
22114
|
+
}];
|
|
22115
|
+
}
|
|
22116
|
+
//#endregion
|
|
22117
|
+
//#region src/pipeline-analytics/services/event-kinds.ts
|
|
22118
|
+
/**
|
|
22119
|
+
* Extensible per-device event kinds (Part B).
|
|
22120
|
+
*
|
|
22121
|
+
* `composeEventKinds` builds the `listEventKinds` payload for a camera:
|
|
22122
|
+
* (a) built-ins — motion + audio, always present;
|
|
22123
|
+
* (b) detection classes actually OBSERVED on the device (track history);
|
|
22124
|
+
* (c) sensor kinds contributed by the camera's OWN bindings and by its
|
|
22125
|
+
* LINKED devices (device-manager `getLinkedDevices`), one descriptor per
|
|
22126
|
+
* bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
|
|
22127
|
+
* Binding-driven per device (`getBindings`) — never a global cap
|
|
22128
|
+
* enumeration (D12).
|
|
22129
|
+
*
|
|
22130
|
+
* `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
|
|
22131
|
+
* linked camera ids, plus the camera id set) with a TTL, so the
|
|
22132
|
+
* `DeviceStateChanged` handler stays cheap at bus rate.
|
|
22133
|
+
*/
|
|
22134
|
+
/**
|
|
22135
|
+
* Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
|
|
22136
|
+
*
|
|
22137
|
+
* A sensor cap bound on a camera makes the camera both the source and the
|
|
22138
|
+
* subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
|
|
22139
|
+
* camera-side button. Those have no other route into the per-camera event
|
|
22140
|
+
* substrate, so the camera includes itself.
|
|
22141
|
+
*
|
|
22142
|
+
* `motion` is the exception, and today the ONLY one: camera motion already
|
|
22143
|
+
* has a dedicated camera-native path (`MotionAnalysis` /
|
|
22144
|
+
* `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
|
|
22145
|
+
* taxonomy lane). Every real camera provider registers a native `motion` cap
|
|
22146
|
+
* on the camera device, so self-inclusion would mint a SECOND, differently
|
|
22147
|
+
* named copy (`motion-sensor`) of every motion transition, at motion rate.
|
|
22148
|
+
*
|
|
22149
|
+
* Add a cap here only when the camera already emits that event through
|
|
22150
|
+
* another ingest path — never to reduce noise.
|
|
22151
|
+
*/
|
|
22152
|
+
var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
|
|
22153
|
+
/** True when a camera may contribute `capName` for itself. */
|
|
22154
|
+
function selfCameraMayEmit(capName) {
|
|
22155
|
+
return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
|
|
22156
|
+
}
|
|
22157
|
+
/**
|
|
22158
|
+
* Full event-kind taxonomy a camera CAN emit (config-derived):
|
|
22159
|
+
* (a) motion built-in — always;
|
|
22160
|
+
* (b) each enabled detection macro + its taxonomy subs (reverse COCO);
|
|
22161
|
+
* (c) audio macro + its subs when the classifier is enabled;
|
|
22162
|
+
* (d) package kinds when a package zone rule is enabled;
|
|
22163
|
+
* (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
|
|
22164
|
+
* LINKED devices (binding-driven), deduped per (kind, source deviceId).
|
|
22165
|
+
* Every descriptor carries parentKind/level/iconId/labelKey via the single
|
|
22166
|
+
* taxonomy dictionary — no color/icon is declared here.
|
|
22167
|
+
*/
|
|
22168
|
+
/**
|
|
22169
|
+
* Run one lookup, reporting a failure instead of propagating it.
|
|
22170
|
+
*
|
|
22171
|
+
* `undefined` means "this one did not answer" — the caller degrades that part
|
|
22172
|
+
* of the taxonomy and keeps the rest. That is the pre-existing contract (each
|
|
22173
|
+
* section had its own try/catch); it is factored out here only because the
|
|
22174
|
+
* sections now run concurrently, and a rejected promise nobody has awaited yet
|
|
22175
|
+
* is an unhandled rejection.
|
|
22176
|
+
*/
|
|
22177
|
+
async function attempt(deps, scope, run) {
|
|
22178
|
+
try {
|
|
22179
|
+
return await run();
|
|
22180
|
+
} catch (err) {
|
|
22181
|
+
deps.onError?.(scope, err);
|
|
22182
|
+
return;
|
|
22183
|
+
}
|
|
22184
|
+
}
|
|
22185
|
+
async function composeEventKinds(deps, deviceId) {
|
|
22186
|
+
const out = [];
|
|
22187
|
+
const cameraSource = {
|
|
22188
|
+
capName: "pipeline-analytics",
|
|
22189
|
+
deviceId
|
|
22190
|
+
};
|
|
22191
|
+
const pushKind = (kind, source = cameraSource) => {
|
|
22192
|
+
const d = require_dist.buildEventKindDescriptor(kind, source);
|
|
22193
|
+
if (d !== null) out.push(d);
|
|
22194
|
+
};
|
|
22195
|
+
const macrosP = attempt(deps, "enabledMacroClasses", () => deps.enabledMacroClasses(deviceId));
|
|
22196
|
+
const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
|
|
22197
|
+
const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
|
|
22198
|
+
const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
|
|
22199
|
+
const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
|
|
22200
|
+
pushKind("motion");
|
|
22201
|
+
for (const macro of await macrosP ?? []) {
|
|
22202
|
+
pushKind(macro);
|
|
22203
|
+
for (const sub of require_dist.subKindsOf(macro)) pushKind(sub.kind);
|
|
21573
22204
|
}
|
|
21574
22205
|
if (await audioP === true) {
|
|
21575
22206
|
pushKind("audio");
|
|
@@ -21582,26 +22213,30 @@ async function composeEventKinds(deps, deviceId) {
|
|
|
21582
22213
|
const devices = (await linkedP)?.devices ?? [];
|
|
21583
22214
|
const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
|
|
21584
22215
|
const seen = /* @__PURE__ */ new Set();
|
|
21585
|
-
|
|
21586
|
-
|
|
21587
|
-
if (entries === void 0) continue;
|
|
22216
|
+
const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
|
|
22217
|
+
if (entries === void 0) return;
|
|
21588
22218
|
for (const { capName } of entries) {
|
|
22219
|
+
if (isSelf && !selfCameraMayEmit(capName)) continue;
|
|
21589
22220
|
const descriptor = require_dist.EVENT_KIND_BY_CAP[capName];
|
|
21590
22221
|
if (descriptor === void 0) continue;
|
|
21591
|
-
const dedupeKey = `${descriptor.kind}:${
|
|
22222
|
+
const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
|
|
21592
22223
|
if (seen.has(dedupeKey)) continue;
|
|
21593
22224
|
seen.add(dedupeKey);
|
|
21594
22225
|
pushKind(descriptor.kind, {
|
|
21595
22226
|
capName,
|
|
21596
|
-
deviceId:
|
|
22227
|
+
deviceId: sourceDeviceId
|
|
21597
22228
|
});
|
|
21598
22229
|
}
|
|
21599
|
-
}
|
|
22230
|
+
};
|
|
22231
|
+
pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
|
|
22232
|
+
for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
|
|
21600
22233
|
return out;
|
|
21601
22234
|
}
|
|
21602
22235
|
var DEFAULT_CACHE_TTL_MS = 6e4;
|
|
21603
22236
|
/**
|
|
21604
|
-
* TTL-cached reverse index: source deviceId → camera ids it is linked to
|
|
22237
|
+
* TTL-cached reverse index: source deviceId → camera ids it is linked to,
|
|
22238
|
+
* plus the camera id SET the index was built from (so the ingest can ask
|
|
22239
|
+
* "is this device itself a camera" without a second enumeration).
|
|
21605
22240
|
* Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
|
|
21606
22241
|
* handler pays one map lookup per event in the common case.
|
|
21607
22242
|
*/
|
|
@@ -21609,6 +22244,8 @@ var LinkedCamerasCache = class {
|
|
|
21609
22244
|
deps;
|
|
21610
22245
|
ttlMs;
|
|
21611
22246
|
index = /* @__PURE__ */ new Map();
|
|
22247
|
+
/** Every camera id seen by the last successful build. */
|
|
22248
|
+
cameras = /* @__PURE__ */ new Set();
|
|
21612
22249
|
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
21613
22250
|
builtAt = null;
|
|
21614
22251
|
building = null;
|
|
@@ -21618,23 +22255,37 @@ var LinkedCamerasCache = class {
|
|
|
21618
22255
|
}
|
|
21619
22256
|
/** Camera ids linked to `sourceDeviceId` ([] when none). */
|
|
21620
22257
|
async camerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
21621
|
-
|
|
21622
|
-
this.building ??= this.rebuild(nowMs).finally(() => {
|
|
21623
|
-
this.building = null;
|
|
21624
|
-
});
|
|
21625
|
-
await this.building;
|
|
21626
|
-
}
|
|
22258
|
+
await this.ensureFresh(nowMs);
|
|
21627
22259
|
return this.index.get(sourceDeviceId) ?? [];
|
|
21628
22260
|
}
|
|
22261
|
+
/**
|
|
22262
|
+
* True when `deviceId` is ITSELF a camera. Answered off the same build as
|
|
22263
|
+
* {@link camerasFor} — a sensor cap bound on a camera needs both facts in
|
|
22264
|
+
* the same event, and a second enumeration would double the cost of the
|
|
22265
|
+
* ingest path for nothing.
|
|
22266
|
+
*/
|
|
22267
|
+
async isCamera(deviceId, nowMs = Date.now()) {
|
|
22268
|
+
await this.ensureFresh(nowMs);
|
|
22269
|
+
return this.cameras.has(deviceId);
|
|
22270
|
+
}
|
|
21629
22271
|
/** Drop the cached index (e.g. on link-topology change events). */
|
|
21630
22272
|
invalidate() {
|
|
21631
22273
|
this.builtAt = null;
|
|
21632
22274
|
}
|
|
21633
22275
|
/** Test/maintenance hook: replace the index directly. */
|
|
21634
|
-
seed(index, builtAt) {
|
|
22276
|
+
seed(index, builtAt, cameraIds = []) {
|
|
21635
22277
|
this.index = new Map(index);
|
|
22278
|
+
this.cameras = new Set(cameraIds);
|
|
21636
22279
|
this.builtAt = builtAt;
|
|
21637
22280
|
}
|
|
22281
|
+
/** Rebuild (single-flight) when never built or past the TTL. */
|
|
22282
|
+
async ensureFresh(nowMs) {
|
|
22283
|
+
if (this.builtAt !== null && nowMs - this.builtAt < this.ttlMs) return;
|
|
22284
|
+
this.building ??= this.rebuild(nowMs).finally(() => {
|
|
22285
|
+
this.building = null;
|
|
22286
|
+
});
|
|
22287
|
+
await this.building;
|
|
22288
|
+
}
|
|
21638
22289
|
async rebuild(nowMs) {
|
|
21639
22290
|
try {
|
|
21640
22291
|
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
@@ -21651,6 +22302,7 @@ var LinkedCamerasCache = class {
|
|
|
21651
22302
|
this.deps.onError?.("getLinkedDevices", err);
|
|
21652
22303
|
}
|
|
21653
22304
|
this.index = next;
|
|
22305
|
+
this.cameras = new Set(cameraIds);
|
|
21654
22306
|
this.builtAt = nowMs;
|
|
21655
22307
|
} catch (err) {
|
|
21656
22308
|
this.deps.onError?.("listCameraIds", err);
|
|
@@ -21659,16 +22311,25 @@ var LinkedCamerasCache = class {
|
|
|
21659
22311
|
}
|
|
21660
22312
|
};
|
|
21661
22313
|
/**
|
|
21662
|
-
* One `DeviceStateChanged` → N history rows
|
|
21663
|
-
*
|
|
21664
|
-
*
|
|
22314
|
+
* One `DeviceStateChanged` → N history rows, one per camera the event belongs
|
|
22315
|
+
* to: the emitting device's LINKED cameras, plus the emitting device ITSELF
|
|
22316
|
+
* when it is a camera carrying the sensor cap (a doorbell wrapper bound on
|
|
22317
|
+
* camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
|
|
22318
|
+
* self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
|
|
22319
|
+
* churn costs one map lookup. Returns the number of rows inserted (0 when
|
|
22320
|
+
* unmapped, or when the device belongs to no camera at all).
|
|
21665
22321
|
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
21666
22322
|
*/
|
|
21667
22323
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
21668
22324
|
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
21669
22325
|
if (descriptor === void 0) return 0;
|
|
21670
|
-
const
|
|
21671
|
-
|
|
22326
|
+
const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
|
|
22327
|
+
const isCamera = await deps.cache.isCamera(data.deviceId);
|
|
22328
|
+
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
22329
|
+
if (cameraIds.length === 0) {
|
|
22330
|
+
deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
|
|
22331
|
+
return 0;
|
|
22332
|
+
}
|
|
21672
22333
|
const slice = data.slice;
|
|
21673
22334
|
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
21674
22335
|
const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
|
|
@@ -21804,6 +22465,8 @@ var SNAPSHOT_MAX_WIDTH = 960;
|
|
|
21804
22465
|
/** Bound the once-per-owner display-fallback memo so a long-lived process never
|
|
21805
22466
|
* leaks it. */
|
|
21806
22467
|
var MAX_LOGGED_DISPLAY_FALLBACKS = 5e3;
|
|
22468
|
+
/** One `pinned-frame resolve missed` line per device per minute. */
|
|
22469
|
+
var PINNED_MISS_WARN_THROTTLE_MS = 6e4;
|
|
21807
22470
|
/**
|
|
21808
22471
|
* Map a detection-frame pixel box (`fromW`×`fromH`, the ≤640 raster the tracker
|
|
21809
22472
|
* ran on) onto the native full frame (`toW`×`toH`, the 960-downscaled native
|
|
@@ -21889,6 +22552,8 @@ var EventMediaDispatcher = class {
|
|
|
21889
22552
|
/** Once-per-owner (`${eventId}:${kind}`) memo for the DISPLAY-crop fallback
|
|
21890
22553
|
* info log — see {@link logDisplayFallbackOnce}. */
|
|
21891
22554
|
loggedDisplayFallbacks = /* @__PURE__ */ new Set();
|
|
22555
|
+
/** Per-device throttle for {@link warnPinnedFrameMiss}. */
|
|
22556
|
+
lastPinnedMissWarnAt = /* @__PURE__ */ new Map();
|
|
21892
22557
|
constructor(deps) {
|
|
21893
22558
|
this.deps = deps;
|
|
21894
22559
|
this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
|
|
@@ -21902,30 +22567,48 @@ var EventMediaDispatcher = class {
|
|
|
21902
22567
|
* by which time the handle's ring slot is usually recycled and the by-handle
|
|
21903
22568
|
* resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
|
|
21904
22569
|
* regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
|
|
21905
|
-
* lane. Returns `null` on a genuine live-window miss
|
|
21906
|
-
*
|
|
21907
|
-
*
|
|
22570
|
+
* lane. Returns `null` on a genuine live-window miss.
|
|
22571
|
+
*
|
|
22572
|
+
* Both miss paths are WARN, throttled to one line per device per minute. They
|
|
22573
|
+
* are the FIRST half of a pair whose second half (`capture batch lost`) has
|
|
22574
|
+
* always been a warn: a batch that lost its frame is reported with
|
|
22575
|
+
* `pinned: false`, and until 2026-08-07 the reason the pin itself failed was
|
|
22576
|
+
* either at debug (the throw) or nowhere at all (the null return). 383 batch
|
|
22577
|
+
* losses in 3 h had `pinned: false` on 100% of them and nothing said why.
|
|
21908
22578
|
*/
|
|
21909
22579
|
async resolvePinnedFrame(deviceId, frameHandle) {
|
|
21910
22580
|
try {
|
|
21911
22581
|
const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
|
|
21912
|
-
if (!decoded)
|
|
22582
|
+
if (!decoded) {
|
|
22583
|
+
this.warnPinnedFrameMiss(deviceId, frameHandle, "recycled-before-pin");
|
|
22584
|
+
return null;
|
|
22585
|
+
}
|
|
21913
22586
|
return {
|
|
21914
22587
|
...decoded,
|
|
21915
22588
|
data: Buffer.from(decoded.data)
|
|
21916
22589
|
};
|
|
21917
22590
|
} catch (err) {
|
|
21918
|
-
this.
|
|
21919
|
-
tags: { deviceId },
|
|
21920
|
-
meta: {
|
|
21921
|
-
deviceId,
|
|
21922
|
-
shmId: frameHandle.shmId,
|
|
21923
|
-
error: String(err)
|
|
21924
|
-
}
|
|
21925
|
-
});
|
|
22591
|
+
this.warnPinnedFrameMiss(deviceId, frameHandle, "threw", String(err));
|
|
21926
22592
|
return null;
|
|
21927
22593
|
}
|
|
21928
22594
|
}
|
|
22595
|
+
/** One `pinned-frame resolve missed` line per device per minute. Throttled
|
|
22596
|
+
* because it fires per capture batch, and the batch's own loss line is not. */
|
|
22597
|
+
warnPinnedFrameMiss(deviceId, frameHandle, reason, error) {
|
|
22598
|
+
const now = Date.now();
|
|
22599
|
+
if (now - (this.lastPinnedMissWarnAt.get(deviceId) ?? 0) < PINNED_MISS_WARN_THROTTLE_MS) return;
|
|
22600
|
+
this.lastPinnedMissWarnAt.set(deviceId, now);
|
|
22601
|
+
this.deps.logger.warn("event media: pinned-frame resolve missed (live window)", {
|
|
22602
|
+
tags: { deviceId },
|
|
22603
|
+
meta: {
|
|
22604
|
+
reason,
|
|
22605
|
+
shmId: frameHandle.shmId,
|
|
22606
|
+
handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
|
|
22607
|
+
handleNodeId: frameHandle.nodeId,
|
|
22608
|
+
...error !== void 0 ? { error } : {}
|
|
22609
|
+
}
|
|
22610
|
+
});
|
|
22611
|
+
}
|
|
21929
22612
|
/**
|
|
21930
22613
|
* WARN-log a whole-batch capture abort: every early return in
|
|
21931
22614
|
* {@link captureForFrame} loses ALL of the frame's targets at once —
|
|
@@ -22377,307 +23060,12 @@ var EventMediaDispatcher = class {
|
|
|
22377
23060
|
deviceId,
|
|
22378
23061
|
trackId: tf.trackId,
|
|
22379
23062
|
error: err instanceof Error ? err.message : String(err)
|
|
22380
|
-
}
|
|
22381
|
-
});
|
|
22382
|
-
return false;
|
|
22383
|
-
}
|
|
22384
|
-
}
|
|
22385
|
-
};
|
|
22386
|
-
/**
|
|
22387
|
-
* A baseline whose last sample is older than `baselineMs × this` no longer
|
|
22388
|
-
* describes the camera — an on-motion camera detaches for hours and comes
|
|
22389
|
-
* back to a different scene. Decaying it forward would make the first frame
|
|
22390
|
-
* back either a guaranteed marker (mean still holds the old quiet) or a
|
|
22391
|
-
* guaranteed miss; discarding it is the honest option and costs one warm-up.
|
|
22392
|
-
*/
|
|
22393
|
-
var STALE_BASELINE_FACTOR = 5;
|
|
22394
|
-
/**
|
|
22395
|
-
* Floor on the MAD used as the deviation unit, in dB. A perfectly flat signal
|
|
22396
|
-
* has MAD 0 and would make every deviation infinite. 1 dB is below the
|
|
22397
|
-
* quantisation of any real level meter, so it only ever binds on a synthetic
|
|
22398
|
-
* or a genuinely dead-steady signal — where bar 2 is what should decide.
|
|
22399
|
-
*/
|
|
22400
|
-
var MIN_MAD_DB = 1;
|
|
22401
|
-
function emptyAudioBaseline() {
|
|
22402
|
-
return {
|
|
22403
|
-
count: 0,
|
|
22404
|
-
mean: 0,
|
|
22405
|
-
mad: 0,
|
|
22406
|
-
firstMs: 0,
|
|
22407
|
-
lastMs: 0
|
|
22408
|
-
};
|
|
22409
|
-
}
|
|
22410
|
-
/** Seed a fresh baseline from one sample (first ever, or after a stale gap). */
|
|
22411
|
-
function seed(sample) {
|
|
22412
|
-
return {
|
|
22413
|
-
count: 1,
|
|
22414
|
-
mean: sample.dbfs,
|
|
22415
|
-
mad: 0,
|
|
22416
|
-
firstMs: sample.timestampMs,
|
|
22417
|
-
lastMs: sample.timestampMs
|
|
22418
|
-
};
|
|
22419
|
-
}
|
|
22420
|
-
/**
|
|
22421
|
-
* Fold `sample` into `state` and decide whether it is a marker candidate.
|
|
22422
|
-
*
|
|
22423
|
-
* The verdict is measured against the PRIOR baseline (this frame excluded) so
|
|
22424
|
-
* a lone spike is not diluted by itself — the same reasoning
|
|
22425
|
-
* `audio-level-detector` uses for its `priorMean`. The state returned always
|
|
22426
|
-
* includes the sample, whatever the verdict.
|
|
22427
|
-
*/
|
|
22428
|
-
function observeAudioAnomaly(state, sample, cfg) {
|
|
22429
|
-
const isFirst = state.count === 0;
|
|
22430
|
-
const gapMs = sample.timestampMs - state.lastMs;
|
|
22431
|
-
const isStale = !isFirst && gapMs > cfg.baselineMs * STALE_BASELINE_FACTOR;
|
|
22432
|
-
if (isFirst || isStale) return {
|
|
22433
|
-
verdict: { kind: "warmup" },
|
|
22434
|
-
nextState: seed(sample)
|
|
22435
|
-
};
|
|
22436
|
-
const alpha = 1 - Math.exp(-Math.max(0, gapMs) / cfg.baselineMs);
|
|
22437
|
-
const priorMean = state.mean;
|
|
22438
|
-
const priorMad = state.mad;
|
|
22439
|
-
const nextState = {
|
|
22440
|
-
count: state.count + 1,
|
|
22441
|
-
mean: priorMean + alpha * (sample.dbfs - priorMean),
|
|
22442
|
-
mad: priorMad + alpha * (Math.abs(sample.dbfs - priorMean) - priorMad),
|
|
22443
|
-
firstMs: state.firstMs,
|
|
22444
|
-
lastMs: sample.timestampMs
|
|
22445
|
-
};
|
|
22446
|
-
if (sample.dbfs <= cfg.silenceFloorDbfs) return {
|
|
22447
|
-
verdict: { kind: "silent" },
|
|
22448
|
-
nextState
|
|
22449
|
-
};
|
|
22450
|
-
if (!(nextState.count >= 30 && sample.timestampMs - state.firstMs >= cfg.baselineMs)) return {
|
|
22451
|
-
verdict: { kind: "warmup" },
|
|
22452
|
-
nextState
|
|
22453
|
-
};
|
|
22454
|
-
if (sample.dbfs < cfg.minDbfs) return {
|
|
22455
|
-
verdict: {
|
|
22456
|
-
kind: "not-loud",
|
|
22457
|
-
dbfs: sample.dbfs
|
|
22458
|
-
},
|
|
22459
|
-
nextState
|
|
22460
|
-
};
|
|
22461
|
-
const deviationDb = sample.dbfs - priorMean;
|
|
22462
|
-
const deviations = deviationDb / Math.max(priorMad, MIN_MAD_DB);
|
|
22463
|
-
const measured = {
|
|
22464
|
-
dbfs: sample.dbfs,
|
|
22465
|
-
mean: priorMean,
|
|
22466
|
-
deviationDb,
|
|
22467
|
-
deviations
|
|
22468
|
-
};
|
|
22469
|
-
return {
|
|
22470
|
-
verdict: deviationDb >= cfg.minDeviationDb && deviations >= cfg.minDeviations ? {
|
|
22471
|
-
kind: "anomalous",
|
|
22472
|
-
...measured
|
|
22473
|
-
} : {
|
|
22474
|
-
kind: "not-anomalous",
|
|
22475
|
-
...measured
|
|
22476
|
-
},
|
|
22477
|
-
nextState
|
|
22478
|
-
};
|
|
22479
|
-
}
|
|
22480
|
-
//#endregion
|
|
22481
|
-
//#region src/pipeline-analytics/services/audio-marker-projector.ts
|
|
22482
|
-
/** One info line per camera per minute, and only when something qualified. */
|
|
22483
|
-
var METRICS_WINDOW_MS = 6e4;
|
|
22484
|
-
var HOUR_MS = 60 * 6e4;
|
|
22485
|
-
var DAY_MS = 24 * HOUR_MS;
|
|
22486
|
-
function emptyCounters(atMs) {
|
|
22487
|
-
return {
|
|
22488
|
-
windowStartMs: atMs,
|
|
22489
|
-
enabled: false,
|
|
22490
|
-
observed: 0,
|
|
22491
|
-
notLoud: 0,
|
|
22492
|
-
notAnomalous: 0,
|
|
22493
|
-
candidates: 0,
|
|
22494
|
-
suppressedMotion: 0,
|
|
22495
|
-
suppressedCooldown: 0,
|
|
22496
|
-
suppressedHourly: 0,
|
|
22497
|
-
suppressedDaily: 0,
|
|
22498
|
-
suppressedInFlight: 0,
|
|
22499
|
-
wouldFire: 0,
|
|
22500
|
-
materialized: 0,
|
|
22501
|
-
materializeFailed: 0
|
|
22502
|
-
};
|
|
22503
|
-
}
|
|
22504
|
-
var AudioMarkerProjector = class {
|
|
22505
|
-
deps;
|
|
22506
|
-
baselines = /* @__PURE__ */ new Map();
|
|
22507
|
-
rates = /* @__PURE__ */ new Map();
|
|
22508
|
-
counters = /* @__PURE__ */ new Map();
|
|
22509
|
-
inFlight = /* @__PURE__ */ new Set();
|
|
22510
|
-
constructor(deps) {
|
|
22511
|
-
this.deps = deps;
|
|
22512
|
-
}
|
|
22513
|
-
/**
|
|
22514
|
-
* Feed ONE audio frame. Synchronous by construction: at ~30 Hz per camera
|
|
22515
|
-
* the common path must not allocate a promise, so the decision is taken
|
|
22516
|
-
* inline and only an actual marker goes async (fire-and-forget, bounded by
|
|
22517
|
-
* the in-flight guard and the rate ceilings).
|
|
22518
|
-
*
|
|
22519
|
-
* Call this BEFORE the classification/level route split so the baseline
|
|
22520
|
-
* sees every frame. A baseline fed only by the level route is blind to
|
|
22521
|
-
* exactly the loud, confidently-classified frames a marker is for.
|
|
22522
|
-
*/
|
|
22523
|
-
observe(input, cfg) {
|
|
22524
|
-
const { deviceId, timestampMs } = input;
|
|
22525
|
-
const counters = this.countersFor(deviceId, timestampMs);
|
|
22526
|
-
counters.observed += 1;
|
|
22527
|
-
counters.enabled = cfg.enabled;
|
|
22528
|
-
const res = observeAudioAnomaly(this.baselines.get(deviceId) ?? emptyAudioBaseline(), {
|
|
22529
|
-
timestampMs,
|
|
22530
|
-
dbfs: input.dbfs
|
|
22531
|
-
}, cfg);
|
|
22532
|
-
this.baselines.set(deviceId, res.nextState);
|
|
22533
|
-
this.maybeFlush(deviceId, timestampMs);
|
|
22534
|
-
switch (res.verdict.kind) {
|
|
22535
|
-
case "silent":
|
|
22536
|
-
case "warmup": return;
|
|
22537
|
-
case "not-loud":
|
|
22538
|
-
counters.notLoud += 1;
|
|
22539
|
-
return;
|
|
22540
|
-
case "not-anomalous":
|
|
22541
|
-
counters.notAnomalous += 1;
|
|
22542
|
-
return;
|
|
22543
|
-
case "anomalous": break;
|
|
22544
|
-
}
|
|
22545
|
-
counters.candidates += 1;
|
|
22546
|
-
if (this.deps.isMotionActive(deviceId, timestampMs, cfg.motionQuietMs)) {
|
|
22547
|
-
counters.suppressedMotion += 1;
|
|
22548
|
-
return;
|
|
22549
|
-
}
|
|
22550
|
-
const fired = this.rates.get(deviceId)?.firedAtMs ?? [];
|
|
22551
|
-
const last = fired.length > 0 ? fired[fired.length - 1] : void 0;
|
|
22552
|
-
if (last !== void 0 && timestampMs - last < cfg.cooldownMs) {
|
|
22553
|
-
counters.suppressedCooldown += 1;
|
|
22554
|
-
return;
|
|
22555
|
-
}
|
|
22556
|
-
const recent = fired.filter((t) => timestampMs - t < DAY_MS);
|
|
22557
|
-
if (recent.filter((t) => timestampMs - t < HOUR_MS).length >= cfg.maxPerHour) {
|
|
22558
|
-
counters.suppressedHourly += 1;
|
|
22559
|
-
this.rates.set(deviceId, { firedAtMs: recent });
|
|
22560
|
-
return;
|
|
22561
|
-
}
|
|
22562
|
-
if (recent.length >= cfg.maxPerDay) {
|
|
22563
|
-
counters.suppressedDaily += 1;
|
|
22564
|
-
this.rates.set(deviceId, { firedAtMs: recent });
|
|
22565
|
-
return;
|
|
22566
|
-
}
|
|
22567
|
-
if (!cfg.enabled) {
|
|
22568
|
-
counters.wouldFire += 1;
|
|
22569
|
-
return;
|
|
22570
|
-
}
|
|
22571
|
-
if (this.inFlight.has(deviceId)) {
|
|
22572
|
-
counters.suppressedInFlight += 1;
|
|
22573
|
-
return;
|
|
22574
|
-
}
|
|
22575
|
-
this.rates.set(deviceId, { firedAtMs: [...recent, timestampMs] });
|
|
22576
|
-
counters.materialized += 1;
|
|
22577
|
-
this.inFlight.add(deviceId);
|
|
22578
|
-
this.materialize(input, counters);
|
|
22579
|
-
}
|
|
22580
|
-
/**
|
|
22581
|
-
* Emit and reset a camera's window if it has anything to say. Exposed so a
|
|
22582
|
-
* shutdown (or a test) can force the tally out rather than losing it.
|
|
22583
|
-
*/
|
|
22584
|
-
flush(nowMs) {
|
|
22585
|
-
for (const deviceId of this.counters.keys()) this.emitWindow(deviceId, nowMs, true);
|
|
22586
|
-
}
|
|
22587
|
-
/** Drop all per-camera state for a device (unbind / device removal). */
|
|
22588
|
-
forgetDevice(deviceId) {
|
|
22589
|
-
this.baselines.delete(deviceId);
|
|
22590
|
-
this.rates.delete(deviceId);
|
|
22591
|
-
this.counters.delete(deviceId);
|
|
22592
|
-
this.inFlight.delete(deviceId);
|
|
22593
|
-
}
|
|
22594
|
-
async materialize(input, counters) {
|
|
22595
|
-
const { deviceId, timestampMs } = input;
|
|
22596
|
-
try {
|
|
22597
|
-
const audioLabels = buildAudioLabels(input);
|
|
22598
|
-
await this.deps.sink.materialize({
|
|
22599
|
-
cameraId: deviceId,
|
|
22600
|
-
sourceDeviceId: deviceId,
|
|
22601
|
-
source: "audio",
|
|
22602
|
-
kind: "audio",
|
|
22603
|
-
timestamp: timestampMs,
|
|
22604
|
-
...audioLabels !== void 0 ? { audioLabels } : {}
|
|
22605
|
-
});
|
|
22606
|
-
} catch (err) {
|
|
22607
|
-
counters.materialized -= 1;
|
|
22608
|
-
counters.materializeFailed += 1;
|
|
22609
|
-
this.deps.onError?.("materializeAudioMarker", err);
|
|
22610
|
-
this.deps.logger.warn("audio marker materialize failed", {
|
|
22611
|
-
tags: { deviceId },
|
|
22612
|
-
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
23063
|
+
}
|
|
22613
23064
|
});
|
|
22614
|
-
|
|
22615
|
-
this.inFlight.delete(deviceId);
|
|
23065
|
+
return false;
|
|
22616
23066
|
}
|
|
22617
23067
|
}
|
|
22618
|
-
countersFor(deviceId, atMs) {
|
|
22619
|
-
const existing = this.counters.get(deviceId);
|
|
22620
|
-
if (existing !== void 0) return existing;
|
|
22621
|
-
const fresh = emptyCounters(atMs);
|
|
22622
|
-
this.counters.set(deviceId, fresh);
|
|
22623
|
-
return fresh;
|
|
22624
|
-
}
|
|
22625
|
-
maybeFlush(deviceId, nowMs) {
|
|
22626
|
-
const c = this.counters.get(deviceId);
|
|
22627
|
-
if (c === void 0 || nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22628
|
-
this.emitWindow(deviceId, nowMs, false);
|
|
22629
|
-
}
|
|
22630
|
-
/**
|
|
22631
|
-
* One line per camera per window — but ONLY when a frame reached the
|
|
22632
|
-
* candidate gate. A camera that heard nothing extraordinary has nothing to
|
|
22633
|
-
* report, and a line per camera per minute forever would bury the ones that
|
|
22634
|
-
* do. `force` still honours that: it flushes the window, it does not invent
|
|
22635
|
-
* a reason to speak.
|
|
22636
|
-
*/
|
|
22637
|
-
emitWindow(deviceId, nowMs, force) {
|
|
22638
|
-
const c = this.counters.get(deviceId);
|
|
22639
|
-
if (c === void 0) return;
|
|
22640
|
-
if (!force && nowMs - c.windowStartMs < METRICS_WINDOW_MS) return;
|
|
22641
|
-
if (c.candidates > 0 || c.materializeFailed > 0) this.deps.logger.info("audio anomaly gate (window)", {
|
|
22642
|
-
tags: { deviceId },
|
|
22643
|
-
meta: {
|
|
22644
|
-
enabled: c.enabled,
|
|
22645
|
-
windowMs: nowMs - c.windowStartMs,
|
|
22646
|
-
observed: c.observed,
|
|
22647
|
-
notLoud: c.notLoud,
|
|
22648
|
-
notAnomalous: c.notAnomalous,
|
|
22649
|
-
candidates: c.candidates,
|
|
22650
|
-
suppressedMotion: c.suppressedMotion,
|
|
22651
|
-
suppressedCooldown: c.suppressedCooldown,
|
|
22652
|
-
suppressedHourly: c.suppressedHourly,
|
|
22653
|
-
suppressedDaily: c.suppressedDaily,
|
|
22654
|
-
suppressedInFlight: c.suppressedInFlight,
|
|
22655
|
-
wouldFire: c.wouldFire,
|
|
22656
|
-
materialized: c.materialized,
|
|
22657
|
-
materializeFailed: c.materializeFailed
|
|
22658
|
-
}
|
|
22659
|
-
});
|
|
22660
|
-
this.counters.set(deviceId, emptyCounters(nowMs));
|
|
22661
|
-
}
|
|
22662
23068
|
};
|
|
22663
|
-
/**
|
|
22664
|
-
* The class heard on the anomalous frame, in the shape `Track.audioLabels`
|
|
22665
|
-
* already defines. Using that field is what lets a marker label itself
|
|
22666
|
-
* ("dog", "glass_breaking") without inventing an event-taxonomy sub-class —
|
|
22667
|
-
* the audio macro vocabulary collides with existing subs (`dog` is a COCO
|
|
22668
|
-
* animal, `siren` is a control), so a sub per audio class would put a barking
|
|
22669
|
-
* dog in the ANIMAL lane.
|
|
22670
|
-
*/
|
|
22671
|
-
function buildAudioLabels(input) {
|
|
22672
|
-
if (input.className === void 0) return void 0;
|
|
22673
|
-
return [{
|
|
22674
|
-
label: input.className,
|
|
22675
|
-
peakScore: input.score ?? 0,
|
|
22676
|
-
count: 1,
|
|
22677
|
-
firstAt: input.timestampMs,
|
|
22678
|
-
lastAt: input.timestampMs
|
|
22679
|
-
}];
|
|
22680
|
-
}
|
|
22681
23069
|
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
22682
23070
|
var FULL_FRAME_QUALITY = 80;
|
|
22683
23071
|
/**
|
|
@@ -22778,13 +23166,19 @@ var SyntheticTrackMaterializer = class {
|
|
|
22778
23166
|
* Fetch the linked camera's snapshot under a bounded timeout. A hanging
|
|
22779
23167
|
* snapshot cap is skipped (returns null + warns) so the track still lands;
|
|
22780
23168
|
* a null / failed fetch is likewise reported and skipped.
|
|
23169
|
+
*
|
|
23170
|
+
* Deliberately NOT `force`. `snapshot.cap.ts` defines `force` as the
|
|
23171
|
+
* operator's refresh button — *"On battery cams this WILL wake the camera —
|
|
23172
|
+
* accept the cost only when the user explicitly asks for it"* — and a
|
|
23173
|
+
* synthetic track is materialised from an EVENT, with nobody asking. Forcing
|
|
23174
|
+
* here walked straight past the snapshot addon's sleep gate and woke a
|
|
23175
|
+
* sleeping battery camera per event (roadmap 4f). Without it the cache
|
|
23176
|
+
* answers: ≤10 s old on a mains camera, and on a battery camera the
|
|
23177
|
+
* intentionally stale-but-honest frame that costs no wake.
|
|
22781
23178
|
*/
|
|
22782
23179
|
async fetchSnapshot(cameraId) {
|
|
22783
23180
|
try {
|
|
22784
|
-
const snap = await withTimeout(this.deps.snapshot.getSnapshot({
|
|
22785
|
-
deviceId: cameraId,
|
|
22786
|
-
force: true
|
|
22787
|
-
}), this.snapshotTimeoutMs);
|
|
23181
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
|
|
22788
23182
|
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
22789
23183
|
return snap;
|
|
22790
23184
|
} catch (err) {
|
|
@@ -24684,6 +25078,478 @@ async function sweepTrackRetention(deps) {
|
|
|
24684
25078
|
return totalTracks;
|
|
24685
25079
|
}
|
|
24686
25080
|
//#endregion
|
|
25081
|
+
//#region src/pipeline-analytics/training-export/dataset-plan.ts
|
|
25082
|
+
var FACE_OWNER_PREFIX = "face-";
|
|
25083
|
+
var PLATE_OWNER_PREFIX = "plate-";
|
|
25084
|
+
/**
|
|
25085
|
+
* Tracks one export pass will carry.
|
|
25086
|
+
*
|
|
25087
|
+
* It is the per-device pin budget (500) times the four cameras a homelab hub
|
|
25088
|
+
* plausibly runs, rounded — i.e. "every track that can be marked", not a
|
|
25089
|
+
* throttle the operator has to think about. The ceiling exists so a fleet-wide
|
|
25090
|
+
* export cannot become an unbounded walk if the pin budget is ever raised, and
|
|
25091
|
+
* `truncated` in the summary says when it bit rather than quietly shipping a
|
|
25092
|
+
* partial dataset as if it were the whole one.
|
|
25093
|
+
*/
|
|
25094
|
+
var TRAINING_EXPORT_MAX_TRACKS = 2e3;
|
|
25095
|
+
/**
|
|
25096
|
+
* Archive path for one media file.
|
|
25097
|
+
*
|
|
25098
|
+
* The scope segment is not decoration: a track and its best object event can
|
|
25099
|
+
* both own a `crop` at the same millisecond, and without the segment the second
|
|
25100
|
+
* one would overwrite the first inside the archive — a silent loss that only
|
|
25101
|
+
* shows up as a dataset that is smaller than the manifest says.
|
|
25102
|
+
*/
|
|
25103
|
+
function archivePathFor(params) {
|
|
25104
|
+
const leaf = params.singleInstance ? `${params.kind}.jpg` : `${params.kind}-${params.timestamp}.jpg`;
|
|
25105
|
+
const dir = params.scope === "" ? "" : `${params.scope}/`;
|
|
25106
|
+
return `tracks/${params.deviceId}/${params.trackId}/${dir}${leaf}`;
|
|
25107
|
+
}
|
|
25108
|
+
/**
|
|
25109
|
+
* A media key ends in `:<timestamp>` for the accumulating kinds and stops at the
|
|
25110
|
+
* kind for the single-instance ones. Reading it off the key rather than
|
|
25111
|
+
* re-deriving the kind set keeps this from drifting when a kind changes sides.
|
|
25112
|
+
*/
|
|
25113
|
+
function isSingleInstanceKey(key, kind) {
|
|
25114
|
+
return key.endsWith(`:${kind}`);
|
|
25115
|
+
}
|
|
25116
|
+
/**
|
|
25117
|
+
* Build the plan. Index reads only — the returned plan holds no image bytes and
|
|
25118
|
+
* is safe to send over tRPC.
|
|
25119
|
+
*/
|
|
25120
|
+
async function buildTrainingExportPlan(readers, input) {
|
|
25121
|
+
const tracks = [];
|
|
25122
|
+
const skippedPaths = [];
|
|
25123
|
+
let truncated = false;
|
|
25124
|
+
let remaining = input.maxTracks;
|
|
25125
|
+
for (const deviceId of input.deviceIds) {
|
|
25126
|
+
if (remaining <= 0) break;
|
|
25127
|
+
const marked = await readers.listMarkedTracks(deviceId, remaining + 1);
|
|
25128
|
+
if (marked.length > remaining) truncated = true;
|
|
25129
|
+
const take = marked.slice(0, remaining);
|
|
25130
|
+
remaining -= take.length;
|
|
25131
|
+
if (take.length === 0) continue;
|
|
25132
|
+
const eventLinks = await readers.listEventIdsForTracks(take.map((t) => t.trackId));
|
|
25133
|
+
const eventsByTrack = /* @__PURE__ */ new Map();
|
|
25134
|
+
for (const link of eventLinks) {
|
|
25135
|
+
const list = eventsByTrack.get(link.trackId);
|
|
25136
|
+
if (list) list.push(link.eventId);
|
|
25137
|
+
else eventsByTrack.set(link.trackId, [link.eventId]);
|
|
25138
|
+
}
|
|
25139
|
+
for (const track of take) {
|
|
25140
|
+
const files = [];
|
|
25141
|
+
const owners = [
|
|
25142
|
+
{
|
|
25143
|
+
ownerKind: "track",
|
|
25144
|
+
ownerId: track.trackId,
|
|
25145
|
+
scope: ""
|
|
25146
|
+
},
|
|
25147
|
+
{
|
|
25148
|
+
ownerKind: "face",
|
|
25149
|
+
ownerId: `${FACE_OWNER_PREFIX}${track.trackId}`,
|
|
25150
|
+
scope: "face"
|
|
25151
|
+
},
|
|
25152
|
+
{
|
|
25153
|
+
ownerKind: "plate",
|
|
25154
|
+
ownerId: `${PLATE_OWNER_PREFIX}${track.trackId}`,
|
|
25155
|
+
scope: "plate"
|
|
25156
|
+
},
|
|
25157
|
+
...(eventsByTrack.get(track.trackId) ?? []).map((eventId) => ({
|
|
25158
|
+
ownerKind: "event",
|
|
25159
|
+
ownerId: eventId,
|
|
25160
|
+
scope: `events/${eventId}`
|
|
25161
|
+
}))
|
|
25162
|
+
];
|
|
25163
|
+
for (const owner of owners) {
|
|
25164
|
+
const infos = await readers.listMediaInfo(owner.ownerKind, owner.ownerId);
|
|
25165
|
+
for (const info of infos) {
|
|
25166
|
+
const archivePath = archivePathFor({
|
|
25167
|
+
deviceId: track.deviceId,
|
|
25168
|
+
trackId: track.trackId,
|
|
25169
|
+
scope: owner.scope,
|
|
25170
|
+
kind: info.kind,
|
|
25171
|
+
timestamp: info.timestamp,
|
|
25172
|
+
singleInstance: isSingleInstanceKey(info.key, info.kind)
|
|
25173
|
+
});
|
|
25174
|
+
files.push({
|
|
25175
|
+
archivePath,
|
|
25176
|
+
mediaKey: info.key,
|
|
25177
|
+
kind: info.kind,
|
|
25178
|
+
sizeBytes: info.sizeBytes,
|
|
25179
|
+
timestamp: info.timestamp
|
|
25180
|
+
});
|
|
25181
|
+
}
|
|
25182
|
+
}
|
|
25183
|
+
tracks.push({
|
|
25184
|
+
trackId: track.trackId,
|
|
25185
|
+
deviceId: track.deviceId,
|
|
25186
|
+
className: track.className,
|
|
25187
|
+
...track.label !== void 0 ? { label: track.label } : {},
|
|
25188
|
+
firstSeen: track.firstSeen,
|
|
25189
|
+
lastSeen: track.lastSeen,
|
|
25190
|
+
classes: track.classes ?? [],
|
|
25191
|
+
zonesVisited: track.zonesVisited,
|
|
25192
|
+
...track.importance !== void 0 ? { importance: track.importance } : {},
|
|
25193
|
+
files
|
|
25194
|
+
});
|
|
25195
|
+
}
|
|
25196
|
+
}
|
|
25197
|
+
const perDevice = /* @__PURE__ */ new Map();
|
|
25198
|
+
let fileCount = 0;
|
|
25199
|
+
let byteCount = 0;
|
|
25200
|
+
for (const track of tracks) {
|
|
25201
|
+
const agg = perDevice.get(track.deviceId) ?? {
|
|
25202
|
+
tracks: 0,
|
|
25203
|
+
files: 0,
|
|
25204
|
+
bytes: 0
|
|
25205
|
+
};
|
|
25206
|
+
agg.tracks += 1;
|
|
25207
|
+
for (const file of track.files) {
|
|
25208
|
+
agg.files += 1;
|
|
25209
|
+
agg.bytes += file.sizeBytes;
|
|
25210
|
+
fileCount += 1;
|
|
25211
|
+
byteCount += file.sizeBytes;
|
|
25212
|
+
}
|
|
25213
|
+
perDevice.set(track.deviceId, agg);
|
|
25214
|
+
}
|
|
25215
|
+
return {
|
|
25216
|
+
generatedAt: readers.now(),
|
|
25217
|
+
tracks,
|
|
25218
|
+
totals: [...perDevice.entries()].map(([deviceId, agg]) => ({
|
|
25219
|
+
deviceId,
|
|
25220
|
+
...agg
|
|
25221
|
+
})),
|
|
25222
|
+
trackCount: tracks.length,
|
|
25223
|
+
fileCount,
|
|
25224
|
+
byteCount,
|
|
25225
|
+
truncated,
|
|
25226
|
+
skippedPaths
|
|
25227
|
+
};
|
|
25228
|
+
}
|
|
25229
|
+
//#endregion
|
|
25230
|
+
//#region src/pipeline-analytics/training-export/tar-stream.ts
|
|
25231
|
+
/**
|
|
25232
|
+
* A minimal POSIX ustar writer — enough to emit a dataset archive entry by
|
|
25233
|
+
* entry, without ever holding the archive in memory.
|
|
25234
|
+
*
|
|
25235
|
+
* ## Why hand-rolled rather than the `tar` package
|
|
25236
|
+
*
|
|
25237
|
+
* `tar` is in the workspace and is the right tool for what it does: archive a
|
|
25238
|
+
* set of paths under one `cwd`. This is a different job. A track's media lives
|
|
25239
|
+
* across several storage LOCATION roots (`eventMedia` and whatever else the
|
|
25240
|
+
* operator relocated to), under paths that encode nothing a training set wants —
|
|
25241
|
+
* `615/events/track/<uuid>/keyFrame.jpg` — and every entry has to be RENAMED on
|
|
25242
|
+
* the way in, to `tracks/615/<trackId>/keyFrame.jpg`. `tar.create` takes a list
|
|
25243
|
+
* of paths relative to a single cwd and gives no rename hook, so using it would
|
|
25244
|
+
* mean staging a whole shadow tree on disk first: a full second copy of the
|
|
25245
|
+
* dataset, written and then deleted, to avoid writing 100 lines.
|
|
25246
|
+
*
|
|
25247
|
+
* So: ustar, which is a 512-byte header and a 512-byte-padded body per entry,
|
|
25248
|
+
* fully specified, and asserted against the system `tar` binary in the spec next
|
|
25249
|
+
* door rather than against my reading of the format.
|
|
25250
|
+
*
|
|
25251
|
+
* ## What this deliberately does NOT do
|
|
25252
|
+
*
|
|
25253
|
+
* No gzip — the payload is JPEG, already compressed; a gzip pass would burn CPU
|
|
25254
|
+
* on the hub for a percent or two. No long-name (GNU/PAX) extension: ustar
|
|
25255
|
+
* splits a path across a 155-byte prefix and a 100-byte name, and
|
|
25256
|
+
* {@link tarNameTooLong} reports a path that will not fit so the caller can skip
|
|
25257
|
+
* it and SAY it skipped it, rather than emitting a silently truncated entry.
|
|
25258
|
+
*/
|
|
25259
|
+
var BLOCK_SIZE = 512;
|
|
25260
|
+
var NAME_MAX = 100;
|
|
25261
|
+
var PREFIX_MAX = 155;
|
|
25262
|
+
/**
|
|
25263
|
+
* Split a path into the ustar `prefix` + `name` pair, or `null` when it cannot
|
|
25264
|
+
* be represented. The split must land on a `/`, and each half has its own cap.
|
|
25265
|
+
*/
|
|
25266
|
+
function splitUstarName(name) {
|
|
25267
|
+
if (Buffer.byteLength(name) <= NAME_MAX) return {
|
|
25268
|
+
prefix: "",
|
|
25269
|
+
name
|
|
25270
|
+
};
|
|
25271
|
+
for (let i = name.length - 1; i >= 0; i--) {
|
|
25272
|
+
if (name[i] !== "/") continue;
|
|
25273
|
+
const head = name.slice(0, i);
|
|
25274
|
+
const tail = name.slice(i + 1);
|
|
25275
|
+
if (Buffer.byteLength(tail) > NAME_MAX) break;
|
|
25276
|
+
if (Buffer.byteLength(head) <= PREFIX_MAX) return {
|
|
25277
|
+
prefix: head,
|
|
25278
|
+
name: tail
|
|
25279
|
+
};
|
|
25280
|
+
}
|
|
25281
|
+
return null;
|
|
25282
|
+
}
|
|
25283
|
+
/** True when a path cannot be represented as a ustar entry name. */
|
|
25284
|
+
function tarNameTooLong(name) {
|
|
25285
|
+
return splitUstarName(name) === null;
|
|
25286
|
+
}
|
|
25287
|
+
function writeString(block, value, offset, length) {
|
|
25288
|
+
block.write(value.slice(0, length - 1), offset, length - 1, "utf8");
|
|
25289
|
+
}
|
|
25290
|
+
/** ustar numeric field: zero-padded octal, NUL-terminated. */
|
|
25291
|
+
function writeOctal(block, value, offset, length) {
|
|
25292
|
+
const text = Math.floor(value).toString(8).padStart(length - 1, "0");
|
|
25293
|
+
block.write(text.slice(-(length - 1)), offset, length - 1, "ascii");
|
|
25294
|
+
}
|
|
25295
|
+
/**
|
|
25296
|
+
* The 512-byte header for one regular file.
|
|
25297
|
+
*
|
|
25298
|
+
* The checksum is computed with the checksum field itself read as eight spaces —
|
|
25299
|
+
* that is the format's rule, and getting it wrong produces an archive that looks
|
|
25300
|
+
* fine until something tries to read it.
|
|
25301
|
+
*/
|
|
25302
|
+
function tarHeader(entry) {
|
|
25303
|
+
const split = splitUstarName(entry.name);
|
|
25304
|
+
if (split === null) throw new Error(`tar: entry name too long for ustar: ${entry.name}`);
|
|
25305
|
+
const block = Buffer.alloc(BLOCK_SIZE);
|
|
25306
|
+
writeString(block, split.name, 0, NAME_MAX);
|
|
25307
|
+
writeOctal(block, 420, 100, 8);
|
|
25308
|
+
writeOctal(block, 0, 108, 8);
|
|
25309
|
+
writeOctal(block, 0, 116, 8);
|
|
25310
|
+
writeOctal(block, entry.body.length, 124, 12);
|
|
25311
|
+
writeOctal(block, Math.floor(entry.mtimeMs / 1e3), 136, 12);
|
|
25312
|
+
block.write(" ", 148, 8, "ascii");
|
|
25313
|
+
block.write("0", 156, 1, "ascii");
|
|
25314
|
+
block.write("ustar\0", 257, 6, "ascii");
|
|
25315
|
+
block.write("00", 263, 2, "ascii");
|
|
25316
|
+
writeString(block, split.prefix, 345, PREFIX_MAX);
|
|
25317
|
+
let sum = 0;
|
|
25318
|
+
for (const byte of block) sum += byte;
|
|
25319
|
+
writeOctal(block, sum, 148, 7);
|
|
25320
|
+
block.write("\0", 154, 1, "ascii");
|
|
25321
|
+
block.write(" ", 155, 1, "ascii");
|
|
25322
|
+
return block;
|
|
25323
|
+
}
|
|
25324
|
+
/** Zero padding that rounds a body up to the 512-byte block. */
|
|
25325
|
+
function tarPadding(size) {
|
|
25326
|
+
const remainder = size % BLOCK_SIZE;
|
|
25327
|
+
return remainder === 0 ? Buffer.alloc(0) : Buffer.alloc(BLOCK_SIZE - remainder);
|
|
25328
|
+
}
|
|
25329
|
+
/** Header + body + padding for one entry, ready to write. */
|
|
25330
|
+
function tarBlocks(entry) {
|
|
25331
|
+
return [
|
|
25332
|
+
tarHeader(entry),
|
|
25333
|
+
entry.body,
|
|
25334
|
+
tarPadding(entry.body.length)
|
|
25335
|
+
];
|
|
25336
|
+
}
|
|
25337
|
+
/** The two zero blocks that terminate an archive. */
|
|
25338
|
+
function tarTrailer() {
|
|
25339
|
+
return Buffer.alloc(BLOCK_SIZE * 2);
|
|
25340
|
+
}
|
|
25341
|
+
//#endregion
|
|
25342
|
+
//#region src/pipeline-analytics/training-export/training-export-stream.ts
|
|
25343
|
+
var MANIFEST_ENTRY_NAME = "manifest.json";
|
|
25344
|
+
function manifestTrack(track) {
|
|
25345
|
+
return {
|
|
25346
|
+
trackId: track.trackId,
|
|
25347
|
+
deviceId: track.deviceId,
|
|
25348
|
+
className: track.className,
|
|
25349
|
+
...track.label !== void 0 ? { label: track.label } : {},
|
|
25350
|
+
firstSeen: track.firstSeen,
|
|
25351
|
+
lastSeen: track.lastSeen,
|
|
25352
|
+
classes: track.classes,
|
|
25353
|
+
zonesVisited: track.zonesVisited,
|
|
25354
|
+
...track.importance !== void 0 ? { importance: track.importance } : {},
|
|
25355
|
+
files: track.files.map((f) => ({
|
|
25356
|
+
path: f.archivePath,
|
|
25357
|
+
kind: f.kind,
|
|
25358
|
+
timestamp: f.timestamp,
|
|
25359
|
+
sizeBytes: f.sizeBytes
|
|
25360
|
+
}))
|
|
25361
|
+
};
|
|
25362
|
+
}
|
|
25363
|
+
/**
|
|
25364
|
+
* The manifest body.
|
|
25365
|
+
*
|
|
25366
|
+
* `mediaKey` is deliberately NOT in it. The archive is a dataset handed to a
|
|
25367
|
+
* training pipeline, and a store-internal key is meaningless outside the hub
|
|
25368
|
+
* that issued it — worse, it invites a consumer to believe it can fetch by that
|
|
25369
|
+
* key later, on media the retention pin does not guarantee is still there once
|
|
25370
|
+
* the mark is removed. The archive path is the identity inside the archive.
|
|
25371
|
+
*/
|
|
25372
|
+
function buildManifest(plan) {
|
|
25373
|
+
const body = {
|
|
25374
|
+
format: "camstack-training-export",
|
|
25375
|
+
version: 1,
|
|
25376
|
+
generatedAt: plan.generatedAt,
|
|
25377
|
+
selection: "tracks in retrain staging (retrainStatus: 'staging')",
|
|
25378
|
+
trackCount: plan.trackCount,
|
|
25379
|
+
fileCount: plan.fileCount,
|
|
25380
|
+
byteCount: plan.byteCount,
|
|
25381
|
+
truncated: plan.truncated,
|
|
25382
|
+
devices: plan.totals,
|
|
25383
|
+
tracks: plan.tracks.map(manifestTrack)
|
|
25384
|
+
};
|
|
25385
|
+
return Buffer.from(JSON.stringify(body, null, 2), "utf8");
|
|
25386
|
+
}
|
|
25387
|
+
/** Write the whole archive to the sink. */
|
|
25388
|
+
async function streamDataset(deps) {
|
|
25389
|
+
const { plan, sink, readBlob, logger, now, pace } = deps;
|
|
25390
|
+
const pacingMs = deps.pacingMs ?? 25;
|
|
25391
|
+
const mtimeMs = now();
|
|
25392
|
+
for (const block of tarBlocks({
|
|
25393
|
+
name: MANIFEST_ENTRY_NAME,
|
|
25394
|
+
body: buildManifest(plan),
|
|
25395
|
+
mtimeMs
|
|
25396
|
+
})) await sink.write(block);
|
|
25397
|
+
let filesWritten = 0;
|
|
25398
|
+
let bytesWritten = 0;
|
|
25399
|
+
let filesMissing = 0;
|
|
25400
|
+
let aborted = false;
|
|
25401
|
+
for (const track of plan.tracks) {
|
|
25402
|
+
for (const file of track.files) {
|
|
25403
|
+
if (deps.aborted?.() === true) {
|
|
25404
|
+
aborted = true;
|
|
25405
|
+
break;
|
|
25406
|
+
}
|
|
25407
|
+
if (tarNameTooLong(file.archivePath)) {
|
|
25408
|
+
filesMissing += 1;
|
|
25409
|
+
logger.warn("training export skipped a file — path too long for the archive", {
|
|
25410
|
+
tags: { deviceId: track.deviceId },
|
|
25411
|
+
meta: {
|
|
25412
|
+
deviceId: track.deviceId,
|
|
25413
|
+
trackId: track.trackId,
|
|
25414
|
+
path: file.archivePath
|
|
25415
|
+
}
|
|
25416
|
+
});
|
|
25417
|
+
continue;
|
|
25418
|
+
}
|
|
25419
|
+
const bytes = await readBlob(file.mediaKey);
|
|
25420
|
+
if (bytes === null) {
|
|
25421
|
+
filesMissing += 1;
|
|
25422
|
+
logger.warn("training export skipped a file — media unreadable", {
|
|
25423
|
+
tags: { deviceId: track.deviceId },
|
|
25424
|
+
meta: {
|
|
25425
|
+
deviceId: track.deviceId,
|
|
25426
|
+
trackId: track.trackId,
|
|
25427
|
+
mediaKey: file.mediaKey
|
|
25428
|
+
}
|
|
25429
|
+
});
|
|
25430
|
+
continue;
|
|
25431
|
+
}
|
|
25432
|
+
for (const block of tarBlocks({
|
|
25433
|
+
name: file.archivePath,
|
|
25434
|
+
body: bytes,
|
|
25435
|
+
mtimeMs
|
|
25436
|
+
})) await sink.write(block);
|
|
25437
|
+
filesWritten += 1;
|
|
25438
|
+
bytesWritten += bytes.length;
|
|
25439
|
+
if (pacingMs > 0) await pace(pacingMs);
|
|
25440
|
+
}
|
|
25441
|
+
if (aborted) break;
|
|
25442
|
+
}
|
|
25443
|
+
await sink.write(tarTrailer());
|
|
25444
|
+
await sink.end();
|
|
25445
|
+
logger.info("training export complete", { meta: {
|
|
25446
|
+
tracks: plan.trackCount,
|
|
25447
|
+
filesWritten,
|
|
25448
|
+
filesMissing,
|
|
25449
|
+
bytesWritten,
|
|
25450
|
+
truncated: plan.truncated,
|
|
25451
|
+
aborted
|
|
25452
|
+
} });
|
|
25453
|
+
return {
|
|
25454
|
+
filesWritten,
|
|
25455
|
+
bytesWritten,
|
|
25456
|
+
filesMissing,
|
|
25457
|
+
aborted
|
|
25458
|
+
};
|
|
25459
|
+
}
|
|
25460
|
+
//#endregion
|
|
25461
|
+
//#region src/pipeline-analytics/training-export/training-export-handler.ts
|
|
25462
|
+
var TRAINING_EXPORT_PREFIX = "training-export";
|
|
25463
|
+
/**
|
|
25464
|
+
* Parse `?deviceIds=615,617` into a device list. An absent or unparseable value
|
|
25465
|
+
* yields `[]`, which the caller reads as "every device with marked tracks" —
|
|
25466
|
+
* stated here rather than left to the reader because the empty array is doing
|
|
25467
|
+
* two jobs and only one of them is obvious.
|
|
25468
|
+
*/
|
|
25469
|
+
function parseDeviceIds(url) {
|
|
25470
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
25471
|
+
const raw = new URLSearchParams(query).get("deviceIds");
|
|
25472
|
+
if (raw === null || raw.trim() === "") return [];
|
|
25473
|
+
const out = [];
|
|
25474
|
+
for (const part of raw.split(",")) {
|
|
25475
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
25476
|
+
if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
|
|
25477
|
+
}
|
|
25478
|
+
return out;
|
|
25479
|
+
}
|
|
25480
|
+
/** Archive filename. Sanitised the same way the backup download is — a header
|
|
25481
|
+
* value carrying a CR or a quote is a response-splitting bug, not a filename. */
|
|
25482
|
+
function archiveFilename(nowMs) {
|
|
25483
|
+
return `camstack-training-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.tar`.replace(/[\r\n"]/g, "");
|
|
25484
|
+
}
|
|
25485
|
+
/** Adapt a `ServerResponse` to the sink, honouring backpressure. */
|
|
25486
|
+
function responseSink(res) {
|
|
25487
|
+
return {
|
|
25488
|
+
write: (chunk) => new Promise((resolve, reject) => {
|
|
25489
|
+
const ok = res.write(chunk, (err) => {
|
|
25490
|
+
if (err) reject(err);
|
|
25491
|
+
else if (ok) resolve();
|
|
25492
|
+
});
|
|
25493
|
+
if (!ok) res.once("drain", resolve);
|
|
25494
|
+
}),
|
|
25495
|
+
end: () => new Promise((resolve) => {
|
|
25496
|
+
res.end(resolve);
|
|
25497
|
+
})
|
|
25498
|
+
};
|
|
25499
|
+
}
|
|
25500
|
+
function createTrainingExportHandler(deps) {
|
|
25501
|
+
return async (req, res) => {
|
|
25502
|
+
const method = req.method ?? "GET";
|
|
25503
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
25504
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
25505
|
+
res.end();
|
|
25506
|
+
return;
|
|
25507
|
+
}
|
|
25508
|
+
const deviceIds = parseDeviceIds(req.url ?? "");
|
|
25509
|
+
let plan;
|
|
25510
|
+
try {
|
|
25511
|
+
plan = await deps.buildPlan(deviceIds);
|
|
25512
|
+
} catch (err) {
|
|
25513
|
+
deps.logger.warn("training export plan failed", { meta: { error: String(err) } });
|
|
25514
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
25515
|
+
res.end("training export plan failed");
|
|
25516
|
+
return;
|
|
25517
|
+
}
|
|
25518
|
+
res.writeHead(200, {
|
|
25519
|
+
"content-type": "application/x-tar",
|
|
25520
|
+
"content-disposition": `attachment; filename="${archiveFilename(deps.now())}"`,
|
|
25521
|
+
"cache-control": "no-store",
|
|
25522
|
+
"x-camstack-export-tracks": String(plan.trackCount),
|
|
25523
|
+
"x-camstack-export-files": String(plan.fileCount)
|
|
25524
|
+
});
|
|
25525
|
+
if (method === "HEAD") {
|
|
25526
|
+
res.end();
|
|
25527
|
+
return;
|
|
25528
|
+
}
|
|
25529
|
+
let clientGone = false;
|
|
25530
|
+
res.once("close", () => {
|
|
25531
|
+
clientGone = res.writableEnded === false;
|
|
25532
|
+
});
|
|
25533
|
+
try {
|
|
25534
|
+
await streamDataset({
|
|
25535
|
+
plan,
|
|
25536
|
+
sink: responseSink(res),
|
|
25537
|
+
readBlob: deps.readBlob,
|
|
25538
|
+
logger: deps.logger,
|
|
25539
|
+
now: deps.now,
|
|
25540
|
+
pace: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
25541
|
+
aborted: () => clientGone
|
|
25542
|
+
});
|
|
25543
|
+
} catch (err) {
|
|
25544
|
+
deps.logger.warn("training export stream failed mid-archive", { meta: {
|
|
25545
|
+
error: String(err),
|
|
25546
|
+
tracks: plan.trackCount
|
|
25547
|
+
} });
|
|
25548
|
+
res.destroy();
|
|
25549
|
+
}
|
|
25550
|
+
};
|
|
25551
|
+
}
|
|
25552
|
+
//#endregion
|
|
24687
25553
|
//#region src/pipeline-analytics/zone-analytics-provider.ts
|
|
24688
25554
|
var HISTORY_WINDOW_MS = 3600 * 1e3;
|
|
24689
25555
|
/** Cap name for the runtime-state mirror — matches the cap's declared
|
|
@@ -25316,6 +26182,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
25316
26182
|
retentionSweepTimer = null;
|
|
25317
26183
|
/** Handle for the event-media data-plane listener (dispose on shutdown). */
|
|
25318
26184
|
eventMediaDataPlane = null;
|
|
26185
|
+
/** Handle for the training-export data-plane listener (dispose on shutdown). */
|
|
26186
|
+
trainingExportDataPlane = null;
|
|
25319
26187
|
/** The NC artifact plane (signed public links for notification media) and its
|
|
25320
26188
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
25321
26189
|
ncArtifactPlane = null;
|
|
@@ -25766,7 +26634,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
25766
26634
|
logger: logger.child("EventMediaDispatcher")
|
|
25767
26635
|
});
|
|
25768
26636
|
this.buildRecognizers(api, logger, stores, transport);
|
|
25769
|
-
this.buildDetailDispatcher(api, logger);
|
|
26637
|
+
this.buildDetailDispatcher(api, logger, transport);
|
|
25770
26638
|
const capProviders = this.buildAnalyticsProviders(api, logger);
|
|
25771
26639
|
await this.buildNotificationCenter(api, logger, stores);
|
|
25772
26640
|
await this.serveEventMediaDataPlane();
|
|
@@ -25949,7 +26817,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
25949
26817
|
let storage = this.ctx.kernel.storage;
|
|
25950
26818
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
25951
26819
|
if (mediaRoot) {
|
|
25952
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
26820
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-HEkeoPPw.js"));
|
|
25953
26821
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
25954
26822
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
25955
26823
|
}
|
|
@@ -26214,7 +27082,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26214
27082
|
* demand via `pipelineRunner.runDetailSubtree`. The dispatcher owns the
|
|
26215
27083
|
* per-device schedulers, concurrency cap, and 1 s tick; result routing +
|
|
26216
27084
|
* the pinned call + crop fallback are injected here. */
|
|
26217
|
-
buildDetailDispatcher(api, logger) {
|
|
27085
|
+
buildDetailDispatcher(api, logger, transport) {
|
|
26218
27086
|
const runnerApi = api.pipelineRunner;
|
|
26219
27087
|
this.detailDispatcher = new TrackDetailDispatcher({
|
|
26220
27088
|
logger: logger.child("DetailDispatcher"),
|
|
@@ -26224,6 +27092,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26224
27092
|
return runnerApi.runDetailSubtree.mutate(input);
|
|
26225
27093
|
},
|
|
26226
27094
|
routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
|
|
27095
|
+
captureFullFrameBase64: async (frame) => {
|
|
27096
|
+
if (frame.frameHandle === void 0) return null;
|
|
27097
|
+
const full = await transport.getFullFrameJpegBase64(frame.frameHandle, frame.frameWidth);
|
|
27098
|
+
return full === null ? null : {
|
|
27099
|
+
base64: full.base64,
|
|
27100
|
+
width: full.width,
|
|
27101
|
+
height: full.height
|
|
27102
|
+
};
|
|
27103
|
+
},
|
|
26227
27104
|
captureCropBase64: async (frame) => {
|
|
26228
27105
|
if (frame.frameHandle === void 0 || !this.captureCrop) return null;
|
|
26229
27106
|
const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
|
|
@@ -26449,11 +27326,99 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26449
27326
|
}) ?? null;
|
|
26450
27327
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
26451
27328
|
await this.serveNcArtifactPlane();
|
|
27329
|
+
await this.serveTrainingExportPlane();
|
|
26452
27330
|
this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
|
|
26453
27331
|
} catch (err) {
|
|
26454
27332
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
26455
27333
|
}
|
|
26456
27334
|
}
|
|
27335
|
+
/**
|
|
27336
|
+
* The training-export data plane. Separate listener, `access: 'admin'` —
|
|
27337
|
+
* marking a track is a viewer-level curation act (D81), but walking off with
|
|
27338
|
+
* every stored frame behind the marked set is not.
|
|
27339
|
+
*/
|
|
27340
|
+
async serveTrainingExportPlane() {
|
|
27341
|
+
try {
|
|
27342
|
+
const handler = createTrainingExportHandler({
|
|
27343
|
+
buildPlan: (deviceIds) => this.buildTrainingExportPlan(deviceIds),
|
|
27344
|
+
readBlob: async (mediaKey) => {
|
|
27345
|
+
const file = await (this.mediaStore?.getByKey(mediaKey) ?? Promise.resolve(null));
|
|
27346
|
+
return file === null ? null : Buffer.from(file.base64, "base64");
|
|
27347
|
+
},
|
|
27348
|
+
logger: this.ctx.logger,
|
|
27349
|
+
now: () => Date.now()
|
|
27350
|
+
});
|
|
27351
|
+
this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
27352
|
+
prefix: "training-export",
|
|
27353
|
+
access: "admin",
|
|
27354
|
+
handler
|
|
27355
|
+
}) ?? null;
|
|
27356
|
+
} catch (err) {
|
|
27357
|
+
this.ctx.logger.warn("training-export data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
27358
|
+
}
|
|
27359
|
+
}
|
|
27360
|
+
/**
|
|
27361
|
+
* Plan the training export — INDEX READS ONLY. No blob is touched here, which
|
|
27362
|
+
* is what lets `getTrainingExportSummary` answer "how big is this" for the
|
|
27363
|
+
* cost of a few KB per track instead of the gigabytes the answer is about.
|
|
27364
|
+
*
|
|
27365
|
+
* An empty `deviceIds` means every device that has tracks, resolved from the
|
|
27366
|
+
* track store rather than from the device manager: a camera that was removed
|
|
27367
|
+
* can still own marked tracks, and refusing to export them because the device
|
|
27368
|
+
* is gone would lose exactly the material an operator kept on purpose.
|
|
27369
|
+
*/
|
|
27370
|
+
async buildTrainingExportPlan(deviceIds) {
|
|
27371
|
+
const trackStore = this.trackStore;
|
|
27372
|
+
const mediaStore = this.mediaStore;
|
|
27373
|
+
const eventStore = this.eventStore;
|
|
27374
|
+
if (!trackStore || !mediaStore) throw new Error("training export: analytics stores unavailable");
|
|
27375
|
+
return buildTrainingExportPlan({
|
|
27376
|
+
listMarkedTracks: (deviceId, limit) => trackStore.listStaging(deviceId, limit),
|
|
27377
|
+
listMediaInfo: async (ownerKind, ownerId) => {
|
|
27378
|
+
if (!isMediaOwnerKind(ownerKind)) return [];
|
|
27379
|
+
return mediaStore.listInfoByOwner(ownerKind, ownerId);
|
|
27380
|
+
},
|
|
27381
|
+
listEventIdsForTracks: async (trackIds) => {
|
|
27382
|
+
if (!eventStore || trackIds.length === 0) return [];
|
|
27383
|
+
return (await eventStore.queryObjectByTrackIds(trackIds)).flatMap((e) => e.trackId === void 0 ? [] : [{
|
|
27384
|
+
eventId: e.id,
|
|
27385
|
+
trackId: e.trackId
|
|
27386
|
+
}]);
|
|
27387
|
+
},
|
|
27388
|
+
now: () => Date.now()
|
|
27389
|
+
}, {
|
|
27390
|
+
deviceIds: deviceIds.length > 0 ? deviceIds : await trackStore.listDeviceIds(),
|
|
27391
|
+
maxTracks: TRAINING_EXPORT_MAX_TRACKS
|
|
27392
|
+
});
|
|
27393
|
+
}
|
|
27394
|
+
/**
|
|
27395
|
+
* Estimate the training export without producing it — the cheap question
|
|
27396
|
+
* (D56's discipline, applied to a pass that runs no inference at all).
|
|
27397
|
+
*/
|
|
27398
|
+
async getTrainingExportSummary(input) {
|
|
27399
|
+
const plan = await this.buildTrainingExportPlan(input.deviceIds ?? []);
|
|
27400
|
+
this.ctx.logger.info("training export summary", { meta: {
|
|
27401
|
+
tracks: plan.trackCount,
|
|
27402
|
+
files: plan.fileCount,
|
|
27403
|
+
bytes: plan.byteCount,
|
|
27404
|
+
truncated: plan.truncated
|
|
27405
|
+
} });
|
|
27406
|
+
return {
|
|
27407
|
+
generatedAt: plan.generatedAt,
|
|
27408
|
+
trackCount: plan.trackCount,
|
|
27409
|
+
fileCount: plan.fileCount,
|
|
27410
|
+
byteCount: plan.byteCount,
|
|
27411
|
+
truncated: plan.truncated,
|
|
27412
|
+
devices: plan.totals
|
|
27413
|
+
};
|
|
27414
|
+
}
|
|
27415
|
+
/** Where to stream the dataset from. The bytes never come through the cap. */
|
|
27416
|
+
async getTrainingExportUrl(input) {
|
|
27417
|
+
if (this.trainingExportDataPlane === null) throw new Error("training export: data plane unavailable on this node");
|
|
27418
|
+
const ids = input.deviceIds ?? [];
|
|
27419
|
+
const query = ids.length > 0 ? `?deviceIds=${ids.join(",")}` : "";
|
|
27420
|
+
return { url: `/addon/${this.ctx.id}/${TRAINING_EXPORT_PREFIX}${query}` };
|
|
27421
|
+
}
|
|
26457
27422
|
/** Object/face embedding dispatcher — migrated from the retired
|
|
26458
27423
|
* enrichment-engine addon. It subscribes to DetectionResult, resolves the
|
|
26459
27424
|
* frame via the runner transport, crops each qualifying detection, and
|
|
@@ -26704,6 +27669,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26704
27669
|
this.eventMediaDataPlane = null;
|
|
26705
27670
|
this.eventMediaBaseUrl = null;
|
|
26706
27671
|
this.eventMediaDispatcher = null;
|
|
27672
|
+
await this.trainingExportDataPlane?.dispose();
|
|
27673
|
+
this.trainingExportDataPlane = null;
|
|
26707
27674
|
}
|
|
26708
27675
|
async handleInferenceResult(data) {
|
|
26709
27676
|
if (this.shuttingDown) return;
|
|
@@ -26848,12 +27815,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26848
27815
|
inPrevActive: false,
|
|
26849
27816
|
positionsCount: positionsCountById.get(id) ?? 1
|
|
26850
27817
|
}) === "resurrection") {
|
|
26851
|
-
log.info("track resumed", {
|
|
26852
|
-
|
|
26853
|
-
|
|
26854
|
-
|
|
26855
|
-
|
|
26856
|
-
|
|
27818
|
+
log.info("track resumed", {
|
|
27819
|
+
tags: { deviceId },
|
|
27820
|
+
meta: {
|
|
27821
|
+
trackId: id,
|
|
27822
|
+
className: t.className,
|
|
27823
|
+
source,
|
|
27824
|
+
resurrected: true
|
|
27825
|
+
}
|
|
27826
|
+
});
|
|
26857
27827
|
if (this.eventMediaDispatcher) {
|
|
26858
27828
|
this.residents.setLastFrameAt(deviceId, id, result.timestamp);
|
|
26859
27829
|
if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
|
|
@@ -26988,10 +27958,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
26988
27958
|
let lostTrackCount = 0;
|
|
26989
27959
|
for (const id of prevIds) if (!currentTrackIds.has(id)) {
|
|
26990
27960
|
lostTrackCount += 1;
|
|
26991
|
-
log.info("track lost", {
|
|
26992
|
-
|
|
26993
|
-
|
|
26994
|
-
|
|
27961
|
+
log.info("track lost", {
|
|
27962
|
+
tags: { deviceId },
|
|
27963
|
+
meta: {
|
|
27964
|
+
trackId: id,
|
|
27965
|
+
source
|
|
27966
|
+
}
|
|
27967
|
+
});
|
|
26995
27968
|
}
|
|
26996
27969
|
this.suppressedBirths.retain(key, currentTrackIds);
|
|
26997
27970
|
this.deferredBirths.retain(key, currentTrackIds);
|
|
@@ -27228,6 +28201,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
27228
28201
|
this.residents.markFirstFrameLanded(deviceId, trackId);
|
|
27229
28202
|
}
|
|
27230
28203
|
for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
|
|
28204
|
+
const landedSnapshots = new Set(res.storedSnapshots.map((s) => s.trackId));
|
|
28205
|
+
for (const target of snapshotTargets) {
|
|
28206
|
+
if (!target.appendSnapshot || landedSnapshots.has(target.trackId)) continue;
|
|
28207
|
+
const gaveBack = this.trackStore?.rollbackSnapshotPending(target.trackId, target.timestamp);
|
|
28208
|
+
log.warn("snapshot capture did not land", {
|
|
28209
|
+
tags: { deviceId },
|
|
28210
|
+
meta: {
|
|
28211
|
+
trackId: target.trackId,
|
|
28212
|
+
timestamp: target.timestamp,
|
|
28213
|
+
intervalReturned: gaveBack === true
|
|
28214
|
+
}
|
|
28215
|
+
});
|
|
28216
|
+
}
|
|
27231
28217
|
})
|
|
27232
28218
|
}).catch(() => {});
|
|
27233
28219
|
}
|
|
@@ -29026,8 +30012,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29026
30012
|
}
|
|
29027
30013
|
/**
|
|
29028
30014
|
* Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
|
|
29029
|
-
* mapped sensor cap. Resolves the
|
|
29030
|
-
*
|
|
30015
|
+
* mapped sensor cap. Resolves the camera set through the TTL cache (the
|
|
30016
|
+
* device's LINKED cameras, plus the device itself when it is a camera
|
|
30017
|
+
* carrying the cap) and inserts ONE row per camera. Best-effort
|
|
30018
|
+
* (telemetry-lossy).
|
|
30019
|
+
*
|
|
30020
|
+
* D8/D11 boundary, stated plainly: the ride here is the LOSSY
|
|
30021
|
+
* `DeviceStateChanged` bus, and there is no reconcile that replays a missed
|
|
30022
|
+
* press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
|
|
30023
|
+
* event is a missing history row and a missing notification — never wrong
|
|
30024
|
+
* durable state.
|
|
29031
30025
|
*/
|
|
29032
30026
|
async handleSensorStateChanged(data, timestamp) {
|
|
29033
30027
|
const store = this.sensorEventStore;
|
|
@@ -29037,7 +30031,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29037
30031
|
await ingestSensorStateChange({
|
|
29038
30032
|
sink: store,
|
|
29039
30033
|
cache,
|
|
29040
|
-
onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
|
|
30034
|
+
onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
|
|
30035
|
+
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
29041
30036
|
}, data, timestamp);
|
|
29042
30037
|
} catch (err) {
|
|
29043
30038
|
this.ctx.logger.warn("sensor-event ingest failed", {
|
|
@@ -29053,7 +30048,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29053
30048
|
if (materializer === null || descriptor === void 0) return;
|
|
29054
30049
|
try {
|
|
29055
30050
|
const cameraIds = await cache.camerasFor(data.deviceId);
|
|
29056
|
-
if (cameraIds.length === 0)
|
|
30051
|
+
if (cameraIds.length === 0) {
|
|
30052
|
+
this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
|
|
30053
|
+
tags: { deviceId: data.deviceId },
|
|
30054
|
+
meta: {
|
|
30055
|
+
capName: data.capName,
|
|
30056
|
+
kind: descriptor.kind
|
|
30057
|
+
}
|
|
30058
|
+
});
|
|
30059
|
+
return;
|
|
30060
|
+
}
|
|
29057
30061
|
const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
|
|
29058
30062
|
for (const cameraId of cameraIds) await materializer.materialize({
|
|
29059
30063
|
cameraId,
|
|
@@ -29074,6 +30078,35 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29074
30078
|
}
|
|
29075
30079
|
}
|
|
29076
30080
|
/**
|
|
30081
|
+
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|
|
30082
|
+
*
|
|
30083
|
+
* BOTH branches are `debug`, deliberately. After the self-camera fix neither
|
|
30084
|
+
* is a surprise any more — they are the two designed steady states, and both
|
|
30085
|
+
* ride at bus rate:
|
|
30086
|
+
*
|
|
30087
|
+
* - `camera-native-cap` is EVERY camera's `motion` slice. Every provider
|
|
30088
|
+
* binds a native `motion` cap on the camera device and motion churns
|
|
30089
|
+
* continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
|
|
30090
|
+
* log flood measured in millions of lines a day — its own outage.
|
|
30091
|
+
* - `no-camera` is every sensor in the fleet that no camera links.
|
|
30092
|
+
*
|
|
30093
|
+
* What must never happen again is the branch being INVISIBLE: a doorbell
|
|
30094
|
+
* press that ingested nothing produced not one line for months. A debug line
|
|
30095
|
+
* carrying `deviceId`, `capName`, `kind` and `reason` answers "why is 615
|
|
30096
|
+
* dark" the moment anyone asks it.
|
|
30097
|
+
*/
|
|
30098
|
+
logSensorIngestDrop(data, reason, kind) {
|
|
30099
|
+
const message = reason === "camera-native-cap" ? "sensor-event ingest produced NO row — the camera already emits this cap through its own path" : "sensor-event ingest produced NO row — the device is not a camera and is linked to none";
|
|
30100
|
+
this.ctx.logger.debug(message, {
|
|
30101
|
+
tags: { deviceId: data.deviceId },
|
|
30102
|
+
meta: {
|
|
30103
|
+
capName: data.capName,
|
|
30104
|
+
kind,
|
|
30105
|
+
reason
|
|
30106
|
+
}
|
|
30107
|
+
});
|
|
30108
|
+
}
|
|
30109
|
+
/**
|
|
29077
30110
|
* Resolve the NAME of a device (the linked sensor/control that produced a
|
|
29078
30111
|
* synthetic event) via the device-manager cap. Best-effort — returns
|
|
29079
30112
|
* undefined on any lookup failure or an unknown device so the synthetic
|
|
@@ -29790,15 +30823,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29790
30823
|
}
|
|
29791
30824
|
/**
|
|
29792
30825
|
* Set the per-track operator flags (`markForTrain`, `debug`). Partial patch —
|
|
29793
|
-
* see `applyTrackFlags`.
|
|
29794
|
-
*
|
|
30826
|
+
* see `applyTrackFlags`. `markForTrain` moves the retrain LIFECYCLE
|
|
30827
|
+
* (`none ↔ staging`, D81), and `staging` is what PINS the track against the
|
|
30828
|
+
* retention sweep — which is why the body enforces a per-device staging budget
|
|
30829
|
+
* and refuses any move on an already-`trained` track. `debug` is attention
|
|
30830
|
+
* only. This is the write surface the three UI toggles share.
|
|
29795
30831
|
*/
|
|
29796
30832
|
async setTrackFlags(input) {
|
|
29797
30833
|
const trackStore = this.trackStore;
|
|
29798
30834
|
if (!trackStore) throw new Error("setTrackFlags: track store unavailable");
|
|
29799
30835
|
const out = await applyTrackFlags({
|
|
29800
|
-
|
|
29801
|
-
|
|
30836
|
+
store: {
|
|
30837
|
+
readFlags: (trackId) => trackStore.readFlags(trackId),
|
|
30838
|
+
setFlags: (trackId, patch) => trackStore.setFlags(trackId, patch),
|
|
30839
|
+
countStaging: (query) => trackStore.countStaging(query)
|
|
30840
|
+
},
|
|
30841
|
+
logger: this.ctx.logger
|
|
29802
30842
|
}, input);
|
|
29803
30843
|
this.ctx.logger.info("track operator flags set", {
|
|
29804
30844
|
tags: { deviceId: input.deviceId },
|
|
@@ -29806,6 +30846,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29806
30846
|
deviceId: input.deviceId,
|
|
29807
30847
|
trackId: input.trackId,
|
|
29808
30848
|
markForTrain: out.markForTrain,
|
|
30849
|
+
retrainStatus: out.retrainStatus,
|
|
29809
30850
|
debug: out.debug
|
|
29810
30851
|
}
|
|
29811
30852
|
});
|
|
@@ -29929,6 +30970,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29929
30970
|
deviceId,
|
|
29930
30971
|
cutoffMs
|
|
29931
30972
|
});
|
|
30973
|
+
const staging = await this.trackStore?.countStaging({
|
|
30974
|
+
deviceId,
|
|
30975
|
+
olderThanMs: cutoffMs
|
|
30976
|
+
});
|
|
30977
|
+
if (staging !== void 0 && staging > 0) this.ctx.logger.info("track retention held staging tracks (retrainStatus)", {
|
|
30978
|
+
tags: { deviceId },
|
|
30979
|
+
meta: {
|
|
30980
|
+
deviceId,
|
|
30981
|
+
staging,
|
|
30982
|
+
cutoffMs,
|
|
30983
|
+
deleted: counts.tracks
|
|
30984
|
+
}
|
|
30985
|
+
});
|
|
29932
30986
|
if (counts.tracks > 0) this.eventsOpsLog?.append({
|
|
29933
30987
|
op: "prune",
|
|
29934
30988
|
reason: "retention",
|