@camstack/addon-post-analysis 1.2.301 → 1.2.303

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.
@@ -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
- /** Recorded-clip follow-ups still running, detached from their delivery.
19892
- * Held only so {@link settleRecordedFootage} can await them in a test. */
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?.key ?? entry.ruleId,
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.recordedFollowUp(entry).kinds.length > 0 ? "recorded-clip-follow-up" : "no-clip"
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
- * Order the RECORDED cut for a moment the ring could not serve — after the
20882
- * alert has already gone out.
20883
- *
20884
- * ## Why it is here and not in `renderFootage`
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).
20885
21020
  *
20886
- * Cutting from the recordings queues an ffmpeg render on the recording node,
20887
- * behind whatever that engine is already doing (renders are serialised one at
20888
- * a time, and a scheduled timelapse holds the lane for minutes). Awaiting it
20889
- * inside the media resolve would move the alarm's latency from "as long as
20890
- * the post-roll" to "as long as the recorder's queue" — trading a punctual
20891
- * alert for a complete one, which is precisely the wrong trade for the
20892
- * notification that tells an operator someone is at the door.
21021
+ * ## Why the notification waits for it
20893
21022
  *
20894
- * A GIF rule is served here too (D682): a package pick-up is concluded five
20895
- * minutes after it happened, so a gif anchored on the pick-up is always
20896
- * outside the ring. The gif is derived from THIS cut's bytes
20897
- * (`deriveGifFromClip`) — one cut, never a second renderer — and a moment the
20898
- * recordings cannot serve ships NO gif, by named reason, rather than a gif of
20899
- * the wrong time.
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.
20900
21030
  *
20901
- * So the alert's latency is UNCHANGED, and **the clip is attached later**, as
20902
- * a follow-up send to the same target carrying the same `tag` — a phone
20903
- * collapses the two, so what the operator sees is the notification gaining
20904
- * its video rather than a second alert. That is an explicit behaviour change
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.
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.
20907
21036
  *
