@camstack/addon-post-analysis 1.2.301 → 1.2.302
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/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-BT-o174v.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DVMLLink.mjs} +1 -1
- package/dist/pipeline-analytics/{hostInit-D2uBFmPA.mjs → hostInit-DRjMNOyG.mjs} +1 -1
- package/dist/pipeline-analytics/index.js +865 -216
- package/dist/pipeline-analytics/index.mjs +865 -216
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -19644,6 +19644,101 @@ function footageSourceFor(input) {
|
|
|
19644
19644
|
return ageSec + preRollSec > ringRetentionSec ? "recordings" : "ring";
|
|
19645
19645
|
}
|
|
19646
19646
|
//#endregion
|
|
19647
|
+
//#region src/notification-center/recorded-cut-memo.ts
|
|
19648
|
+
/** How long the recorder is treated as busy after a cut timed out. */
|
|
19649
|
+
var NC_RECORDER_BUSY_MS = 6e4;
|
|
19650
|
+
/**
|
|
19651
|
+
* How long a cut is reused. The outbox retries at 5 s, 10 s, 20 s … so ten
|
|
19652
|
+
* minutes covers every retry that could still be about the same notification.
|
|
19653
|
+
*/
|
|
19654
|
+
var NC_RECORDED_CUT_MEMO_TTL_MS = 10 * 6e4;
|
|
19655
|
+
/** At most this many cuts are held (each is a ~10 s clip, a few hundred KB). */
|
|
19656
|
+
var NC_RECORDED_CUT_MEMO_MAX = 16;
|
|
19657
|
+
/**
|
|
19658
|
+
* Grace a joining row adds to the cut's own deadline: the recorder checks its
|
|
19659
|
+
* deadline between 1 s polls, so its `timed-out` lands up to ~1 s after it.
|
|
19660
|
+
*/
|
|
19661
|
+
var NC_RECORDED_CUT_JOIN_GRACE_MS = 2e3;
|
|
19662
|
+
/** The window a request describes — rows that agree on it share a cut. */
|
|
19663
|
+
function recordedCutKey(req) {
|
|
19664
|
+
return [
|
|
19665
|
+
req.deviceId,
|
|
19666
|
+
req.recordId,
|
|
19667
|
+
req.aroundMs,
|
|
19668
|
+
req.preRollSec,
|
|
19669
|
+
req.postRollSec,
|
|
19670
|
+
req.speed ?? 1
|
|
19671
|
+
].join(":");
|
|
19672
|
+
}
|
|
19673
|
+
var NcRecordedCutMemo = class {
|
|
19674
|
+
cuts = /* @__PURE__ */ new Map();
|
|
19675
|
+
busyUntil = 0;
|
|
19676
|
+
/** When the busy window closes (0 when it never opened). */
|
|
19677
|
+
busyUntilMs() {
|
|
19678
|
+
return this.busyUntil;
|
|
19679
|
+
}
|
|
19680
|
+
/** Is the recorder inside its busy window? */
|
|
19681
|
+
isBusy(now) {
|
|
19682
|
+
return now < this.busyUntil;
|
|
19683
|
+
}
|
|
19684
|
+
/** A cut timed out: open the breaker. Returns when it closes. */
|
|
19685
|
+
noteTimedOut(now) {
|
|
19686
|
+
this.busyUntil = Math.max(this.busyUntil, now + NC_RECORDER_BUSY_MS);
|
|
19687
|
+
return this.busyUntil;
|
|
19688
|
+
}
|
|
19689
|
+
/** The memoised cut for this window, if one is held. */
|
|
19690
|
+
find(key, now) {
|
|
19691
|
+
this.evictAged(now);
|
|
19692
|
+
return this.cuts.get(key) ?? null;
|
|
19693
|
+
}
|
|
19694
|
+
/** Start (and hold) the cut for this window. */
|
|
19695
|
+
start(key, now, deadlineMs, run) {
|
|
19696
|
+
this.evict(now);
|
|
19697
|
+
const cut = run().then((result) => result.ok ? {
|
|
19698
|
+
kind: "cut",
|
|
19699
|
+
mp4: result.mp4,
|
|
19700
|
+
profile: result.profile
|
|
19701
|
+
} : {
|
|
19702
|
+
kind: "refused",
|
|
19703
|
+
result
|
|
19704
|
+
}, (err) => ({
|
|
19705
|
+
kind: "threw",
|
|
19706
|
+
error: String(err)
|
|
19707
|
+
}));
|
|
19708
|
+
const shared = {
|
|
19709
|
+
key,
|
|
19710
|
+
createdAt: now,
|
|
19711
|
+
deadlineMs,
|
|
19712
|
+
cut,
|
|
19713
|
+
gif: null,
|
|
19714
|
+
settled: false
|
|
19715
|
+
};
|
|
19716
|
+
cut.then(() => {
|
|
19717
|
+
shared.settled = true;
|
|
19718
|
+
});
|
|
19719
|
+
this.cuts.set(key, shared);
|
|
19720
|
+
return shared;
|
|
19721
|
+
}
|
|
19722
|
+
/** How many cuts are held — diagnostics and tests. */
|
|
19723
|
+
size() {
|
|
19724
|
+
return this.cuts.size;
|
|
19725
|
+
}
|
|
19726
|
+
/** Drop cuts past their TTL. Called on every use AND from the notification
|
|
19727
|
+
* centre's periodic drain tick, so bytes do not outlive the last row. */
|
|
19728
|
+
evictAged(now) {
|
|
19729
|
+
for (const [key, c] of this.cuts) if (now - c.createdAt > NC_RECORDED_CUT_MEMO_TTL_MS) this.cuts.delete(key);
|
|
19730
|
+
}
|
|
19731
|
+
/** Make room for one more: aged entries first, then the oldest. */
|
|
19732
|
+
evict(now) {
|
|
19733
|
+
this.evictAged(now);
|
|
19734
|
+
while (this.cuts.size >= NC_RECORDED_CUT_MEMO_MAX) {
|
|
19735
|
+
const oldest = this.cuts.keys().next();
|
|
19736
|
+
if (oldest.done === true) break;
|
|
19737
|
+
this.cuts.delete(oldest.value);
|
|
19738
|
+
}
|
|
19739
|
+
}
|
|
19740
|
+
};
|
|
19741
|
+
//#endregion
|
|
19647
19742
|
//#region src/notification-center/media-anchor.ts
|
|
19648
19743
|
/**
|
|
19649
19744
|
* Decide the footage anchor for one subject. Pure, total, and the ONLY place
|
|
@@ -19793,6 +19888,20 @@ function composeRuleAiBody(input) {
|
|
|
19793
19888
|
if (input.templateNamesAi) return input.body;
|
|
19794
19889
|
return input.body.length === 0 ? input.aiText : `${input.body}\n${input.aiText}`;
|
|
19795
19890
|
}
|
|
19891
|
+
/**
|
|
19892
|
+
* `work`, or `onTimeout` once `ms` has passed — whichever is first. `work` must
|
|
19893
|
+
* never reject. The hard edge of the recorded-footage wait: the recorder's own
|
|
19894
|
+
* `deadlineMs` is checked between polls, and an RPC in flight is not.
|
|
19895
|
+
*/
|
|
19896
|
+
function settleWithin(work, ms, onTimeout) {
|
|
19897
|
+
return new Promise((resolve) => {
|
|
19898
|
+
const timer = setTimeout(() => resolve(onTimeout), ms);
|
|
19899
|
+
work.then((value) => {
|
|
19900
|
+
clearTimeout(timer);
|
|
19901
|
+
resolve(value);
|
|
19902
|
+
});
|
|
19903
|
+
});
|
|
19904
|
+
}
|
|
19796
19905
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
19797
19906
|
/**
|
|
19798
19907
|
* How long the broker retains the footage window a notification is cut from,
|
|
@@ -19811,6 +19920,27 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
|
19811
19920
|
*/
|
|
19812
19921
|
var NC_CLIP_RING_RETENTION_SEC = 20;
|
|
19813
19922
|
/**
|
|
19923
|
+
* How long a notification whose moment the ring has lost waits for its
|
|
19924
|
+
* RECORDED cut (and the gif derived from it) before it ships without (D686).
|
|
19925
|
+
*
|
|
19926
|
+
* The notification is HELD for this: a notifier is one-shot, so the footage
|
|
19927
|
+
* rides the single message or does not ride at all. Measured on the
|
|
19928
|
+
* 2026-09-30 pick-up on 615: export 1 s, derive 262 ms — so the bound is
|
|
19929
|
+
* almost never reached, and when it is the render is queued behind something
|
|
19930
|
+
* that holds the recorder's one ffmpeg lane for minutes (a timelapse), which
|
|
19931
|
+
* no wait worth holding a notification for would outlast.
|
|
19932
|
+
*
|
|
19933
|
+
* Why not longer: the outbox drains rows ONE AT A TIME, so this is also the
|
|
19934
|
+
* worst case by which every row queued behind this one is delayed. A ring cut
|
|
19935
|
+
* of a live moment already blocks the drain for its post-roll (6 s) plus the
|
|
19936
|
+
* render; 30 s is the ceiling on top of that, reached only when the recorder
|
|
19937
|
+
* is wedged. Why not shorter: a moment that reaches this path is already
|
|
19938
|
+
* older than the ring (≥ 20 s, a pick-up ≥ 5 min), so a few more seconds cost
|
|
19939
|
+
* the operator nothing, and a bound under ~10 s would drop the gif whenever
|
|
19940
|
+
* the recorder is merely busy.
|
|
19941
|
+
*/
|
|
19942
|
+
var NC_RECORDED_FOOTAGE_WAIT_MS = 3e4;
|
|
19943
|
+
/**
|
|
19814
19944
|
* The bounded wait for a still when the rule asked for NO footage.
|
|
19815
19945
|
*
|
|
19816
19946
|
* Track media lands 1.5–5.5 s after the trigger (device 615, 2026-08-12), so a
|
|
@@ -19888,9 +20018,8 @@ var NcDispatcher = class {
|
|
|
19888
20018
|
* {@link NC_ANNOTATED_FALLBACK_MEMO_MAX}. */
|
|
19889
20019
|
reportedAnnotatedFallbacks = /* @__PURE__ */ new Set();
|
|
19890
20020
|
texts;
|
|
19891
|
-
/**
|
|
19892
|
-
|
|
19893
|
-
recordedFootage = /* @__PURE__ */ new Set();
|
|
20021
|
+
/** One recorded cut per moment, and the recorder-busy breaker (D686). */
|
|
20022
|
+
recordedCuts = new NcRecordedCutMemo();
|
|
19894
20023
|
constructor(deps) {
|
|
19895
20024
|
this.deps = deps;
|
|
19896
20025
|
this.now = deps.now ?? (() => Date.now());
|
|
@@ -19898,6 +20027,18 @@ var NcDispatcher = class {
|
|
|
19898
20027
|
this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
|
|
19899
20028
|
this.texts = deps.texts ?? new NcTextCatalog();
|
|
19900
20029
|
}
|
|
20030
|
+
/**
|
|
20031
|
+
* Drop held recorded cuts past their TTL (re-review R4). Driven by the
|
|
20032
|
+
* notification centre's drain tick, so a clip's bytes do not stay resident
|
|
20033
|
+
* after the last row that could use them.
|
|
20034
|
+
*/
|
|
20035
|
+
sweepRecordedCuts(now) {
|
|
20036
|
+
this.recordedCuts.evictAged(now);
|
|
20037
|
+
}
|
|
20038
|
+
/** How many recorded cuts are held — diagnostics and tests. */
|
|
20039
|
+
recordedCutsHeld() {
|
|
20040
|
+
return this.recordedCuts.size();
|
|
20041
|
+
}
|
|
19901
20042
|
/** The outbox `deliver` executor. */
|
|
19902
20043
|
async deliver(entry) {
|
|
19903
20044
|
if (this.deps.isRuleTargetDisabled !== void 0) {
|
|
@@ -20020,7 +20161,6 @@ var NcDispatcher = class {
|
|
|
20020
20161
|
...dropped.length > 0 ? { dropped } : {}
|
|
20021
20162
|
}
|
|
20022
20163
|
});
|
|
20023
|
-
this.startRecordedFootage(entry, target, notification);
|
|
20024
20164
|
return {
|
|
20025
20165
|
ok: true,
|
|
20026
20166
|
...verdict !== null ? { confirm: verdict } : {},
|
|
@@ -20565,7 +20705,7 @@ var NcDispatcher = class {
|
|
|
20565
20705
|
priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
|
|
20566
20706
|
...params,
|
|
20567
20707
|
...tapUrl !== null ? { clickUrl: tapUrl } : {},
|
|
20568
|
-
tag: entry.payload.group
|
|
20708
|
+
...entry.payload.group !== void 0 ? { tag: entry.payload.group.key } : {},
|
|
20569
20709
|
...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
|
|
20570
20710
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
20571
20711
|
...systemEvent !== void 0 ? { metadata: {
|
|
@@ -20837,13 +20977,10 @@ var NcDispatcher = class {
|
|
|
20837
20977
|
preRollSec,
|
|
20838
20978
|
retentionSec: this.ringRetentionSec(entry.payload.subject.deviceId),
|
|
20839
20979
|
wanted: [...wantGif ? ["gif"] : [], ...wantMp4 ? ["video"] : []],
|
|
20840
|
-
next: this.
|
|
20980
|
+
next: this.recordedFootagePlan(entry).kinds.length > 0 ? "recorded-cut" : "no-clip"
|
|
20841
20981
|
}
|
|
20842
20982
|
});
|
|
20843
|
-
return
|
|
20844
|
-
attempted: false,
|
|
20845
|
-
attachments: []
|
|
20846
|
-
};
|
|
20983
|
+
return this.cutRecordedFootage(entry, anchor, preRollSec);
|
|
20847
20984
|
}
|
|
20848
20985
|
const attachments = [];
|
|
20849
20986
|
try {
|
|
@@ -20878,176 +21015,238 @@ var NcDispatcher = class {
|
|
|
20878
21015
|
};
|
|
20879
21016
|
}
|
|
20880
21017
|
/**
|
|
20881
|
-
*
|
|
20882
|
-
*
|
|
21018
|
+
* Cut the footage from the RECORDINGS, for a moment the ring could not
|
|
21019
|
+
* serve, and hand it to the ONE notification this row sends (D686).
|
|
20883
21020
|
*
|
|
20884
|
-
* ## Why
|
|
21021
|
+
* ## Why the notification waits for it
|
|
20885
21022
|
*
|
|
20886
|
-
*
|
|
20887
|
-
*
|
|
20888
|
-
* a
|
|
20889
|
-
*
|
|
20890
|
-
*
|
|
20891
|
-
*
|
|
20892
|
-
*
|
|
21023
|
+
* A notifier is one-shot. The earlier design (D682) sent the alert first and
|
|
21024
|
+
* the recorded footage as a SECOND send with the same `tag`, on the promise
|
|
21025
|
+
* that "a phone collapses the two" — Zentik honours no tag, so every such
|
|
21026
|
+
* row reached the operator as two messages (615, 2026-09-30). A moment that
|
|
21027
|
+
* reaches this path is already older than the ring (a pick-up is concluded
|
|
21028
|
+
* minutes after it happened), so waiting a few more seconds costs nothing;
|
|
21029
|
+
* a second message costs the operator a duplicate every time.
|
|
20893
21030
|
*
|
|
20894
|
-
*
|
|
20895
|
-
*
|
|
20896
|
-
*
|
|
20897
|
-
*
|
|
20898
|
-
*
|
|
20899
|
-
* the wrong time.
|
|
21031
|
+
* The wait is bounded by {@link NC_RECORDED_FOOTAGE_WAIT_MS} and the bound is
|
|
21032
|
+
* handed to the recorder as `deadlineMs`, so a render nobody waits for any
|
|
21033
|
+
* more is abandoned, not left running. A cut that fails, throws or is not
|
|
21034
|
+
* ready in time costs the footage and never the notification — it ships
|
|
21035
|
+
* with the still, and the reason is logged per camera.
|
|
20900
21036
|
*
|
|
20901
|
-
*
|
|
20902
|
-
*
|
|
20903
|
-
*
|
|
20904
|
-
*
|
|
20905
|
-
* and not a silent one: the log says `recorded-clip-follow-up` on the refusal
|
|
20906
|
-
* line, and says so again when the follow-up lands.
|
|
21037
|
+
* A GIF rule is served here too (D682): the gif is derived from THIS cut's
|
|
21038
|
+
* bytes (`deriveGifFromClip`) — one cut, never a second renderer — and a
|
|
21039
|
+
* moment the recordings cannot serve ships NO gif, by named reason, rather
|
|
21040
|
+
* than a gif of the wrong time.
|
|
20907
21041
|
*
|
|
20908
|
-
*
|
|
20909
|
-
*
|
|
20910
|
-
* `footageSourceFor` is the ONE rule, and both callers ask it rather than one
|
|
20911
|
-
* of them remembering the other's answer. It is monotone in the clock, so the
|
|
20912
|
-
* later ask here can only ever agree with, or become stricter than, the one
|
|
20913
|
-
* `renderFootage` made — it can never decide "the ring" for a window the ring
|
|
20914
|
-
* has already dropped. A second spelling of the arithmetic is what would be
|
|
20915
|
-
* unsafe; a second CALL is not.
|
|
20916
|
-
*
|
|
20917
|
-
* Never throws, never rejects: this runs detached from a delivery that has
|
|
20918
|
-
* already succeeded, and an unhandled rejection here would be a crash charged
|
|
20919
|
-
* to a notification that arrived perfectly.
|
|
21042
|
+
* `footageSourceFor` already said `recordings` for this row: the caller asks
|
|
21043
|
+
* it once, and this runs only on that answer.
|
|
20920
21044
|
*/
|
|
20921
|
-
|
|
21045
|
+
async cutRecordedFootage(entry, anchor, preRollSec) {
|
|
21046
|
+
const plan = this.recordedFootagePlan(entry);
|
|
21047
|
+
this.reportRecordedRefusals(entry, plan, anchor);
|
|
20922
21048
|
const cut = this.deps.cutRecordedClip;
|
|
20923
|
-
|
|
20924
|
-
|
|
20925
|
-
|
|
20926
|
-
|
|
20927
|
-
const
|
|
20928
|
-
|
|
20929
|
-
|
|
20930
|
-
|
|
21049
|
+
if (cut === void 0 || plan.kinds.length === 0) return {
|
|
21050
|
+
attempted: false,
|
|
21051
|
+
attachments: []
|
|
21052
|
+
};
|
|
21053
|
+
const startedAt = this.now();
|
|
21054
|
+
const videoSpeed = entry.payload.mediaClipSpeed;
|
|
21055
|
+
const window = {
|
|
21056
|
+
deviceId: entry.payload.subject.deviceId,
|
|
21057
|
+
aroundMs: anchor.atMs,
|
|
20931
21058
|
preRollSec,
|
|
20932
|
-
|
|
20933
|
-
|
|
20934
|
-
|
|
20935
|
-
|
|
20936
|
-
|
|
20937
|
-
|
|
21059
|
+
postRollSec: entry.payload.mediaClipPostRollSec ?? NC_CLIP_POST_ROLL_DEFAULT_SEC,
|
|
21060
|
+
...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
|
|
21061
|
+
recordId: entry.recordId
|
|
21062
|
+
};
|
|
21063
|
+
const key = recordedCutKey(window);
|
|
21064
|
+
let shared = this.recordedCuts.find(key, startedAt);
|
|
21065
|
+
let boundMs = NC_RECORDED_FOOTAGE_WAIT_MS;
|
|
21066
|
+
if (shared !== null && !shared.settled) {
|
|
21067
|
+
if (this.recordedCuts.isBusy(startedAt)) {
|
|
21068
|
+
const outcome = {
|
|
21069
|
+
kind: "recorder-busy",
|
|
21070
|
+
busyUntil: this.recordedCuts.busyUntilMs()
|
|
21071
|
+
};
|
|
21072
|
+
this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
|
|
21073
|
+
return {
|
|
21074
|
+
attempted: false,
|
|
21075
|
+
attachments: []
|
|
21076
|
+
};
|
|
21077
|
+
}
|
|
21078
|
+
boundMs = Math.min(NC_RECORDED_FOOTAGE_WAIT_MS, shared.deadlineMs + NC_RECORDED_CUT_JOIN_GRACE_MS - startedAt);
|
|
21079
|
+
if (boundMs <= 0) {
|
|
21080
|
+
this.reportRecordedOutcome(entry, plan.kinds, anchor, { kind: "timed-out" }, startedAt);
|
|
21081
|
+
return {
|
|
21082
|
+
attempted: false,
|
|
21083
|
+
attachments: []
|
|
21084
|
+
};
|
|
21085
|
+
}
|
|
21086
|
+
}
|
|
21087
|
+
if (shared === null) {
|
|
21088
|
+
if (this.recordedCuts.isBusy(startedAt)) {
|
|
21089
|
+
const outcome = {
|
|
21090
|
+
kind: "recorder-busy",
|
|
21091
|
+
busyUntil: this.recordedCuts.busyUntilMs()
|
|
21092
|
+
};
|
|
21093
|
+
this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
|
|
21094
|
+
return {
|
|
21095
|
+
attempted: false,
|
|
21096
|
+
attachments: []
|
|
21097
|
+
};
|
|
21098
|
+
}
|
|
21099
|
+
const deadlineMs = startedAt + NC_RECORDED_FOOTAGE_WAIT_MS;
|
|
21100
|
+
shared = this.recordedCuts.start(key, startedAt, deadlineMs, () => cut({
|
|
21101
|
+
...window,
|
|
20938
21102
|
ruleId: entry.ruleId,
|
|
20939
|
-
|
|
20940
|
-
|
|
20941
|
-
|
|
20942
|
-
|
|
20943
|
-
|
|
20944
|
-
|
|
21103
|
+
deadlineMs
|
|
21104
|
+
}));
|
|
21105
|
+
}
|
|
21106
|
+
const outcome = await settleWithin(this.attachRecordedCut(entry, plan.kinds, shared), boundMs, { kind: "timed-out" });
|
|
21107
|
+
if (outcome.kind === "timed-out") this.recordedCuts.noteTimedOut(this.now());
|
|
21108
|
+
this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
|
|
21109
|
+
return {
|
|
21110
|
+
attempted: true,
|
|
21111
|
+
attachments: outcome.kind === "attached" ? outcome.attachments : []
|
|
21112
|
+
};
|
|
21113
|
+
}
|
|
21114
|
+
/** The shared cut, then this row's containers of it — never rejects. */
|
|
21115
|
+
async attachRecordedCut(entry, wanted, shared) {
|
|
21116
|
+
const result = await shared.cut;
|
|
21117
|
+
if (result.kind === "threw") return {
|
|
21118
|
+
kind: "threw",
|
|
21119
|
+
error: result.error
|
|
21120
|
+
};
|
|
21121
|
+
if (result.kind === "refused") return {
|
|
21122
|
+
kind: "cut-refused",
|
|
21123
|
+
reason: result.result.reason
|
|
21124
|
+
};
|
|
21125
|
+
try {
|
|
21126
|
+
const attachments = await this.recordedAttachments(entry, wanted, result.mp4, shared);
|
|
21127
|
+
if (attachments.length === 0) return { kind: "no-gif" };
|
|
21128
|
+
return {
|
|
21129
|
+
kind: "attached",
|
|
21130
|
+
attachments,
|
|
21131
|
+
profile: result.profile,
|
|
21132
|
+
bytes: result.mp4.byteLength
|
|
21133
|
+
};
|
|
21134
|
+
} catch (err) {
|
|
21135
|
+
return {
|
|
21136
|
+
kind: "threw",
|
|
21137
|
+
error: String(err)
|
|
21138
|
+
};
|
|
21139
|
+
}
|
|
21140
|
+
}
|
|
21141
|
+
/**
|
|
21142
|
+
* Why a kind the rule asked for is not cut from the recordings — said per
|
|
21143
|
+
* camera, because "where is my gif" is always asked about a camera.
|
|
21144
|
+
* `no-derive` included: a wiring seam, but a silent one is a dropped gif.
|
|
21145
|
+
*/
|
|
21146
|
+
reportRecordedRefusals(entry, plan, anchor) {
|
|
21147
|
+
const common = {
|
|
21148
|
+
ruleId: entry.ruleId,
|
|
21149
|
+
eventId: entry.recordId,
|
|
21150
|
+
targetId: entry.targetId,
|
|
21151
|
+
anchoredOn: anchor.source,
|
|
21152
|
+
...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
|
|
21153
|
+
next: plan.kinds
|
|
21154
|
+
};
|
|
21155
|
+
const tags = { deviceId: entry.payload.subject.deviceId };
|
|
21156
|
+
if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif: none — the gif is not cut from the recordings", {
|
|
21157
|
+
tags,
|
|
21158
|
+
meta: {
|
|
21159
|
+
...common,
|
|
21160
|
+
reason: plan.gifRefused
|
|
20945
21161
|
}
|
|
20946
21162
|
});
|
|
20947
|
-
if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video
|
|
20948
|
-
tags
|
|
21163
|
+
if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video: none — the video is not cut from the recordings", {
|
|
21164
|
+
tags,
|
|
20949
21165
|
meta: {
|
|
20950
|
-
|
|
20951
|
-
|
|
20952
|
-
targetId: target.id,
|
|
20953
|
-
reason: plan.videoRefused,
|
|
20954
|
-
anchoredOn: anchor.source,
|
|
20955
|
-
...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
|
|
20956
|
-
next: wanted
|
|
21166
|
+
...common,
|
|
21167
|
+
reason: plan.videoRefused
|
|
20957
21168
|
}
|
|
20958
21169
|
});
|
|
20959
|
-
|
|
20960
|
-
|
|
20961
|
-
|
|
20962
|
-
|
|
20963
|
-
|
|
20964
|
-
|
|
20965
|
-
|
|
20966
|
-
|
|
20967
|
-
|
|
20968
|
-
|
|
20969
|
-
|
|
20970
|
-
|
|
20971
|
-
|
|
20972
|
-
|
|
20973
|
-
|
|
21170
|
+
}
|
|
21171
|
+
/** One line per recorded cut, whatever became of it, tagged per camera. The
|
|
21172
|
+
* ring line said `next: recorded-cut`; this is where that story ends. */
|
|
21173
|
+
reportRecordedOutcome(entry, wanted, anchor, outcome, startedAt) {
|
|
21174
|
+
const tags = { deviceId: entry.payload.subject.deviceId };
|
|
21175
|
+
const common = {
|
|
21176
|
+
ruleId: entry.ruleId,
|
|
21177
|
+
eventId: entry.recordId,
|
|
21178
|
+
targetId: entry.targetId,
|
|
21179
|
+
wanted,
|
|
21180
|
+
anchoredOn: anchor.source
|
|
21181
|
+
};
|
|
21182
|
+
switch (outcome.kind) {
|
|
21183
|
+
case "attached":
|
|
21184
|
+
this.deps.logger.info("recorded footage attached", {
|
|
21185
|
+
tags,
|
|
20974
21186
|
meta: {
|
|
20975
|
-
|
|
20976
|
-
|
|
20977
|
-
|
|
20978
|
-
|
|
20979
|
-
|
|
21187
|
+
...common,
|
|
21188
|
+
profile: outcome.profile,
|
|
21189
|
+
bytes: outcome.bytes,
|
|
21190
|
+
attachments: outcome.attachments.map((a) => a.mediaType),
|
|
21191
|
+
waitedMs: this.now() - startedAt
|
|
20980
21192
|
}
|
|
20981
21193
|
});
|
|
20982
21194
|
return;
|
|
20983
|
-
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
this.deps.logger.info("recorded clip follow-up: none — the gif could not be derived", {
|
|
20987
|
-
tags: { deviceId },
|
|
21195
|
+
case "cut-refused":
|
|
21196
|
+
this.deps.logger.info("recorded footage: none — the recorded cut failed", {
|
|
21197
|
+
tags,
|
|
20988
21198
|
meta: {
|
|
20989
|
-
|
|
20990
|
-
|
|
20991
|
-
targetId: target.id,
|
|
20992
|
-
reason: "gif-derive-failed",
|
|
20993
|
-
wanted
|
|
21199
|
+
...common,
|
|
21200
|
+
reason: outcome.reason
|
|
20994
21201
|
}
|
|
20995
21202
|
});
|
|
20996
21203
|
return;
|
|
20997
|
-
|
|
20998
|
-
|
|
20999
|
-
|
|
21000
|
-
addonId: target.addonId,
|
|
21001
|
-
targetId: target.id,
|
|
21002
|
-
notification: {
|
|
21003
|
-
...notification,
|
|
21004
|
-
attachments: withUrls.attachments
|
|
21005
|
-
}
|
|
21006
|
-
});
|
|
21007
|
-
if (!sent.success) {
|
|
21008
|
-
this.deps.logger.warn("recorded clip follow-up: the send failed", {
|
|
21009
|
-
tags: { deviceId },
|
|
21204
|
+
case "no-gif":
|
|
21205
|
+
this.deps.logger.info("recorded footage: none — the gif could not be derived", {
|
|
21206
|
+
tags,
|
|
21010
21207
|
meta: {
|
|
21011
|
-
|
|
21012
|
-
|
|
21013
|
-
targetId: target.id,
|
|
21014
|
-
error: sent.error ?? "send failed"
|
|
21208
|
+
...common,
|
|
21209
|
+
reason: "gif-derive-failed"
|
|
21015
21210
|
}
|
|
21016
21211
|
});
|
|
21017
21212
|
return;
|
|
21018
|
-
|
|
21019
|
-
|
|
21020
|
-
|
|
21021
|
-
|
|
21022
|
-
|
|
21023
|
-
|
|
21024
|
-
|
|
21025
|
-
|
|
21026
|
-
|
|
21027
|
-
|
|
21028
|
-
|
|
21029
|
-
|
|
21030
|
-
|
|
21031
|
-
|
|
21032
|
-
|
|
21033
|
-
|
|
21034
|
-
|
|
21035
|
-
|
|
21036
|
-
|
|
21037
|
-
|
|
21038
|
-
|
|
21039
|
-
|
|
21040
|
-
|
|
21041
|
-
|
|
21042
|
-
|
|
21043
|
-
|
|
21044
|
-
|
|
21045
|
-
|
|
21213
|
+
case "threw":
|
|
21214
|
+
this.deps.logger.warn("recorded footage: none — the recorded cut threw", {
|
|
21215
|
+
tags,
|
|
21216
|
+
meta: {
|
|
21217
|
+
...common,
|
|
21218
|
+
reason: "threw",
|
|
21219
|
+
error: outcome.error
|
|
21220
|
+
}
|
|
21221
|
+
});
|
|
21222
|
+
return;
|
|
21223
|
+
case "timed-out":
|
|
21224
|
+
this.deps.logger.warn("recorded footage: none — not ready inside the bound", {
|
|
21225
|
+
tags,
|
|
21226
|
+
meta: {
|
|
21227
|
+
...common,
|
|
21228
|
+
reason: "timed-out",
|
|
21229
|
+
boundMs: NC_RECORDED_FOOTAGE_WAIT_MS,
|
|
21230
|
+
busyForMs: NC_RECORDER_BUSY_MS
|
|
21231
|
+
}
|
|
21232
|
+
});
|
|
21233
|
+
return;
|
|
21234
|
+
case "recorder-busy":
|
|
21235
|
+
this.deps.logger.info("recorded footage: none — the recorder is busy", {
|
|
21236
|
+
tags,
|
|
21237
|
+
meta: {
|
|
21238
|
+
...common,
|
|
21239
|
+
reason: "recorder-busy",
|
|
21240
|
+
busyUntil: outcome.busyUntil
|
|
21241
|
+
}
|
|
21242
|
+
});
|
|
21243
|
+
return;
|
|
21244
|
+
}
|
|
21046
21245
|
}
|
|
21047
21246
|
/**
|
|
21048
|
-
* What
|
|
21049
|
-
*
|
|
21050
|
-
*
|
|
21247
|
+
* What the recorded cut for this row would CARRY — the one answer both the
|
|
21248
|
+
* ring line (`next`) and the cut read, so the log cannot promise a cut the
|
|
21249
|
+
* dispatcher will not order.
|
|
21051
21250
|
*
|
|
21052
21251
|
* A video when the rule asked for one. A gif only for a package PICK-UP
|
|
21053
21252
|
* whose moment is KNOWN (D682): the gif is derived from the recorded cut's
|
|
@@ -21061,7 +21260,7 @@ var NcDispatcher = class {
|
|
|
21061
21260
|
* gif OR video — cutting the recordings there is exactly the doormat D682
|
|
21062
21261
|
* removed (see {@link NcRecordedGifRefusal}, {@link NcRecordedVideoRefusal}).
|
|
21063
21262
|
*/
|
|
21064
|
-
|
|
21263
|
+
recordedFootagePlan(entry) {
|
|
21065
21264
|
if (this.deps.cutRecordedClip === void 0) return { kinds: [] };
|
|
21066
21265
|
const gifRefused = this.recordedGifRefusal(entry);
|
|
21067
21266
|
const videoRefused = this.recordedVideoRefusal(entry);
|
|
@@ -21092,48 +21291,39 @@ var NcDispatcher = class {
|
|
|
21092
21291
|
if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
|
|
21093
21292
|
}
|
|
21094
21293
|
/**
|
|
21095
|
-
* The
|
|
21096
|
-
*
|
|
21097
|
-
* reason already logged); the video, when asked for, still ships.
|
|
21294
|
+
* The recorded attachments, gif first and video second — the order the ring
|
|
21295
|
+
* path ships them in. A gif that could not be derived is left out (its
|
|
21296
|
+
* reason already logged); the video, when asked for, still ships. A derive
|
|
21297
|
+
* is not started once the bound has passed: nobody would wait for it.
|
|
21098
21298
|
*/
|
|
21099
|
-
async recordedAttachments(entry, wanted, mp4) {
|
|
21299
|
+
async recordedAttachments(entry, wanted, mp4, shared) {
|
|
21100
21300
|
const out = [];
|
|
21101
21301
|
const derive = this.deps.deriveGifFromClip;
|
|
21102
21302
|
if (wanted.includes("gif") && derive !== void 0) {
|
|
21103
|
-
|
|
21303
|
+
if (shared.gif === null && this.now() < shared.deadlineMs) shared.gif = derive({
|
|
21104
21304
|
deviceId: entry.payload.subject.deviceId,
|
|
21105
21305
|
mp4,
|
|
21106
21306
|
...entry.payload.mediaClipSpeed !== void 0 ? { videoSpeed: entry.payload.mediaClipSpeed } : {},
|
|
21107
21307
|
ruleId: entry.ruleId,
|
|
21108
21308
|
recordId: entry.recordId
|
|
21109
21309
|
});
|
|
21110
|
-
|
|
21310
|
+
const gif = shared.gif === null ? null : await shared.gif;
|
|
21311
|
+
if (gif !== null && gif.ok) out.push({
|
|
21111
21312
|
mediaType: "gif",
|
|
21112
|
-
bytes: gif.gif,
|
|
21313
|
+
bytes: copyBytes(gif.gif),
|
|
21113
21314
|
mime: "image/gif",
|
|
21114
21315
|
name: "event.gif"
|
|
21115
21316
|
});
|
|
21116
21317
|
}
|
|
21117
21318
|
if (wanted.includes("video")) out.push({
|
|
21118
21319
|
mediaType: "video",
|
|
21119
|
-
bytes: mp4,
|
|
21320
|
+
bytes: copyBytes(mp4),
|
|
21120
21321
|
mime: "video/mp4",
|
|
21121
21322
|
name: "event.mp4"
|
|
21122
21323
|
});
|
|
21123
21324
|
return out;
|
|
21124
21325
|
}
|
|
21125
21326
|
/**
|
|
21126
|
-
* Settle every recorded-clip follow-up this dispatcher has in flight.
|
|
21127
|
-
*
|
|
21128
|
-
* A TEST SEAM, and the honest kind: the follow-up is detached by design, so
|
|
21129
|
-
* without this a test can only assert the absence of something that has not
|
|
21130
|
-
* happened yet. Production never calls it — the outbox's `deliver` is done
|
|
21131
|
-
* when the alert is out, which is the whole point.
|
|
21132
|
-
*/
|
|
21133
|
-
async settleRecordedFootage() {
|
|
21134
|
-
while (this.recordedFootage.size > 0) await Promise.all([...this.recordedFootage]);
|
|
21135
|
-
}
|
|
21136
|
-
/**
|
|
21137
21327
|
* Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
|
|
21138
21328
|
*
|
|
21139
21329
|
* Runs for EVERY rule now, footage or not, and concurrently with the render:
|
|
@@ -35574,6 +35764,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
35574
35764
|
async drainTick() {
|
|
35575
35765
|
await this.snoozes.load();
|
|
35576
35766
|
this.sweepGroups(this.now());
|
|
35767
|
+
this.dispatcher.sweepRecordedCuts(this.now());
|
|
35577
35768
|
await this.outbox.drainOnce();
|
|
35578
35769
|
await this.deliverDueDigests();
|
|
35579
35770
|
this.drainTicks += 1;
|
|
@@ -39666,6 +39857,13 @@ var TapeRecordSchema = discriminatedUnion("kind", [
|
|
|
39666
39857
|
h: number(),
|
|
39667
39858
|
rootModelId: string().optional(),
|
|
39668
39859
|
shed: literal(true).optional(),
|
|
39860
|
+
/**
|
|
39861
|
+
* The `package-detection` step RAN on this frame (`FrameResult.
|
|
39862
|
+
* packageLooked`, D687) — what makes an empty doormat on a tape an absence
|
|
39863
|
+
* rather than silence. Absent when it did not run. Added under v1: optional,
|
|
39864
|
+
* and a tape written before it simply never attests a look.
|
|
39865
|
+
*/
|
|
39866
|
+
pl: literal(true).optional(),
|
|
39669
39867
|
dets: array(TapeDetectionSchema),
|
|
39670
39868
|
disc: array(TapeDiscardSchema),
|
|
39671
39869
|
inj: array(string()),
|
|
@@ -52584,6 +52782,7 @@ function buildFrameRecord(input) {
|
|
|
52584
52782
|
h: input.frame.height,
|
|
52585
52783
|
...input.frame.rootModelId !== void 0 ? { rootModelId: input.frame.rootModelId } : {},
|
|
52586
52784
|
...input.frame.shed === true ? { shed: true } : {},
|
|
52785
|
+
...input.frame.packageLooked === true ? { pl: true } : {},
|
|
52587
52786
|
dets,
|
|
52588
52787
|
disc: tapeDiscards(input.frame.discarded),
|
|
52589
52788
|
inj,
|
|
@@ -64264,15 +64463,6 @@ var EventMediaService = class {
|
|
|
64264
64463
|
/** How often the render is polled while it runs. */
|
|
64265
64464
|
var NC_RECORDED_CLIP_POLL_MS = 1e3;
|
|
64266
64465
|
/**
|
|
64267
|
-
* Hard bound on one notification clip render.
|
|
64268
|
-
*
|
|
64269
|
-
* Far below the timelapse renderer's 30 min, and deliberately: this is a
|
|
64270
|
-
* ten-second window, and a render that has not finished in two minutes is
|
|
64271
|
-
* queued behind something else entirely (renders are serialised one ffmpeg at
|
|
64272
|
-
* a time). Waiting longer would hold a follow-up nobody is still waiting for.
|
|
64273
|
-
*/
|
|
64274
|
-
var NC_RECORDED_CLIP_TIMEOUT_MS = 12e4;
|
|
64275
|
-
/**
|
|
64276
64466
|
* How long the recorder keeps the file we are about to read and delete.
|
|
64277
64467
|
*
|
|
64278
64468
|
* `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
|
|
@@ -64337,6 +64527,20 @@ async function cutRecordedClip(ports, req) {
|
|
|
64337
64527
|
reason: "not-recorded"
|
|
64338
64528
|
};
|
|
64339
64529
|
}
|
|
64530
|
+
if (ports.now() >= req.deadlineMs) {
|
|
64531
|
+
ports.logger.info("recorded clip: the bound was spent before the render", {
|
|
64532
|
+
tags,
|
|
64533
|
+
meta: {
|
|
64534
|
+
...common,
|
|
64535
|
+
reason: "timed-out",
|
|
64536
|
+
deadlineMs: req.deadlineMs
|
|
64537
|
+
}
|
|
64538
|
+
});
|
|
64539
|
+
return {
|
|
64540
|
+
ok: false,
|
|
64541
|
+
reason: "timed-out"
|
|
64542
|
+
};
|
|
64543
|
+
}
|
|
64340
64544
|
const pick = pickProfile(availability.ranges, {
|
|
64341
64545
|
startMs: window.fromMs,
|
|
64342
64546
|
endMs: window.toMs
|
|
@@ -64387,27 +64591,25 @@ async function cutRecordedClip(ports, req) {
|
|
|
64387
64591
|
};
|
|
64388
64592
|
}
|
|
64389
64593
|
const pollMs = ports.pollMs ?? NC_RECORDED_CLIP_POLL_MS;
|
|
64390
|
-
const timeoutMs = ports.timeoutMs ?? NC_RECORDED_CLIP_TIMEOUT_MS;
|
|
64391
|
-
const deadline = ports.now() + timeoutMs;
|
|
64392
64594
|
let state = record.state;
|
|
64393
64595
|
let failure = record.error;
|
|
64394
64596
|
while (state === "queued" || state === "rendering") {
|
|
64395
|
-
if (ports.now() >=
|
|
64597
|
+
if (ports.now() >= req.deadlineMs) {
|
|
64396
64598
|
ports.logger.warn("recorded clip: the render did not finish inside its bound", {
|
|
64397
64599
|
tags,
|
|
64398
64600
|
meta: {
|
|
64399
64601
|
...common,
|
|
64400
|
-
|
|
64602
|
+
reason: "timed-out",
|
|
64401
64603
|
exportId: record.id,
|
|
64402
64604
|
profile: pick.profile,
|
|
64403
|
-
|
|
64605
|
+
deadlineMs: req.deadlineMs,
|
|
64404
64606
|
state
|
|
64405
64607
|
}
|
|
64406
64608
|
});
|
|
64407
|
-
await
|
|
64609
|
+
await abandon(ports, record.id);
|
|
64408
64610
|
return {
|
|
64409
64611
|
ok: false,
|
|
64410
|
-
reason: "
|
|
64612
|
+
reason: "timed-out"
|
|
64411
64613
|
};
|
|
64412
64614
|
}
|
|
64413
64615
|
await ports.sleep(pollMs);
|
|
@@ -64503,6 +64705,19 @@ async function cutRecordedClip(ports, req) {
|
|
|
64503
64705
|
}
|
|
64504
64706
|
/** Best-effort removal of the temporary file. Its `maxLifeMs` is the backstop,
|
|
64505
64707
|
* so a failure here costs disk for fifteen minutes and is worth one line. */
|
|
64708
|
+
/** A render nobody waits for any more: cancel it, then delete its row and file. */
|
|
64709
|
+
async function abandon(ports, exportId) {
|
|
64710
|
+
const cancel = ports.cancelExport;
|
|
64711
|
+
if (cancel !== void 0) try {
|
|
64712
|
+
await cancel({ exportId });
|
|
64713
|
+
} catch (err) {
|
|
64714
|
+
ports.logger.debug("recorded clip: could not cancel the abandoned export", { meta: {
|
|
64715
|
+
exportId,
|
|
64716
|
+
error: String(err)
|
|
64717
|
+
} });
|
|
64718
|
+
}
|
|
64719
|
+
await discard(ports, exportId);
|
|
64720
|
+
}
|
|
64506
64721
|
async function discard(ports, exportId) {
|
|
64507
64722
|
const del = ports.deleteExport;
|
|
64508
64723
|
if (del === void 0) return;
|
|
@@ -64538,8 +64753,8 @@ function decodeBase64(base64) {
|
|
|
64538
64753
|
* (`PACKAGE_STILLNESS_TTL_MS`). Once its footage is anchored on the pick-up
|
|
64539
64754
|
* itself rather than on the conclusion (`media-anchor.ts`), the window is far
|
|
64540
64755
|
* older than any clip ring (≤ 60 s, `NC_CLIP_RETENTION_MAX_SEC`), so the ring
|
|
64541
|
-
* refuses it. A gif-only rule then had NOTHING: the recorded
|
|
64542
|
-
*
|
|
64756
|
+
* refuses it. A gif-only rule then had NOTHING: the recorded cut served only a
|
|
64757
|
+
* rule that asked for a video. The honest fix for "the gif shows the
|
|
64543
64758
|
* empty doormat" is not "no gif" — it is the gif of the pick-up, from the
|
|
64544
64759
|
* recordings.
|
|
64545
64760
|
*
|
|
@@ -64610,7 +64825,7 @@ async function deriveRecordedGif(ports, req) {
|
|
|
64610
64825
|
}
|
|
64611
64826
|
const out = ran.ok ? ran.stdout : null;
|
|
64612
64827
|
if (out === null || out.byteLength === 0) {
|
|
64613
|
-
ports.logger.warn("recorded gif: the derive failed — the
|
|
64828
|
+
ports.logger.warn("recorded gif: the derive failed — the notification carries no gif", {
|
|
64614
64829
|
tags,
|
|
64615
64830
|
meta: {
|
|
64616
64831
|
...common,
|
|
@@ -73848,6 +74063,15 @@ var PackageDropSettingsSchema = object({
|
|
|
73848
74063
|
/** Emit `package-picked-up` when a delivered package's entry departs. */
|
|
73849
74064
|
packageDropPickupEnabled: boolean().default(true),
|
|
73850
74065
|
/**
|
|
74066
|
+
* Conclude a pick-up EARLY on positive evidence (D687): three looks of the
|
|
74067
|
+
* package step spanning ≥ 10 s that found the doormat empty and unoccluded,
|
|
74068
|
+
* with somebody in the drop-off zone around the disappearance — instead of
|
|
74069
|
+
* waiting out the 300 s TTL. OFF by default: the one pick-up the stored data
|
|
74070
|
+
* could replay (615, 2026-09-30) is no sample to judge the false-departure
|
|
74071
|
+
* risk by. Silence always keeps the TTL.
|
|
74072
|
+
*/
|
|
74073
|
+
packageDropEarlyPickupEnabled: boolean().default(false),
|
|
74074
|
+
/**
|
|
73851
74075
|
* A parcel needs a deliverer: a stillness candidate only ever promotes to
|
|
73852
74076
|
* `delivered` if a non-package track (person/vehicle/animal) was active IN
|
|
73853
74077
|
* A CONFIGURED PACKAGE DROP-OFF ZONE within this many seconds BEFORE the
|
|
@@ -73886,6 +74110,7 @@ function resolvePackageDropSettings(raw) {
|
|
|
73886
74110
|
packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
|
|
73887
74111
|
packageDropMaxBboxAreaFrac: pick("packageDropMaxBboxAreaFrac"),
|
|
73888
74112
|
packageDropPickupEnabled: pick("packageDropPickupEnabled"),
|
|
74113
|
+
packageDropEarlyPickupEnabled: pick("packageDropEarlyPickupEnabled"),
|
|
73889
74114
|
packageDropActivityWindowSec: pick("packageDropActivityWindowSec"),
|
|
73890
74115
|
packageDropClassFilter: pick("packageDropClassFilter")
|
|
73891
74116
|
};
|
|
@@ -76219,11 +76444,129 @@ function trackIsStill(input) {
|
|
|
76219
76444
|
spanFrac: metrics.pathSpanFrac
|
|
76220
76445
|
};
|
|
76221
76446
|
}
|
|
76447
|
+
//#endregion
|
|
76448
|
+
//#region src/pipeline-analytics/pipeline/package/package-stillness-tracker.ts
|
|
76449
|
+
/**
|
|
76450
|
+
* PackageStillnessTracker — the DEDICATED promotion path for packages.
|
|
76451
|
+
*
|
|
76452
|
+
* ## Why this exists (the arithmetic)
|
|
76453
|
+
*
|
|
76454
|
+
* The generic `StationaryObjectRegistry` used to promote a track that recorded
|
|
76455
|
+
* 4 positions inside a 30 s window — a criterion built for a car parking in
|
|
76456
|
+
* device 617's lot and observed on EVERY frame. (It has since moved to repeated
|
|
76457
|
+
* evidence across many sightings, `stationary-evidence.ts`; the impossibility
|
|
76458
|
+
* below is what forced this path to exist, and the class SKIP in the generic
|
|
76459
|
+
* loop is what keeps it separate now.)
|
|
76460
|
+
*
|
|
76461
|
+
* `package-detection` is not observed on every frame. It is a root step gated
|
|
76462
|
+
* by the `MotionSettleGovernor`, which runs it on a motion-settle edge or, in
|
|
76463
|
+
* the worst case, on a 45 s safety poll. Four positions in 30 s at one
|
|
76464
|
+
* position per 45 s is not a tuning problem, it is an impossibility — and it is
|
|
76465
|
+
* why `PackageDropDetector` never fired once in its life (0 `pa-pkg-*` rows on
|
|
76466
|
+
* every camera, ever, verified 2026-08-13).
|
|
76467
|
+
*
|
|
76468
|
+
* Measured on device 615 (the only camera that has ever produced a `package`
|
|
76469
|
+
* box): three separate tracks at 02:01:08, 02:03:14 and 02:05:29, each alive
|
|
76470
|
+
* for 4–5 s, each carrying TWO frames ~1.2 s apart, each on a NEW trackId. The
|
|
76471
|
+
* facts to accommodate are therefore:
|
|
76472
|
+
*
|
|
76473
|
+
* - the track DIES between governor rounds → re-association is by POSITION
|
|
76474
|
+
* (IoU), never by trackId;
|
|
76475
|
+
* - a round can be minutes apart → there is NO look-back window, only a count
|
|
76476
|
+
* of distinct FRAMES plus the operator's optional dwell;
|
|
76477
|
+
* - a burst of frames inside one settle session COUNTS: three frames ~400 ms
|
|
76478
|
+
* apart is three hits, which is enough to confirm the box is still there
|
|
76479
|
+
* without waiting for a second governor round after the courier left.
|
|
76480
|
+
* Device 615 2026-08-24 13:40: a real parcel expired at rounds=1 /
|
|
76481
|
+
* dwell 1458 ms vs 15 s — the 5 s round-gap plus 15 s dwell were the miss.
|
|
76482
|
+
*
|
|
76483
|
+
* The generic registry is untouched: a parked car still promotes exactly as
|
|
76484
|
+
* before. `scripts/check-package-promotion-satisfiable.ts` fails the build if
|
|
76485
|
+
* anyone breaks the relation between these constants and the governor's poll.
|
|
76486
|
+
*
|
|
76487
|
+
* ## Contract
|
|
76488
|
+
*
|
|
76489
|
+
* Pure logic with an injected clock and id factory — `observe` takes the frame
|
|
76490
|
+
* timestamp and never reads `Date.now()`. It emits {@link StationaryEntry}
|
|
76491
|
+
* values in the exact shape `PackageDropDetector.onStationaryChange` already
|
|
76492
|
+
* consumes, so the detector is unchanged by this path: `promoted` feeds
|
|
76493
|
+
* `appeared`, `departed` feeds `departed`, and the entry id is stable across
|
|
76494
|
+
* both so a pick-up pairs to its delivery.
|
|
76495
|
+
*
|
|
76496
|
+
* Expiry is judged on the frame clock inside `observe`, which is called on
|
|
76497
|
+
* every pipeline frame (an empty `sightings` list when the package step did
|
|
76498
|
+
* not run or found nothing). A camera producing no frames concludes nothing
|
|
76499
|
+
* there — and that is load-bearing, not an accident: the FIRST frame after a
|
|
76500
|
+
* silence runs the governor's poll (it is always due after a TTL of silence)
|
|
76501
|
+
* and re-sights a parcel that is still there before the expiry is judged.
|
|
76502
|
+
*
|
|
76503
|
+
* `sweep` judges the same TTL off a timer, so a pick-up is not held until the
|
|
76504
|
+
* next frame happens to arrive (615, 2026-09-30: 29 s). It concludes ONLY a
|
|
76505
|
+
* promoted parcel with an ABSENCE READ since its last sighting — a frame on
|
|
76506
|
+
* which the package step LOOKED (`looked`) and found no box at that place,
|
|
76507
|
+
* with no person or vehicle box over it (`occluders`). A parcel nobody looked
|
|
76508
|
+
* for since is left for the next frame's look. Silence is not evidence.
|
|
76509
|
+
*/
|
|
76510
|
+
/**
|
|
76511
|
+
* Distinct FRAMES a box must be re-seen on before it is a parked package.
|
|
76512
|
+
* Three is the same bar as tracker `minHits`: one frame is a flicker, two
|
|
76513
|
+
* can still be a passer-by's bag in a single glance, three is "it stayed".
|
|
76514
|
+
* Counted per sighting, not per 5 s governor round — a settle burst of
|
|
76515
|
+
* three frames in ~1 s is a confirmation, not one collapsed round.
|
|
76516
|
+
*/
|
|
76517
|
+
var PACKAGE_STILLNESS_MIN_HITS = 3;
|
|
76518
|
+
/**
|
|
76519
|
+
* Minimum spacing between two observations for them to count as separate
|
|
76520
|
+
* pick-up-slack windows. Must be ≤ the governor's poll interval. No longer
|
|
76521
|
+
* used for promotion (hits count every frame).
|
|
76522
|
+
*/
|
|
76523
|
+
var PACKAGE_STILLNESS_ROUND_GAP_MS = 5e3;
|
|
76524
|
+
/**
|
|
76525
|
+
* How long a candidate survives without being re-associated, in OBSERVED time.
|
|
76526
|
+
* Must comfortably outlast several governor polls — the real inter-round gap
|
|
76527
|
+
* measured on 615 was ~2 minutes, not the nominal 45 s, because a
|
|
76528
|
+
* session-dispatch camera decodes nothing between motion sessions.
|
|
76529
|
+
*/
|
|
76530
|
+
var PACKAGE_STILLNESS_TTL_MS = 5 * 6e4;
|
|
76531
|
+
/**
|
|
76532
|
+
* Association floor. Deliberately looser than the registry's `SUPPRESS_IOU`
|
|
76533
|
+
* (0.6): a box re-detected minutes later by a fresh track jitters more than one
|
|
76534
|
+
* confirmed frame-to-frame. Measured jitter on 615's doormat across 4 minutes
|
|
76535
|
+
* was IoU ≈ 0.98, so 0.3 is generous without being meaningless.
|
|
76536
|
+
*/
|
|
76537
|
+
var PACKAGE_STILLNESS_ASSOC_IOU = .3;
|
|
76538
|
+
/**
|
|
76539
|
+
* Two absence reads closer than this are ONE look, not two. A settle burst
|
|
76540
|
+
* runs the package step 5 s apart and a poll 45 s apart, so a real second
|
|
76541
|
+
* look is always further away; two frames of one moment (a re-dispatch, a
|
|
76542
|
+
* recovery pass) must not count as agreement.
|
|
76543
|
+
*/
|
|
76544
|
+
var PACKAGE_ABSENCE_READ_MIN_GAP_MS = 2e3;
|
|
76545
|
+
/**
|
|
76546
|
+
* Early pick-up (D687, per-camera opt-in): absence reads a promoted parcel
|
|
76547
|
+
* needs before it may depart ahead of its TTL. Three is the same bar as
|
|
76548
|
+
* promotion — one empty look is a missed detection, two can be one wobble of
|
|
76549
|
+
* the detector, three agreeing is "it is gone" (D49: a read that destroys
|
|
76550
|
+
* work needs another to agree).
|
|
76551
|
+
*/
|
|
76552
|
+
var PACKAGE_EARLY_PICKUP_MIN_READS = 3;
|
|
76553
|
+
/**
|
|
76554
|
+
* …and the span those reads must cover, first to last. A settle burst looks
|
|
76555
|
+
* every 5 s, so 10 s is three burst runs: the parcel is still gone after the
|
|
76556
|
+
* collector has had time to put it back down.
|
|
76557
|
+
*/
|
|
76558
|
+
var PACKAGE_EARLY_PICKUP_MIN_SPAN_MS = 1e4;
|
|
76559
|
+
/**
|
|
76560
|
+
* Absence reads the frame-free SWEEP needs before it may conclude a TTL
|
|
76561
|
+
* (fix round 1, D49): one read can be stale by minutes, or a detector
|
|
76562
|
+
* wobble; a second must agree, and the most recent look must be one of them.
|
|
76563
|
+
*/
|
|
76564
|
+
var PACKAGE_SWEEP_MIN_READS = 2;
|
|
76222
76565
|
var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
|
|
76223
|
-
minHits:
|
|
76224
|
-
roundGapMs:
|
|
76225
|
-
ttlMs:
|
|
76226
|
-
assocIou:
|
|
76566
|
+
minHits: PACKAGE_STILLNESS_MIN_HITS,
|
|
76567
|
+
roundGapMs: PACKAGE_STILLNESS_ROUND_GAP_MS,
|
|
76568
|
+
ttlMs: PACKAGE_STILLNESS_TTL_MS,
|
|
76569
|
+
assocIou: PACKAGE_STILLNESS_ASSOC_IOU
|
|
76227
76570
|
};
|
|
76228
76571
|
var PackageStillnessTracker = class {
|
|
76229
76572
|
config;
|
|
@@ -76266,37 +76609,117 @@ var PackageStillnessTracker = class {
|
|
|
76266
76609
|
next[index] = parked;
|
|
76267
76610
|
promoted.push(toEntry(parked));
|
|
76268
76611
|
}
|
|
76612
|
+
const present = input.rawPackageBoxes !== void 0 ? withRawPresence(next, consumed, input.rawPackageBoxes, input.timestamp, this.config.assocIou) : next;
|
|
76613
|
+
const looked = input.looked === true && input.rawPackageBoxes !== void 0 ? withLook(present, consumed, input, input.rawPackageBoxes, this.config.assocIou) : present;
|
|
76614
|
+
const windowMs = input.activityWindowMs ?? 0;
|
|
76615
|
+
const early = /* @__PURE__ */ new Set();
|
|
76616
|
+
const unbacked = [];
|
|
76617
|
+
const read = looked.map((c) => {
|
|
76618
|
+
if (input.earlyPickup !== true || c.earlyUnbackedReported || !hasEarlyEvidence(c)) return c;
|
|
76619
|
+
const reason = this.earlyUnbackedReason(c, windowMs, input.lastActivityAt);
|
|
76620
|
+
if (reason === null) return c;
|
|
76621
|
+
unbacked.push({
|
|
76622
|
+
id: c.id,
|
|
76623
|
+
reason
|
|
76624
|
+
});
|
|
76625
|
+
return {
|
|
76626
|
+
...c,
|
|
76627
|
+
earlyUnbackedReported: true
|
|
76628
|
+
};
|
|
76629
|
+
});
|
|
76630
|
+
return {
|
|
76631
|
+
promoted,
|
|
76632
|
+
...this.expire({
|
|
76633
|
+
deviceId: input.deviceId,
|
|
76634
|
+
candidates: read,
|
|
76635
|
+
isExpired: (c) => {
|
|
76636
|
+
if (input.timestamp - c.lastSeenAt > this.config.ttlMs) return true;
|
|
76637
|
+
if (input.earlyPickup !== true || !hasEarlyEvidence(c)) return false;
|
|
76638
|
+
if (this.earlyUnbackedReason(c, windowMs, input.lastActivityAt) !== null) return false;
|
|
76639
|
+
early.add(c.id);
|
|
76640
|
+
return true;
|
|
76641
|
+
},
|
|
76642
|
+
activityWindowMs: windowMs,
|
|
76643
|
+
...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
|
|
76644
|
+
}),
|
|
76645
|
+
earlyDepartureIds: [...early],
|
|
76646
|
+
earlyEvidenceUnbacked: unbacked
|
|
76647
|
+
};
|
|
76648
|
+
}
|
|
76649
|
+
/** Why complete early evidence may NOT conclude, or null when it may. */
|
|
76650
|
+
earlyUnbackedReason(c, activityWindowMs, lastActivityAt) {
|
|
76651
|
+
if (activityWindowMs <= 0) return "no-activity-window";
|
|
76652
|
+
if (lastActivityAt === void 0 || lastActivityAt < c.lastSeenAt - this.config.roundGapMs) return "nobody-around";
|
|
76653
|
+
return null;
|
|
76654
|
+
}
|
|
76655
|
+
/**
|
|
76656
|
+
* Judge the TTL without a frame. Concludes only a PROMOTED parcel whose TTL
|
|
76657
|
+
* ran out AND that carries at least one absence read since its last
|
|
76658
|
+
* sighting; every other candidate is left for the next frame, whose look
|
|
76659
|
+
* decides it. Never promotes. Immutable, never throws.
|
|
76660
|
+
*/
|
|
76661
|
+
sweep(input) {
|
|
76662
|
+
const candidates = this.byDevice.get(input.deviceId) ?? [];
|
|
76663
|
+
return {
|
|
76664
|
+
promoted: [],
|
|
76665
|
+
...this.expire({
|
|
76666
|
+
deviceId: input.deviceId,
|
|
76667
|
+
candidates,
|
|
76668
|
+
isExpired: (c) => c.promotedAt !== null && c.absenceReads.length >= PACKAGE_SWEEP_MIN_READS && c.lastLook === "absent" && input.timestamp - c.lastSeenAt > this.config.ttlMs,
|
|
76669
|
+
activityWindowMs: input.activityWindowMs ?? 0,
|
|
76670
|
+
...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
|
|
76671
|
+
}),
|
|
76672
|
+
earlyDepartureIds: [],
|
|
76673
|
+
earlyEvidenceUnbacked: []
|
|
76674
|
+
};
|
|
76675
|
+
}
|
|
76676
|
+
/** The devices holding a candidate — what a sweep has to visit. */
|
|
76677
|
+
devicesWithCandidates() {
|
|
76678
|
+
return [...this.byDevice.keys()];
|
|
76679
|
+
}
|
|
76680
|
+
/**
|
|
76681
|
+
* Expiry. A promoted candidate that ages out is a departure (it feeds the
|
|
76682
|
+
* pick-up pairing) — but only a departure with somebody around the
|
|
76683
|
+
* disappearance is a PICK-UP; an unwitnessed vanish is the detector losing
|
|
76684
|
+
* the box. An unpromoted candidate that ages out is reported with its
|
|
76685
|
+
* counters: it expired in silence once (2026-08-18 13:41, a true parcel)
|
|
76686
|
+
* and that silence cost the diagnosis a day.
|
|
76687
|
+
*/
|
|
76688
|
+
expire(input) {
|
|
76269
76689
|
const departed = [];
|
|
76270
76690
|
const departedWithoutActivity = [];
|
|
76271
76691
|
const expired = [];
|
|
76272
76692
|
const kept = [];
|
|
76273
|
-
const
|
|
76274
|
-
|
|
76275
|
-
|
|
76276
|
-
if (c.promotedAt !== null) if (windowMs <= 0 || input.lastActivityAt !== void 0 && input.lastActivityAt >= c.lastSeenAt - this.config.roundGapMs) departed.push(toEntry(c));
|
|
76277
|
-
else departedWithoutActivity.push(toEntry(c));
|
|
76278
|
-
else expired.push({
|
|
76279
|
-
id: c.id,
|
|
76280
|
-
className: c.className,
|
|
76281
|
-
bbox: { ...c.bbox },
|
|
76282
|
-
hits: c.hits,
|
|
76283
|
-
firstSeenAt: c.firstSeenAt,
|
|
76284
|
-
lastSeenAt: c.lastSeenAt,
|
|
76285
|
-
activityBackedAtOpen: c.activityBackedAtOpen
|
|
76286
|
-
});
|
|
76693
|
+
for (const c of input.candidates) {
|
|
76694
|
+
if (!input.isExpired(c)) {
|
|
76695
|
+
kept.push(c);
|
|
76287
76696
|
continue;
|
|
76288
76697
|
}
|
|
76289
|
-
|
|
76698
|
+
if (c.promotedAt === null) {
|
|
76699
|
+
expired.push(expiredReport(c));
|
|
76700
|
+
continue;
|
|
76701
|
+
}
|
|
76702
|
+
if (this.pickupBacked(c, input.activityWindowMs, input.lastActivityAt)) departed.push(toEntry(c));
|
|
76703
|
+
else departedWithoutActivity.push(toEntry(c));
|
|
76290
76704
|
}
|
|
76291
76705
|
if (kept.length === 0) this.byDevice.delete(input.deviceId);
|
|
76292
76706
|
else this.byDevice.set(input.deviceId, kept);
|
|
76293
76707
|
return {
|
|
76294
|
-
promoted,
|
|
76295
76708
|
departed,
|
|
76296
76709
|
departedWithoutActivity,
|
|
76297
76710
|
expired
|
|
76298
76711
|
};
|
|
76299
76712
|
}
|
|
76713
|
+
/**
|
|
76714
|
+
* A pick-up needs somebody around the DISAPPEARANCE: activity at or after
|
|
76715
|
+
* the box's last sighting (one round-gap of slack — the taker is tracked in
|
|
76716
|
+
* the same frames that stop showing the box). The DELIVERER's activity
|
|
76717
|
+
* minutes earlier must not count, or every short-dwell vanish would read as
|
|
76718
|
+
* a pick-up. A window of 0 disables the gate.
|
|
76719
|
+
*/
|
|
76720
|
+
pickupBacked(c, activityWindowMs, lastActivityAt) {
|
|
76721
|
+
return activityWindowMs <= 0 || lastActivityAt !== void 0 && lastActivityAt >= c.lastSeenAt - this.config.roundGapMs;
|
|
76722
|
+
}
|
|
76300
76723
|
/** Drop a device's candidates (unbind / step disabled). */
|
|
76301
76724
|
forgetDevice(deviceId) {
|
|
76302
76725
|
this.byDevice.delete(deviceId);
|
|
@@ -76339,6 +76762,9 @@ var PackageStillnessTracker = class {
|
|
|
76339
76762
|
lastSeenAt: input.timestamp,
|
|
76340
76763
|
hits: 1,
|
|
76341
76764
|
promotedAt: null,
|
|
76765
|
+
absenceReads: [],
|
|
76766
|
+
lastLook: null,
|
|
76767
|
+
earlyUnbackedReported: false,
|
|
76342
76768
|
activityBackedAtOpen,
|
|
76343
76769
|
...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
|
|
76344
76770
|
...s.label !== void 0 ? { label: s.label } : {},
|
|
@@ -76357,12 +76783,91 @@ function confirm(prev, input, s) {
|
|
|
76357
76783
|
frameHeight: input.frameHeight,
|
|
76358
76784
|
lastSeenAt: input.timestamp,
|
|
76359
76785
|
hits: prev.hits + 1,
|
|
76786
|
+
absenceReads: [],
|
|
76787
|
+
lastLook: "present",
|
|
76788
|
+
earlyUnbackedReported: false,
|
|
76360
76789
|
...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
|
|
76361
76790
|
...s.label !== void 0 ? { label: s.label } : {},
|
|
76362
76791
|
...s.confidence !== void 0 ? { confidence: s.confidence } : {},
|
|
76363
76792
|
...s.keyFrameMediaId !== void 0 ? { keyFrameMediaId: s.keyFrameMediaId } : {}
|
|
76364
76793
|
};
|
|
76365
76794
|
}
|
|
76795
|
+
/**
|
|
76796
|
+
* A promoted parcel the tracker did not re-sight but the package step's RAW
|
|
76797
|
+
* output shows at its place (IoU ≥ the association floor): the detector saw
|
|
76798
|
+
* it, whatever the tracker did with the box. It is SEEN — `lastSeenAt` moves,
|
|
76799
|
+
* so the TTL ages from here and the pick-up instant is this last presence —
|
|
76800
|
+
* and every absence read so far is contradicted.
|
|
76801
|
+
*/
|
|
76802
|
+
function withRawPresence(candidates, sighted, raw, timestamp, assocIou) {
|
|
76803
|
+
return candidates.map((c) => {
|
|
76804
|
+
if (c.promotedAt === null || sighted.has(c.id)) return c;
|
|
76805
|
+
if (!raw.some((b) => iou(b, c.bbox) >= assocIou)) return c;
|
|
76806
|
+
return {
|
|
76807
|
+
...c,
|
|
76808
|
+
lastSeenAt: timestamp,
|
|
76809
|
+
absenceReads: [],
|
|
76810
|
+
lastLook: "present"
|
|
76811
|
+
};
|
|
76812
|
+
});
|
|
76813
|
+
}
|
|
76814
|
+
/**
|
|
76815
|
+
* Fold this frame's LOOK into every promoted candidate it did not re-sight.
|
|
76816
|
+
*
|
|
76817
|
+
* Judged against what the package step RETURNED (`raw`), never against the
|
|
76818
|
+
* tracker's sightings: a raw box at the place is a PRESENCE even when the
|
|
76819
|
+
* tracker dropped it, and it contradicts every absence read so far (D49). A
|
|
76820
|
+
* look whose view of the place was covered by a person or vehicle saw the
|
|
76821
|
+
* occluder, and says nothing — but it is the latest look, so the sweep waits
|
|
76822
|
+
* for a later one.
|
|
76823
|
+
*/
|
|
76824
|
+
function withLook(candidates, sighted, input, raw, assocIou) {
|
|
76825
|
+
const occluders = input.occluders ?? [];
|
|
76826
|
+
return candidates.map((c) => {
|
|
76827
|
+
if (c.promotedAt === null || sighted.has(c.id)) return c;
|
|
76828
|
+
if (raw.some((b) => iou(b, c.bbox) >= assocIou)) return {
|
|
76829
|
+
...c,
|
|
76830
|
+
absenceReads: [],
|
|
76831
|
+
lastLook: "present"
|
|
76832
|
+
};
|
|
76833
|
+
if (occluders.some((o) => boxesOverlap(o, c.bbox))) return {
|
|
76834
|
+
...c,
|
|
76835
|
+
lastLook: "occluded"
|
|
76836
|
+
};
|
|
76837
|
+
const last = c.absenceReads[c.absenceReads.length - 1];
|
|
76838
|
+
if (last !== void 0 && input.timestamp - last < PACKAGE_ABSENCE_READ_MIN_GAP_MS) return {
|
|
76839
|
+
...c,
|
|
76840
|
+
lastLook: "absent"
|
|
76841
|
+
};
|
|
76842
|
+
return {
|
|
76843
|
+
...c,
|
|
76844
|
+
absenceReads: [...c.absenceReads, input.timestamp],
|
|
76845
|
+
lastLook: "absent"
|
|
76846
|
+
};
|
|
76847
|
+
});
|
|
76848
|
+
}
|
|
76849
|
+
/** Enough agreeing absence reads, over enough time, to call a parcel gone
|
|
76850
|
+
* ahead of its TTL (D687). */
|
|
76851
|
+
function hasEarlyEvidence(c) {
|
|
76852
|
+
if (c.promotedAt === null) return false;
|
|
76853
|
+
const first = c.absenceReads[0];
|
|
76854
|
+
const last = c.absenceReads[c.absenceReads.length - 1];
|
|
76855
|
+
return c.absenceReads.length >= PACKAGE_EARLY_PICKUP_MIN_READS && first !== void 0 && last !== void 0 && last - first >= PACKAGE_EARLY_PICKUP_MIN_SPAN_MS;
|
|
76856
|
+
}
|
|
76857
|
+
function boxesOverlap(a, b) {
|
|
76858
|
+
return Math.min(a.x + a.w, b.x + b.w) > Math.max(a.x, b.x) && Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y);
|
|
76859
|
+
}
|
|
76860
|
+
function expiredReport(c) {
|
|
76861
|
+
return {
|
|
76862
|
+
id: c.id,
|
|
76863
|
+
className: c.className,
|
|
76864
|
+
bbox: { ...c.bbox },
|
|
76865
|
+
hits: c.hits,
|
|
76866
|
+
firstSeenAt: c.firstSeenAt,
|
|
76867
|
+
lastSeenAt: c.lastSeenAt,
|
|
76868
|
+
activityBackedAtOpen: c.activityBackedAtOpen
|
|
76869
|
+
};
|
|
76870
|
+
}
|
|
76366
76871
|
function toEntry(c) {
|
|
76367
76872
|
return {
|
|
76368
76873
|
id: c.id,
|
|
@@ -76381,6 +76886,60 @@ function toEntry(c) {
|
|
|
76381
76886
|
};
|
|
76382
76887
|
}
|
|
76383
76888
|
//#endregion
|
|
76889
|
+
//#region src/pipeline-analytics/pipeline/package/package-look-evidence.ts
|
|
76890
|
+
function packageLookEvidence(input) {
|
|
76891
|
+
const raw = [];
|
|
76892
|
+
for (const d of input.frame.detections) {
|
|
76893
|
+
if (d.kind !== "first-level" || !input.packageClasses.has(d.macroClass)) continue;
|
|
76894
|
+
raw.push({
|
|
76895
|
+
x: d.bbox.x,
|
|
76896
|
+
y: d.bbox.y,
|
|
76897
|
+
w: d.bbox.width,
|
|
76898
|
+
h: d.bbox.height
|
|
76899
|
+
});
|
|
76900
|
+
}
|
|
76901
|
+
for (const d of input.frame.discarded ?? []) {
|
|
76902
|
+
if (!input.packageClasses.has(d.macroClass)) continue;
|
|
76903
|
+
const [x1, y1, x2, y2] = d.bbox;
|
|
76904
|
+
raw.push({
|
|
76905
|
+
x: x1,
|
|
76906
|
+
y: y1,
|
|
76907
|
+
w: x2 - x1,
|
|
76908
|
+
h: y2 - y1
|
|
76909
|
+
});
|
|
76910
|
+
}
|
|
76911
|
+
return {
|
|
76912
|
+
looked: input.frame.packageLooked === true,
|
|
76913
|
+
occluders: input.tracked.filter((t) => !input.packageClasses.has(t.className)).map((t) => ({
|
|
76914
|
+
x: t.bbox.x,
|
|
76915
|
+
y: t.bbox.y,
|
|
76916
|
+
w: t.bbox.w,
|
|
76917
|
+
h: t.bbox.h
|
|
76918
|
+
})),
|
|
76919
|
+
rawPackageBoxes: raw,
|
|
76920
|
+
earlyPickup: input.earlyPickupEnabled
|
|
76921
|
+
};
|
|
76922
|
+
}
|
|
76923
|
+
//#endregion
|
|
76924
|
+
//#region src/pipeline-analytics/pipeline/package/package-stillness-sweep.ts
|
|
76925
|
+
function sweepPackageStillness(deps) {
|
|
76926
|
+
for (const deviceId of deps.tracker.devicesWithCandidates()) try {
|
|
76927
|
+
sweepOne(deps, deviceId);
|
|
76928
|
+
} catch (err) {
|
|
76929
|
+
deps.onDeviceError(deviceId, err);
|
|
76930
|
+
}
|
|
76931
|
+
}
|
|
76932
|
+
function sweepOne(deps, deviceId) {
|
|
76933
|
+
const lastActivityAt = deps.lastActivityAtFor(deviceId);
|
|
76934
|
+
const outcome = deps.tracker.sweep({
|
|
76935
|
+
deviceId,
|
|
76936
|
+
timestamp: deps.now,
|
|
76937
|
+
activityWindowMs: deps.activityWindowMsFor(deviceId),
|
|
76938
|
+
...lastActivityAt !== void 0 ? { lastActivityAt } : {}
|
|
76939
|
+
});
|
|
76940
|
+
if (outcome.departed.length > 0 || outcome.departedWithoutActivity.length > 0 || outcome.expired.length > 0) deps.onOutcome(deviceId, outcome, deps.now);
|
|
76941
|
+
}
|
|
76942
|
+
//#endregion
|
|
76384
76943
|
//#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
|
|
76385
76944
|
/**
|
|
76386
76945
|
* Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
|
|
@@ -90428,6 +90987,13 @@ function buildGlobalSettingsSchema(cap) {
|
|
|
90428
90987
|
description: "Also emit a package-picked-up event when the delivered package leaves.",
|
|
90429
90988
|
default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
|
|
90430
90989
|
},
|
|
90990
|
+
{
|
|
90991
|
+
type: "boolean",
|
|
90992
|
+
key: "packageDropEarlyPickupEnabled",
|
|
90993
|
+
label: "Early pick-up",
|
|
90994
|
+
description: "Report a pick-up as soon as the package detector has looked three times over at least 10 s, found the spot empty and unblocked, and somebody was in the drop-off zone — instead of after 5 minutes without a sighting. Off by default until it is measured on more pick-ups.",
|
|
90995
|
+
default: PACKAGE_DROP_DEFAULTS.packageDropEarlyPickupEnabled
|
|
90996
|
+
},
|
|
90431
90997
|
{
|
|
90432
90998
|
type: "slider",
|
|
90433
90999
|
key: "packageDropMinFrames",
|
|
@@ -96094,7 +96660,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
96094
96660
|
createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
|
|
96095
96661
|
getExport: (input) => api.recordingExport.getExport.query(input),
|
|
96096
96662
|
readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
|
|
96097
|
-
deleteExport: (input) => api.recordingExport.deleteExport.mutate(input)
|
|
96663
|
+
deleteExport: (input) => api.recordingExport.deleteExport.mutate(input),
|
|
96664
|
+
cancelExport: (input) => api.recordingExport.cancelExport.mutate(input)
|
|
96098
96665
|
}, req),
|
|
96099
96666
|
deriveGifFromClip: (req) => deriveRecordedGif(buildRecordedGifPorts({
|
|
96100
96667
|
logger: logger.child("RecordedGif"),
|
|
@@ -97101,6 +97668,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
97101
97668
|
startSweepTimers() {
|
|
97102
97669
|
this.ttlSweepTimer = setInterval(() => {
|
|
97103
97670
|
this.sweepExpiredTracks();
|
|
97671
|
+
this.runPackageStillnessSweep(Date.now());
|
|
97104
97672
|
}, TTL_SWEEP_INTERVAL_MS);
|
|
97105
97673
|
this.faceReembedAutoTimer = setInterval(() => {
|
|
97106
97674
|
this.maybeAutoReembedFaces().then(() => this.faceAlignmentJobs?.maybeAutoRunPhotoPass());
|
|
@@ -98252,7 +98820,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
98252
98820
|
settings: packageSettings,
|
|
98253
98821
|
frameWidth: result.frameWidth,
|
|
98254
98822
|
frameHeight: result.frameHeight,
|
|
98255
|
-
timestamp: result.timestamp
|
|
98823
|
+
timestamp: result.timestamp,
|
|
98824
|
+
frame
|
|
98256
98825
|
});
|
|
98257
98826
|
if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
|
|
98258
98827
|
const dispatcher = this.detailDispatcher;
|
|
@@ -101102,8 +101671,77 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
101102
101671
|
minHits: input.settings.packageDropMinFrames,
|
|
101103
101672
|
minDwellMs: input.settings.packageDropDwellSec * 1e3,
|
|
101104
101673
|
activityWindowMs: input.settings.packageDropActivityWindowSec * 1e3,
|
|
101105
|
-
...lastActivityAt !== void 0 ? { lastActivityAt } : {}
|
|
101674
|
+
...lastActivityAt !== void 0 ? { lastActivityAt } : {},
|
|
101675
|
+
...packageLookEvidence({
|
|
101676
|
+
frame: input.frame,
|
|
101677
|
+
tracked: input.tracked,
|
|
101678
|
+
packageClasses: input.packageClasses,
|
|
101679
|
+
earlyPickupEnabled: input.settings.packageDropEarlyPickupEnabled
|
|
101680
|
+
})
|
|
101681
|
+
});
|
|
101682
|
+
this.bridgePackageStillness({
|
|
101683
|
+
deviceId: input.deviceId,
|
|
101684
|
+
outcome,
|
|
101685
|
+
timestamp: input.timestamp,
|
|
101686
|
+
settings: input.settings,
|
|
101687
|
+
...lastActivityAt !== void 0 ? { lastActivityAt } : {},
|
|
101688
|
+
via: "frame"
|
|
101106
101689
|
});
|
|
101690
|
+
}
|
|
101691
|
+
/**
|
|
101692
|
+
* Judge the package TTL without a frame (F4) — on the existing TTL sweep
|
|
101693
|
+
* tick, for the devices holding a candidate. The tracker concludes only a
|
|
101694
|
+
* parcel the step looked for and did not find; see
|
|
101695
|
+
* `PackageStillnessTracker.sweep`.
|
|
101696
|
+
*/
|
|
101697
|
+
/**
|
|
101698
|
+
* The tick's entry point: a throw here is an uncaught exception in a timer
|
|
101699
|
+
* callback, so it is caught and named (review M3). Per-device throws are
|
|
101700
|
+
* already caught inside the driver, so one camera cannot stop the others.
|
|
101701
|
+
*/
|
|
101702
|
+
runPackageStillnessSweep(now) {
|
|
101703
|
+
try {
|
|
101704
|
+
this.sweepPackageStillness(now);
|
|
101705
|
+
} catch (err) {
|
|
101706
|
+
this.ctx.logger.warn("package stillness sweep threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
101707
|
+
}
|
|
101708
|
+
}
|
|
101709
|
+
sweepPackageStillness(now) {
|
|
101710
|
+
if (this.packageDropDetector === null) return;
|
|
101711
|
+
sweepPackageStillness({
|
|
101712
|
+
tracker: this.packageStillness,
|
|
101713
|
+
now,
|
|
101714
|
+
activityWindowMsFor: (deviceId) => this.packageDropSettingsFromCache(deviceId).packageDropActivityWindowSec * 1e3,
|
|
101715
|
+
lastActivityAtFor: (deviceId) => this.lastNonPackageActivityAt.get(deviceId),
|
|
101716
|
+
onOutcome: (deviceId, outcome, at) => {
|
|
101717
|
+
const lastActivityAt = this.lastNonPackageActivityAt.get(deviceId);
|
|
101718
|
+
this.bridgePackageStillness({
|
|
101719
|
+
deviceId,
|
|
101720
|
+
outcome,
|
|
101721
|
+
timestamp: at,
|
|
101722
|
+
settings: this.packageDropSettingsFromCache(deviceId),
|
|
101723
|
+
...lastActivityAt !== void 0 ? { lastActivityAt } : {},
|
|
101724
|
+
via: "sweep"
|
|
101725
|
+
});
|
|
101726
|
+
},
|
|
101727
|
+
onDeviceError: (deviceId, err) => {
|
|
101728
|
+
this.ctx.logger.warn("package stillness sweep threw", {
|
|
101729
|
+
tags: { deviceId },
|
|
101730
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
101731
|
+
});
|
|
101732
|
+
}
|
|
101733
|
+
});
|
|
101734
|
+
}
|
|
101735
|
+
/**
|
|
101736
|
+
* Bridge one stillness outcome into `PackageDropDetector` — the SAME
|
|
101737
|
+
* `onStationaryChange` contract the generic registry uses. A frame's outcome
|
|
101738
|
+
* and a sweep's take this one path, so a pick-up concluded without a frame
|
|
101739
|
+
* is logged, paired and notified exactly like one concluded on a frame.
|
|
101740
|
+
*/
|
|
101741
|
+
bridgePackageStillness(input) {
|
|
101742
|
+
if (this.packageDropDetector === null) return;
|
|
101743
|
+
const tags = { deviceId: input.deviceId };
|
|
101744
|
+
const { outcome, lastActivityAt } = input;
|
|
101107
101745
|
for (const entry of outcome.promoted) {
|
|
101108
101746
|
const centroid = {
|
|
101109
101747
|
x: entry.bbox.x + entry.bbox.w / 2,
|
|
@@ -101153,7 +101791,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
101153
101791
|
meta: {
|
|
101154
101792
|
entryId: entry.id,
|
|
101155
101793
|
className: entry.className,
|
|
101156
|
-
unseenForMs: input.timestamp - entry.lastConfirmedAt
|
|
101794
|
+
unseenForMs: input.timestamp - entry.lastConfirmedAt,
|
|
101795
|
+
via: input.via,
|
|
101796
|
+
rule: outcome.earlyDepartureIds.includes(entry.id) ? "early" : "ttl"
|
|
101157
101797
|
}
|
|
101158
101798
|
});
|
|
101159
101799
|
this.packageBackground?.noteRealPickup({
|
|
@@ -101195,7 +101835,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
101195
101835
|
className: entry.className,
|
|
101196
101836
|
bbox: entry.bbox,
|
|
101197
101837
|
unseenForMs: input.timestamp - entry.lastConfirmedAt,
|
|
101198
|
-
...lastActivityAt !== void 0 ? { lastActivityAt } : {}
|
|
101838
|
+
...lastActivityAt !== void 0 ? { lastActivityAt } : {},
|
|
101839
|
+
via: input.via
|
|
101199
101840
|
}
|
|
101200
101841
|
});
|
|
101201
101842
|
this.packageBackground?.noteGhostCycle({
|
|
@@ -101214,6 +101855,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
|
|
|
101214
101855
|
});
|
|
101215
101856
|
}).catch(() => void 0);
|
|
101216
101857
|
}
|
|
101858
|
+
for (const u of outcome.earlyEvidenceUnbacked) this.ctx.logger.info("early pick-up evidence complete — nobody around, left to the TTL", {
|
|
101859
|
+
tags,
|
|
101860
|
+
meta: {
|
|
101861
|
+
entryId: u.id,
|
|
101862
|
+
reason: u.reason,
|
|
101863
|
+
via: input.via
|
|
101864
|
+
}
|
|
101865
|
+
});
|
|
101217
101866
|
for (const ex of outcome.expired) {
|
|
101218
101867
|
const observedDwellMs = ex.lastSeenAt - ex.firstSeenAt;
|
|
101219
101868
|
const requiredDwellMs = input.settings.packageDropDwellSec * 1e3;
|