20908
- * ## The choice is the same one, asked once more
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.
20909
21041
  *
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
- startRecordedFootage(entry, target, notification) {
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
- const plan = this.recordedFollowUp(entry);
20924
- const wanted = plan.kinds;
20925
- if (cut === void 0 || wanted.length === 0 && plan.gifRefused === void 0 && plan.videoRefused === void 0) return;
20926
- const preRollSec = entry.payload.mediaClipPreRollSec ?? NC_CLIP_PRE_ROLL_DEFAULT_SEC;
20927
- const anchor = mediaAnchor(entry.payload.subject);
20928
- if (footageSourceFor({
20929
- anchorMs: anchor.atMs,
20930
- nowMs: this.now(),
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
- ringRetentionSec: this.ringRetentionSec(entry.payload.subject.deviceId)
20933
- }) !== "recordings") return;
20934
- const deviceId = entry.payload.subject.deviceId;
20935
- if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif follow-up: none — the gif is not cut from the recordings", {
20936
- tags: { deviceId },
20937
- meta: {
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
- eventId: entry.recordId,
20940
- targetId: target.id,
20941
- reason: plan.gifRefused,
20942
- anchoredOn: anchor.source,
20943
- ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20944
- next: wanted
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 follow-up: none — the video is not cut from the recordings", {
20948
- tags: { deviceId },
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
- ruleId: entry.ruleId,
20951
- eventId: entry.recordId,
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
- if (wanted.length === 0) return;
20960
- const videoSpeed = entry.payload.mediaClipSpeed;
20961
- const pending = (async () => {
20962
- const result = await cut({
20963
- deviceId,
20964
- aroundMs: anchor.atMs,
20965
- preRollSec,
20966
- postRollSec: entry.payload.mediaClipPostRollSec ?? NC_CLIP_POST_ROLL_DEFAULT_SEC,
20967
- ...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
20968
- ruleId: entry.ruleId,
20969
- recordId: entry.recordId
20970
- });
20971
- if (!result.ok) {
20972
- this.deps.logger.info("recorded footage follow-up: none — the recorded cut failed", {
20973
- tags: { deviceId },
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
- ruleId: entry.ruleId,
20976
- eventId: entry.recordId,
20977
- targetId: target.id,
20978
- reason: result.reason,
20979
- wanted
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
- const attachments = await this.recordedAttachments(entry, wanted, result.mp4);
20985
- if (attachments.length === 0) {
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
- ruleId: entry.ruleId,
20990
- eventId: entry.recordId,
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
- const withUrls = await this.withArtifactUrls(entry, attachments);
20999
- const sent = await this.deps.send({
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
- ruleId: entry.ruleId,
21012
- eventId: entry.recordId,
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
- this.deps.logger.info("recorded clip follow-up delivered", {
21020
- tags: { deviceId },
21021
- meta: {
21022
- ruleId: entry.ruleId,
21023
- eventId: entry.recordId,
21024
- target: target.name,
21025
- profile: result.profile,
21026
- bytes: result.mp4.byteLength,
21027
- attachments: attachments.map((a) => a.mediaType),
21028
- anchoredOn: anchor.source,
21029
- lagMs: this.now() - entry.updatedAt
21030
- }
21031
- });
21032
- })().catch((err) => {
21033
- this.deps.logger.warn("recorded clip follow-up threw", {
21034
- tags: { deviceId },
21035
- meta: {
21036
- ruleId: entry.ruleId,
21037
- eventId: entry.recordId,
21038
- error: String(err)
21039
- }
21040
- });
21041
- });
21042
- this.recordedFootage.add(pending);
21043
- pending.finally(() => {
21044
- this.recordedFootage.delete(pending);
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 a recorded follow-up for this row would CARRY — the one answer both
21049
- * the refusal line (`next`) and the follow-up read, so the log cannot promise
21050
- * a follow-up the dispatcher will not send.
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
- recordedFollowUp(entry) {
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 follow-up's attachments, gif first and video second — the order the
21096
- * ring path ships them in. A gif that could not be derived is left out (its
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
- const gif = await derive({
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
- if (gif.ok) out.push({
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() >= deadline) {
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
- cause: "render-timeout",
64602
+ reason: "timed-out",
64401
64603
  exportId: record.id,
64402
64604
  profile: pick.profile,
64403
- timeoutMs,
64605
+ deadlineMs: req.deadlineMs,
64404
64606
  state
64405
64607
  }
64406
64608
  });
64407
- await discard(ports, record.id);
64609
+ await abandon(ports, record.id);
64408
64610
  return {
64409
64611
  ok: false,
64410
- reason: "cut-failed"
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 follow-up served
64542
- * only a rule that asked for a video. The honest fix for "the gif shows the
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 follow-up carries no gif", {
64828
+ ports.logger.warn("recorded gif: the derive failed — the notification carries no gif", {
64614
64829
  tags,
64615
64830
  meta: {
64616
64831
  ...common,
@@ -67013,6 +67228,22 @@ function messageOf(err) {
67013
67228
  function isDetailNotAttachedRefusal(err) {
67014
67229
  return messageOf(err).includes(DETAIL_NOT_ATTACHED_MARKER);
67015
67230
  }
67231
+ /**
67232
+ * The runner's TYPED handle-expired refusal (D688): the frame handle a dispatch
67233
+ * named is past the native lease age bound, and the registry will refuse it on
67234
+ * every later ask. TWIN of `addon-pipeline/src/pipeline-runner/detail-park.ts`
67235
+ * `DETAIL_HANDLE_EXPIRED_MARKER` — keep the two identical.
67236
+ *
67237
+ * Why the dispatcher cares: unlike not-attached this is about ONE handle, not
67238
+ * the session — the parked frame still serves — but the handle itself is
67239
+ * finished. Naming it again (next tick, or through the native-strict crop rung)
67240
+ * can only be refused again; on the live fleet that was ~7 refusals per handle.
67241
+ */
67242
+ var DETAIL_HANDLE_EXPIRED_MARKER = "runDetailSubtree-refused:handle-expired";
67243
+ /** Did this `runDetailSubtree` error carry the runner's handle-expired marker? */
67244
+ function isDetailHandleExpiredRefusal(err) {
67245
+ return messageOf(err).includes(DETAIL_HANDLE_EXPIRED_MARKER);
67246
+ }
67016
67247
  //#endregion
67017
67248
  //#region src/pipeline-analytics/detail-scheduler.ts
67018
67249
  /** Default backoff/period when a step's cadence omits `minIntervalMs`. */
@@ -67469,6 +67700,7 @@ var TrackDetailDispatcher = class {
67469
67700
  className: frame.className
67470
67701
  };
67471
67702
  const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
67703
+ let handleRefused = false;
67472
67704
  if (frame.frameHandle !== void 0) try {
67473
67705
  const primary = await this.deps.runDetailSubtree({
67474
67706
  deviceId,
@@ -67482,7 +67714,10 @@ var TrackDetailDispatcher = class {
67482
67714
  this.warnNotAttachedThrottled(deviceId, dev, req);
67483
67715
  return null;
67484
67716
  }
67485
- this.deps.logger.debug("detail primary call failed — trying frame fallback", {
67717
+ if (isDetailHandleExpiredRefusal(err)) {
67718
+ handleRefused = true;
67719
+ this.retireRefusedHandle(deviceId, dev, req, frame);
67720
+ } else this.deps.logger.debug("detail primary call failed — trying frame fallback", {
67486
67721
  tags: { deviceId },
67487
67722
  meta: {
67488
67723
  trackId: req.trackId,
@@ -67532,7 +67767,7 @@ var TrackDetailDispatcher = class {
67532
67767
  }
67533
67768
  });
67534
67769
  }
67535
- if (this.deps.captureCropBase64 !== void 0) try {
67770
+ if (this.deps.captureCropBase64 !== void 0 && frame.frameHandle !== void 0 && !handleRefused) try {
67536
67771
  const cropJpeg = await this.deps.captureCropBase64(frame);
67537
67772
  if (cropJpeg !== null) {
67538
67773
  const retry = await this.deps.runDetailSubtree({
@@ -67561,6 +67796,35 @@ var TrackDetailDispatcher = class {
67561
67796
  return null;
67562
67797
  }
67563
67798
  /**
67799
+ * The runner refused `frame`'s handle as past the lease age bound (D688).
67800
+ * That verdict is final for the handle, so the track's stored association
67801
+ * loses it: later dispatches skip rung 1 and the native-strict crop rung
67802
+ * (both would name the same handle and be refused again — ~7 times per
67803
+ * handle on the live fleet) and go straight to the parked frame, until a
67804
+ * newly OBSERVED frame brings a fresh handle via {@link onFrame}.
67805
+ *
67806
+ * Only the refused handle is dropped: if a newer frame replaced it while the
67807
+ * dispatch was in flight, that one stands. The line is therefore emitted once
67808
+ * per (track, handle) — a concurrent dispatch refused on the same handle
67809
+ * finds it already gone and says nothing.
67810
+ */
67811
+ retireRefusedHandle(deviceId, dev, req, frame) {
67812
+ const current = dev.tracks.get(req.trackId);
67813
+ if (current === void 0 || current.frameHandle !== frame.frameHandle) return;
67814
+ const { frameHandle: refused, ...withoutHandle } = current;
67815
+ dev.tracks.set(req.trackId, withoutHandle);
67816
+ this.deps.logger.info("detail dispatch: frame handle refused as expired — retired for this track, parked frame only until a fresh observation", {
67817
+ tags: { deviceId },
67818
+ meta: {
67819
+ trackId: req.trackId,
67820
+ stepId: req.stepId,
67821
+ reason: "handle-expired",
67822
+ handle: refused === void 0 ? null : `${refused.shmId}#${refused.slot}#${refused.seq}`,
67823
+ parkedFrameRung: this.deps.captureParkedFrame !== void 0
67824
+ }
67825
+ });
67826
+ }
67827
+ /**
67564
67828
  * The typed refusal is EXPECTED for a track that outlives the runner's
67565
67829
  * detail park grace (a closing track's last dispatches), so it logs its own
67566
67830
  * line — the reason is knowable, unlike the every-rung-missed warn — and
@@ -73848,6 +74112,15 @@ var PackageDropSettingsSchema = object({
73848
74112
  /** Emit `package-picked-up` when a delivered package's entry departs. */
73849
74113
  packageDropPickupEnabled: boolean().default(true),
73850
74114
  /**
74115
+ * Conclude a pick-up EARLY on positive evidence (D687): three looks of the
74116
+ * package step spanning ≥ 10 s that found the doormat empty and unoccluded,
74117
+ * with somebody in the drop-off zone around the disappearance — instead of
74118
+ * waiting out the 300 s TTL. OFF by default: the one pick-up the stored data
74119
+ * could replay (615, 2026-09-30) is no sample to judge the false-departure
74120
+ * risk by. Silence always keeps the TTL.
74121
+ */
74122
+ packageDropEarlyPickupEnabled: boolean().default(false),
74123
+ /**
73851
74124
  * A parcel needs a deliverer: a stillness candidate only ever promotes to
73852
74125
  * `delivered` if a non-package track (person/vehicle/animal) was active IN
73853
74126
  * A CONFIGURED PACKAGE DROP-OFF ZONE within this many seconds BEFORE the
@@ -73886,6 +74159,7 @@ function resolvePackageDropSettings(raw) {
73886
74159
  packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
73887
74160
  packageDropMaxBboxAreaFrac: pick("packageDropMaxBboxAreaFrac"),
73888
74161
  packageDropPickupEnabled: pick("packageDropPickupEnabled"),
74162
+ packageDropEarlyPickupEnabled: pick("packageDropEarlyPickupEnabled"),
73889
74163
  packageDropActivityWindowSec: pick("packageDropActivityWindowSec"),
73890
74164
  packageDropClassFilter: pick("packageDropClassFilter")
73891
74165
  };
@@ -76219,11 +76493,129 @@ function trackIsStill(input) {
76219
76493
  spanFrac: metrics.pathSpanFrac
76220
76494
  };
76221
76495
  }
76496
+ //#endregion
76497
+ //#region src/pipeline-analytics/pipeline/package/package-stillness-tracker.ts
76498
+ /**
76499
+ * PackageStillnessTracker — the DEDICATED promotion path for packages.
76500
+ *
76501
+ * ## Why this exists (the arithmetic)
76502
+ *
76503
+ * The generic `StationaryObjectRegistry` used to promote a track that recorded
76504
+ * 4 positions inside a 30 s window — a criterion built for a car parking in
76505
+ * device 617's lot and observed on EVERY frame. (It has since moved to repeated
76506
+ * evidence across many sightings, `stationary-evidence.ts`; the impossibility
76507
+ * below is what forced this path to exist, and the class SKIP in the generic
76508
+ * loop is what keeps it separate now.)
76509
+ *
76510
+ * `package-detection` is not observed on every frame. It is a root step gated
76511
+ * by the `MotionSettleGovernor`, which runs it on a motion-settle edge or, in
76512
+ * the worst case, on a 45 s safety poll. Four positions in 30 s at one
76513
+ * position per 45 s is not a tuning problem, it is an impossibility — and it is
76514
+ * why `PackageDropDetector` never fired once in its life (0 `pa-pkg-*` rows on
76515
+ * every camera, ever, verified 2026-08-13).
76516
+ *
76517
+ * Measured on device 615 (the only camera that has ever produced a `package`
76518
+ * box): three separate tracks at 02:01:08, 02:03:14 and 02:05:29, each alive
76519
+ * for 4–5 s, each carrying TWO frames ~1.2 s apart, each on a NEW trackId. The
76520
+ * facts to accommodate are therefore:
76521
+ *
76522
+ * - the track DIES between governor rounds → re-association is by POSITION
76523
+ * (IoU), never by trackId;
76524
+ * - a round can be minutes apart → there is NO look-back window, only a count
76525
+ * of distinct FRAMES plus the operator's optional dwell;
76526
+ * - a burst of frames inside one settle session COUNTS: three frames ~400 ms
76527
+ * apart is three hits, which is enough to confirm the box is still there
76528
+ * without waiting for a second governor round after the courier left.
76529
+ * Device 615 2026-08-24 13:40: a real parcel expired at rounds=1 /
76530
+ * dwell 1458 ms vs 15 s — the 5 s round-gap plus 15 s dwell were the miss.
76531
+ *
76532
+ * The generic registry is untouched: a parked car still promotes exactly as
76533
+ * before. `scripts/check-package-promotion-satisfiable.ts` fails the build if
76534
+ * anyone breaks the relation between these constants and the governor's poll.
76535
+ *
76536
+ * ## Contract
76537
+ *
76538
+ * Pure logic with an injected clock and id factory — `observe` takes the frame
76539
+ * timestamp and never reads `Date.now()`. It emits {@link StationaryEntry}
76540
+ * values in the exact shape `PackageDropDetector.onStationaryChange` already
76541
+ * consumes, so the detector is unchanged by this path: `promoted` feeds
76542
+ * `appeared`, `departed` feeds `departed`, and the entry id is stable across
76543
+ * both so a pick-up pairs to its delivery.
76544
+ *
76545
+ * Expiry is judged on the frame clock inside `observe`, which is called on
76546
+ * every pipeline frame (an empty `sightings` list when the package step did
76547
+ * not run or found nothing). A camera producing no frames concludes nothing
76548
+ * there — and that is load-bearing, not an accident: the FIRST frame after a
76549
+ * silence runs the governor's poll (it is always due after a TTL of silence)
76550
+ * and re-sights a parcel that is still there before the expiry is judged.
76551
+ *
76552
+ * `sweep` judges the same TTL off a timer, so a pick-up is not held until the
76553
+ * next frame happens to arrive (615, 2026-09-30: 29 s). It concludes ONLY a
76554
+ * promoted parcel with an ABSENCE READ since its last sighting — a frame on
76555
+ * which the package step LOOKED (`looked`) and found no box at that place,
76556
+ * with no person or vehicle box over it (`occluders`). A parcel nobody looked
76557
+ * for since is left for the next frame's look. Silence is not evidence.
76558
+ */
76559
+ /**
76560
+ * Distinct FRAMES a box must be re-seen on before it is a parked package.
76561
+ * Three is the same bar as tracker `minHits`: one frame is a flicker, two
76562
+ * can still be a passer-by's bag in a single glance, three is "it stayed".
76563
+ * Counted per sighting, not per 5 s governor round — a settle burst of
76564
+ * three frames in ~1 s is a confirmation, not one collapsed round.
76565
+ */
76566
+ var PACKAGE_STILLNESS_MIN_HITS = 3;
76567
+ /**
76568
+ * Minimum spacing between two observations for them to count as separate
76569
+ * pick-up-slack windows. Must be ≤ the governor's poll interval. No longer
76570
+ * used for promotion (hits count every frame).
76571
+ */
76572
+ var PACKAGE_STILLNESS_ROUND_GAP_MS = 5e3;
76573
+ /**
76574
+ * How long a candidate survives without being re-associated, in OBSERVED time.
76575
+ * Must comfortably outlast several governor polls — the real inter-round gap
76576
+ * measured on 615 was ~2 minutes, not the nominal 45 s, because a
76577
+ * session-dispatch camera decodes nothing between motion sessions.
76578
+ */
76579
+ var PACKAGE_STILLNESS_TTL_MS = 5 * 6e4;
76580
+ /**
76581
+ * Association floor. Deliberately looser than the registry's `SUPPRESS_IOU`
76582
+ * (0.6): a box re-detected minutes later by a fresh track jitters more than one
76583
+ * confirmed frame-to-frame. Measured jitter on 615's doormat across 4 minutes
76584
+ * was IoU ≈ 0.98, so 0.3 is generous without being meaningless.
76585
+ */
76586
+ var PACKAGE_STILLNESS_ASSOC_IOU = .3;
76587
+ /**
76588
+ * Two absence reads closer than this are ONE look, not two. A settle burst
76589
+ * runs the package step 5 s apart and a poll 45 s apart, so a real second
76590
+ * look is always further away; two frames of one moment (a re-dispatch, a
76591
+ * recovery pass) must not count as agreement.
76592
+ */
76593
+ var PACKAGE_ABSENCE_READ_MIN_GAP_MS = 2e3;
76594
+ /**
76595
+ * Early pick-up (D687, per-camera opt-in): absence reads a promoted parcel
76596
+ * needs before it may depart ahead of its TTL. Three is the same bar as
76597
+ * promotion — one empty look is a missed detection, two can be one wobble of
76598
+ * the detector, three agreeing is "it is gone" (D49: a read that destroys
76599
+ * work needs another to agree).
76600
+ */
76601
+ var PACKAGE_EARLY_PICKUP_MIN_READS = 3;
76602
+ /**
76603
+ * …and the span those reads must cover, first to last. A settle burst looks
76604
+ * every 5 s, so 10 s is three burst runs: the parcel is still gone after the
76605
+ * collector has had time to put it back down.
76606
+ */
76607
+ var PACKAGE_EARLY_PICKUP_MIN_SPAN_MS = 1e4;
76608
+ /**
76609
+ * Absence reads the frame-free SWEEP needs before it may conclude a TTL
76610
+ * (fix round 1, D49): one read can be stale by minutes, or a detector
76611
+ * wobble; a second must agree, and the most recent look must be one of them.
76612
+ */
76613
+ var PACKAGE_SWEEP_MIN_READS = 2;
76222
76614
  var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
76223
- minHits: 3,
76224
- roundGapMs: 5e3,
76225
- ttlMs: 5 * 6e4,
76226
- assocIou: .3
76615
+ minHits: PACKAGE_STILLNESS_MIN_HITS,
76616
+ roundGapMs: PACKAGE_STILLNESS_ROUND_GAP_MS,
76617
+ ttlMs: PACKAGE_STILLNESS_TTL_MS,
76618
+ assocIou: PACKAGE_STILLNESS_ASSOC_IOU
76227
76619
  };
76228
76620
  var PackageStillnessTracker = class {
76229
76621
  config;
@@ -76266,37 +76658,117 @@ var PackageStillnessTracker = class {
76266
76658
  next[index] = parked;
76267
76659
  promoted.push(toEntry(parked));
76268
76660
  }
76661
+ const present = input.rawPackageBoxes !== void 0 ? withRawPresence(next, consumed, input.rawPackageBoxes, input.timestamp, this.config.assocIou) : next;
76662
+ const looked = input.looked === true && input.rawPackageBoxes !== void 0 ? withLook(present, consumed, input, input.rawPackageBoxes, this.config.assocIou) : present;
76663
+ const windowMs = input.activityWindowMs ?? 0;
76664
+ const early = /* @__PURE__ */ new Set();
76665
+ const unbacked = [];
76666
+ const read = looked.map((c) => {
76667
+ if (input.earlyPickup !== true || c.earlyUnbackedReported || !hasEarlyEvidence(c)) return c;
76668
+ const reason = this.earlyUnbackedReason(c, windowMs, input.lastActivityAt);
76669
+ if (reason === null) return c;
76670
+ unbacked.push({
76671
+ id: c.id,
76672
+ reason
76673
+ });
76674
+ return {
76675
+ ...c,
76676
+ earlyUnbackedReported: true
76677
+ };
76678
+ });
76679
+ return {
76680
+ promoted,
76681
+ ...this.expire({
76682
+ deviceId: input.deviceId,
76683
+ candidates: read,
76684
+ isExpired: (c) => {
76685
+ if (input.timestamp - c.lastSeenAt > this.config.ttlMs) return true;
76686
+ if (input.earlyPickup !== true || !hasEarlyEvidence(c)) return false;
76687
+ if (this.earlyUnbackedReason(c, windowMs, input.lastActivityAt) !== null) return false;
76688
+ early.add(c.id);
76689
+ return true;
76690
+ },
76691
+ activityWindowMs: windowMs,
76692
+ ...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
76693
+ }),
76694
+ earlyDepartureIds: [...early],
76695
+ earlyEvidenceUnbacked: unbacked
76696
+ };
76697
+ }
76698
+ /** Why complete early evidence may NOT conclude, or null when it may. */
76699
+ earlyUnbackedReason(c, activityWindowMs, lastActivityAt) {
76700
+ if (activityWindowMs <= 0) return "no-activity-window";
76701
+ if (lastActivityAt === void 0 || lastActivityAt < c.lastSeenAt - this.config.roundGapMs) return "nobody-around";
76702
+ return null;
76703
+ }
76704
+ /**
76705
+ * Judge the TTL without a frame. Concludes only a PROMOTED parcel whose TTL
76706
+ * ran out AND that carries at least one absence read since its last
76707
+ * sighting; every other candidate is left for the next frame, whose look
76708
+ * decides it. Never promotes. Immutable, never throws.
76709
+ */
76710
+ sweep(input) {
76711
+ const candidates = this.byDevice.get(input.deviceId) ?? [];
76712
+ return {
76713
+ promoted: [],
76714
+ ...this.expire({
76715
+ deviceId: input.deviceId,
76716
+ candidates,
76717
+ isExpired: (c) => c.promotedAt !== null && c.absenceReads.length >= PACKAGE_SWEEP_MIN_READS && c.lastLook === "absent" && input.timestamp - c.lastSeenAt > this.config.ttlMs,
76718
+ activityWindowMs: input.activityWindowMs ?? 0,
76719
+ ...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
76720
+ }),
76721
+ earlyDepartureIds: [],
76722
+ earlyEvidenceUnbacked: []
76723
+ };
76724
+ }
76725
+ /** The devices holding a candidate — what a sweep has to visit. */
76726
+ devicesWithCandidates() {
76727
+ return [...this.byDevice.keys()];
76728
+ }
76729
+ /**
76730
+ * Expiry. A promoted candidate that ages out is a departure (it feeds the
76731
+ * pick-up pairing) — but only a departure with somebody around the
76732
+ * disappearance is a PICK-UP; an unwitnessed vanish is the detector losing
76733
+ * the box. An unpromoted candidate that ages out is reported with its
76734
+ * counters: it expired in silence once (2026-08-18 13:41, a true parcel)
76735
+ * and that silence cost the diagnosis a day.
76736
+ */
76737
+ expire(input) {
76269
76738
  const departed = [];
76270
76739
  const departedWithoutActivity = [];
76271
76740
  const expired = [];
76272
76741
  const kept = [];
76273
- const windowMs = input.activityWindowMs ?? 0;
76274
- for (const c of next) {
76275
- if (input.timestamp - c.lastSeenAt > this.config.ttlMs) {
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
- });
76742
+ for (const c of input.candidates) {
76743
+ if (!input.isExpired(c)) {
76744
+ kept.push(c);
76745
+ continue;
76746
+ }
76747
+ if (c.promotedAt === null) {
76748
+ expired.push(expiredReport(c));
76287
76749
  continue;
76288
76750
  }
76289
- kept.push(c);
76751
+ if (this.pickupBacked(c, input.activityWindowMs, input.lastActivityAt)) departed.push(toEntry(c));
76752
+ else departedWithoutActivity.push(toEntry(c));
76290
76753
  }
76291
76754
  if (kept.length === 0) this.byDevice.delete(input.deviceId);
76292
76755
  else this.byDevice.set(input.deviceId, kept);
76293
76756
  return {
76294
- promoted,
76295
76757
  departed,
76296
76758
  departedWithoutActivity,
76297
76759
  expired
76298
76760
  };
76299
76761
  }
76762
+ /**
76763
+ * A pick-up needs somebody around the DISAPPEARANCE: activity at or after
76764
+ * the box's last sighting (one round-gap of slack — the taker is tracked in
76765
+ * the same frames that stop showing the box). The DELIVERER's activity
76766
+ * minutes earlier must not count, or every short-dwell vanish would read as
76767
+ * a pick-up. A window of 0 disables the gate.
76768
+ */
76769
+ pickupBacked(c, activityWindowMs, lastActivityAt) {
76770
+ return activityWindowMs <= 0 || lastActivityAt !== void 0 && lastActivityAt >= c.lastSeenAt - this.config.roundGapMs;
76771
+ }
76300
76772
  /** Drop a device's candidates (unbind / step disabled). */
76301
76773
  forgetDevice(deviceId) {
76302
76774
  this.byDevice.delete(deviceId);
@@ -76339,6 +76811,9 @@ var PackageStillnessTracker = class {
76339
76811
  lastSeenAt: input.timestamp,
76340
76812
  hits: 1,
76341
76813
  promotedAt: null,
76814
+ absenceReads: [],
76815
+ lastLook: null,
76816
+ earlyUnbackedReported: false,
76342
76817
  activityBackedAtOpen,
76343
76818
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
76344
76819
  ...s.label !== void 0 ? { label: s.label } : {},
@@ -76357,12 +76832,91 @@ function confirm(prev, input, s) {
76357
76832
  frameHeight: input.frameHeight,
76358
76833
  lastSeenAt: input.timestamp,
76359
76834
  hits: prev.hits + 1,
76835
+ absenceReads: [],
76836
+ lastLook: "present",
76837
+ earlyUnbackedReported: false,
76360
76838
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
76361
76839
  ...s.label !== void 0 ? { label: s.label } : {},
76362
76840
  ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
76363
76841
  ...s.keyFrameMediaId !== void 0 ? { keyFrameMediaId: s.keyFrameMediaId } : {}
76364
76842
  };
76365
76843
  }
76844
+ /**
76845
+ * A promoted parcel the tracker did not re-sight but the package step's RAW
76846
+ * output shows at its place (IoU ≥ the association floor): the detector saw
76847
+ * it, whatever the tracker did with the box. It is SEEN — `lastSeenAt` moves,
76848
+ * so the TTL ages from here and the pick-up instant is this last presence —
76849
+ * and every absence read so far is contradicted.
76850
+ */
76851
+ function withRawPresence(candidates, sighted, raw, timestamp, assocIou) {
76852
+ return candidates.map((c) => {
76853
+ if (c.promotedAt === null || sighted.has(c.id)) return c;
76854
+ if (!raw.some((b) => iou(b, c.bbox) >= assocIou)) return c;
76855
+ return {
76856
+ ...c,
76857
+ lastSeenAt: timestamp,
76858
+ absenceReads: [],
76859
+ lastLook: "present"
76860
+ };
76861
+ });
76862
+ }
76863
+ /**
76864
+ * Fold this frame's LOOK into every promoted candidate it did not re-sight.
76865
+ *
76866
+ * Judged against what the package step RETURNED (`raw`), never against the
76867
+ * tracker's sightings: a raw box at the place is a PRESENCE even when the
76868
+ * tracker dropped it, and it contradicts every absence read so far (D49). A
76869
+ * look whose view of the place was covered by a person or vehicle saw the
76870
+ * occluder, and says nothing — but it is the latest look, so the sweep waits
76871
+ * for a later one.
76872
+ */
76873
+ function withLook(candidates, sighted, input, raw, assocIou) {
76874
+ const occluders = input.occluders ?? [];
76875
+ return candidates.map((c) => {
76876
+ if (c.promotedAt === null || sighted.has(c.id)) return c;
76877
+ if (raw.some((b) => iou(b, c.bbox) >= assocIou)) return {
76878
+ ...c,
76879
+ absenceReads: [],
76880
+ lastLook: "present"
76881
+ };
76882
+ if (occluders.some((o) => boxesOverlap(o, c.bbox))) return {
76883
+ ...c,
76884
+ lastLook: "occluded"
76885
+ };
76886
+ const last = c.absenceReads[c.absenceReads.length - 1];
76887
+ if (last !== void 0 && input.timestamp - last < PACKAGE_ABSENCE_READ_MIN_GAP_MS) return {
76888
+ ...c,
76889
+ lastLook: "absent"
76890
+ };
76891
+ return {
76892
+ ...c,
76893
+ absenceReads: [...c.absenceReads, input.timestamp],
76894
+ lastLook: "absent"
76895
+ };
76896
+ });
76897
+ }
76898
+ /** Enough agreeing absence reads, over enough time, to call a parcel gone
76899
+ * ahead of its TTL (D687). */
76900
+ function hasEarlyEvidence(c) {
76901
+ if (c.promotedAt === null) return false;
76902
+ const first = c.absenceReads[0];
76903
+ const last = c.absenceReads[c.absenceReads.length - 1];
76904
+ return c.absenceReads.length >= PACKAGE_EARLY_PICKUP_MIN_READS && first !== void 0 && last !== void 0 && last - first >= PACKAGE_EARLY_PICKUP_MIN_SPAN_MS;
76905
+ }
76906
+ function boxesOverlap(a, b) {
76907
+ 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);
76908
+ }
76909
+ function expiredReport(c) {
76910
+ return {
76911
+ id: c.id,
76912
+ className: c.className,
76913
+ bbox: { ...c.bbox },
76914
+ hits: c.hits,
76915
+ firstSeenAt: c.firstSeenAt,
76916
+ lastSeenAt: c.lastSeenAt,
76917
+ activityBackedAtOpen: c.activityBackedAtOpen
76918
+ };
76919
+ }
76366
76920
  function toEntry(c) {
76367
76921
  return {
76368
76922
  id: c.id,
@@ -76381,6 +76935,60 @@ function toEntry(c) {
76381
76935
  };
76382
76936
  }
76383
76937
  //#endregion
76938
+ //#region src/pipeline-analytics/pipeline/package/package-look-evidence.ts
76939
+ function packageLookEvidence(input) {
76940
+ const raw = [];
76941
+ for (const d of input.frame.detections) {
76942
+ if (d.kind !== "first-level" || !input.packageClasses.has(d.macroClass)) continue;
76943
+ raw.push({
76944
+ x: d.bbox.x,
76945
+ y: d.bbox.y,
76946
+ w: d.bbox.width,
76947
+ h: d.bbox.height
76948
+ });
76949
+ }
76950
+ for (const d of input.frame.discarded ?? []) {
76951
+ if (!input.packageClasses.has(d.macroClass)) continue;
76952
+ const [x1, y1, x2, y2] = d.bbox;
76953
+ raw.push({
76954
+ x: x1,
76955
+ y: y1,
76956
+ w: x2 - x1,
76957
+ h: y2 - y1
76958
+ });
76959
+ }
76960
+ return {
76961
+ looked: input.frame.packageLooked === true,
76962
+ occluders: input.tracked.filter((t) => !input.packageClasses.has(t.className)).map((t) => ({
76963
+ x: t.bbox.x,
76964
+ y: t.bbox.y,
76965
+ w: t.bbox.w,
76966
+ h: t.bbox.h
76967
+ })),
76968
+ rawPackageBoxes: raw,
76969
+ earlyPickup: input.earlyPickupEnabled
76970
+ };
76971
+ }
76972
+ //#endregion
76973
+ //#region src/pipeline-analytics/pipeline/package/package-stillness-sweep.ts
76974
+ function sweepPackageStillness(deps) {
76975
+ for (const deviceId of deps.tracker.devicesWithCandidates()) try {
76976
+ sweepOne(deps, deviceId);
76977
+ } catch (err) {
76978
+ deps.onDeviceError(deviceId, err);
76979
+ }
76980
+ }
76981
+ function sweepOne(deps, deviceId) {
76982
+ const lastActivityAt = deps.lastActivityAtFor(deviceId);
76983
+ const outcome = deps.tracker.sweep({
76984
+ deviceId,
76985
+ timestamp: deps.now,
76986
+ activityWindowMs: deps.activityWindowMsFor(deviceId),
76987
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
76988
+ });
76989
+ if (outcome.departed.length > 0 || outcome.departedWithoutActivity.length > 0 || outcome.expired.length > 0) deps.onOutcome(deviceId, outcome, deps.now);
76990
+ }
76991
+ //#endregion
76384
76992
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
76385
76993
  /**
76386
76994
  * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
@@ -90428,6 +91036,13 @@ function buildGlobalSettingsSchema(cap) {
90428
91036
  description: "Also emit a package-picked-up event when the delivered package leaves.",
90429
91037
  default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
90430
91038
  },
91039
+ {
91040
+ type: "boolean",
91041
+ key: "packageDropEarlyPickupEnabled",
91042
+ label: "Early pick-up",
91043
+ 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.",
91044
+ default: PACKAGE_DROP_DEFAULTS.packageDropEarlyPickupEnabled
91045
+ },
90431
91046
  {
90432
91047
  type: "slider",
90433
91048
  key: "packageDropMinFrames",
@@ -96094,7 +96709,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
96094
96709
  createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
96095
96710
  getExport: (input) => api.recordingExport.getExport.query(input),
96096
96711
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
96097
- deleteExport: (input) => api.recordingExport.deleteExport.mutate(input)
96712
+ deleteExport: (input) => api.recordingExport.deleteExport.mutate(input),
96713
+ cancelExport: (input) => api.recordingExport.cancelExport.mutate(input)
96098
96714
  }, req),
96099
96715
  deriveGifFromClip: (req) => deriveRecordedGif(buildRecordedGifPorts({
96100
96716
  logger: logger.child("RecordedGif"),
@@ -97101,6 +97717,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
97101
97717
  startSweepTimers() {
97102
97718
  this.ttlSweepTimer = setInterval(() => {
97103
97719
  this.sweepExpiredTracks();
97720
+ this.runPackageStillnessSweep(Date.now());
97104
97721
  }, TTL_SWEEP_INTERVAL_MS);
97105
97722
  this.faceReembedAutoTimer = setInterval(() => {
97106
97723
  this.maybeAutoReembedFaces().then(() => this.faceAlignmentJobs?.maybeAutoRunPhotoPass());
@@ -98252,7 +98869,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
98252
98869
  settings: packageSettings,
98253
98870
  frameWidth: result.frameWidth,
98254
98871
  frameHeight: result.frameHeight,
98255
- timestamp: result.timestamp
98872
+ timestamp: result.timestamp,
98873
+ frame
98256
98874
  });
98257
98875
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
98258
98876
  const dispatcher = this.detailDispatcher;
@@ -101102,8 +101720,77 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
101102
101720
  minHits: input.settings.packageDropMinFrames,
101103
101721
  minDwellMs: input.settings.packageDropDwellSec * 1e3,
101104
101722
  activityWindowMs: input.settings.packageDropActivityWindowSec * 1e3,
101105
- ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
101723
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101724
+ ...packageLookEvidence({
101725
+ frame: input.frame,
101726
+ tracked: input.tracked,
101727
+ packageClasses: input.packageClasses,
101728
+ earlyPickupEnabled: input.settings.packageDropEarlyPickupEnabled
101729
+ })
101730
+ });
101731
+ this.bridgePackageStillness({
101732
+ deviceId: input.deviceId,
101733
+ outcome,
101734
+ timestamp: input.timestamp,
101735
+ settings: input.settings,
101736
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101737
+ via: "frame"
101738
+ });
101739
+ }
101740
+ /**
101741
+ * Judge the package TTL without a frame (F4) — on the existing TTL sweep
101742
+ * tick, for the devices holding a candidate. The tracker concludes only a
101743
+ * parcel the step looked for and did not find; see
101744
+ * `PackageStillnessTracker.sweep`.
101745
+ */
101746
+ /**
101747
+ * The tick's entry point: a throw here is an uncaught exception in a timer
101748
+ * callback, so it is caught and named (review M3). Per-device throws are
101749
+ * already caught inside the driver, so one camera cannot stop the others.
101750
+ */
101751
+ runPackageStillnessSweep(now) {
101752
+ try {
101753
+ this.sweepPackageStillness(now);
101754
+ } catch (err) {
101755
+ this.ctx.logger.warn("package stillness sweep threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
101756
+ }
101757
+ }
101758
+ sweepPackageStillness(now) {
101759
+ if (this.packageDropDetector === null) return;
101760
+ sweepPackageStillness({
101761
+ tracker: this.packageStillness,
101762
+ now,
101763
+ activityWindowMsFor: (deviceId) => this.packageDropSettingsFromCache(deviceId).packageDropActivityWindowSec * 1e3,
101764
+ lastActivityAtFor: (deviceId) => this.lastNonPackageActivityAt.get(deviceId),
101765
+ onOutcome: (deviceId, outcome, at) => {
101766
+ const lastActivityAt = this.lastNonPackageActivityAt.get(deviceId);
101767
+ this.bridgePackageStillness({
101768
+ deviceId,
101769
+ outcome,
101770
+ timestamp: at,
101771
+ settings: this.packageDropSettingsFromCache(deviceId),
101772
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101773
+ via: "sweep"
101774
+ });
101775
+ },
101776
+ onDeviceError: (deviceId, err) => {
101777
+ this.ctx.logger.warn("package stillness sweep threw", {
101778
+ tags: { deviceId },
101779
+ meta: { error: err instanceof Error ? err.message : String(err) }
101780
+ });
101781
+ }
101106
101782
  });
101783
+ }
101784
+ /**
101785
+ * Bridge one stillness outcome into `PackageDropDetector` — the SAME
101786
+ * `onStationaryChange` contract the generic registry uses. A frame's outcome
101787
+ * and a sweep's take this one path, so a pick-up concluded without a frame
101788
+ * is logged, paired and notified exactly like one concluded on a frame.
101789
+ */
101790
+ bridgePackageStillness(input) {
101791
+ if (this.packageDropDetector === null) return;
101792
+ const tags = { deviceId: input.deviceId };
101793
+ const { outcome, lastActivityAt } = input;
101107
101794
  for (const entry of outcome.promoted) {
101108
101795
  const centroid = {
101109
101796
  x: entry.bbox.x + entry.bbox.w / 2,
@@ -101153,7 +101840,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
101153
101840
  meta: {
101154
101841
  entryId: entry.id,
101155
101842
  className: entry.className,
101156
- unseenForMs: input.timestamp - entry.lastConfirmedAt
101843
+ unseenForMs: input.timestamp - entry.lastConfirmedAt,
101844
+ via: input.via,
101845
+ rule: outcome.earlyDepartureIds.includes(entry.id) ? "early" : "ttl"
101157
101846
  }
101158
101847
  });
101159
101848
  this.packageBackground?.noteRealPickup({
@@ -101195,7 +101884,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
101195
101884
  className: entry.className,
101196
101885
  bbox: entry.bbox,
101197
101886
  unseenForMs: input.timestamp - entry.lastConfirmedAt,
101198
- ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
101887
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101888
+ via: input.via
101199
101889
  }
101200
101890
  });
101201
101891
  this.packageBackground?.noteGhostCycle({
@@ -101214,6 +101904,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
101214
101904
  });
101215
101905
  }).catch(() => void 0);
101216
101906
  }
101907
+ for (const u of outcome.earlyEvidenceUnbacked) this.ctx.logger.info("early pick-up evidence complete — nobody around, left to the TTL", {
101908
+ tags,
101909
+ meta: {
101910
+ entryId: u.id,
101911
+ reason: u.reason,
101912
+ via: input.via
101913
+ }
101914
+ });
101217
101915
  for (const ex of outcome.expired) {
101218
101916
  const observedDwellMs = ex.lastSeenAt - ex.firstSeenAt;
101219
101917
  const requiredDwellMs = input.settings.packageDropDwellSec * 1e3;