@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.
@@ -19667,6 +19667,101 @@ function footageSourceFor(input) {
19667
19667
  return ageSec + preRollSec > ringRetentionSec ? "recordings" : "ring";
19668
19668
  }
19669
19669
  //#endregion
19670
+ //#region src/notification-center/recorded-cut-memo.ts
19671
+ /** How long the recorder is treated as busy after a cut timed out. */
19672
+ var NC_RECORDER_BUSY_MS = 6e4;
19673
+ /**
19674
+ * How long a cut is reused. The outbox retries at 5 s, 10 s, 20 s … so ten
19675
+ * minutes covers every retry that could still be about the same notification.
19676
+ */
19677
+ var NC_RECORDED_CUT_MEMO_TTL_MS = 10 * 6e4;
19678
+ /** At most this many cuts are held (each is a ~10 s clip, a few hundred KB). */
19679
+ var NC_RECORDED_CUT_MEMO_MAX = 16;
19680
+ /**
19681
+ * Grace a joining row adds to the cut's own deadline: the recorder checks its
19682
+ * deadline between 1 s polls, so its `timed-out` lands up to ~1 s after it.
19683
+ */
19684
+ var NC_RECORDED_CUT_JOIN_GRACE_MS = 2e3;
19685
+ /** The window a request describes — rows that agree on it share a cut. */
19686
+ function recordedCutKey(req) {
19687
+ return [
19688
+ req.deviceId,
19689
+ req.recordId,
19690
+ req.aroundMs,
19691
+ req.preRollSec,
19692
+ req.postRollSec,
19693
+ req.speed ?? 1
19694
+ ].join(":");
19695
+ }
19696
+ var NcRecordedCutMemo = class {
19697
+ cuts = /* @__PURE__ */ new Map();
19698
+ busyUntil = 0;
19699
+ /** When the busy window closes (0 when it never opened). */
19700
+ busyUntilMs() {
19701
+ return this.busyUntil;
19702
+ }
19703
+ /** Is the recorder inside its busy window? */
19704
+ isBusy(now) {
19705
+ return now < this.busyUntil;
19706
+ }
19707
+ /** A cut timed out: open the breaker. Returns when it closes. */
19708
+ noteTimedOut(now) {
19709
+ this.busyUntil = Math.max(this.busyUntil, now + NC_RECORDER_BUSY_MS);
19710
+ return this.busyUntil;
19711
+ }
19712
+ /** The memoised cut for this window, if one is held. */
19713
+ find(key, now) {
19714
+ this.evictAged(now);
19715
+ return this.cuts.get(key) ?? null;
19716
+ }
19717
+ /** Start (and hold) the cut for this window. */
19718
+ start(key, now, deadlineMs, run) {
19719
+ this.evict(now);
19720
+ const cut = run().then((result) => result.ok ? {
19721
+ kind: "cut",
19722
+ mp4: result.mp4,
19723
+ profile: result.profile
19724
+ } : {
19725
+ kind: "refused",
19726
+ result
19727
+ }, (err) => ({
19728
+ kind: "threw",
19729
+ error: String(err)
19730
+ }));
19731
+ const shared = {
19732
+ key,
19733
+ createdAt: now,
19734
+ deadlineMs,
19735
+ cut,
19736
+ gif: null,
19737
+ settled: false
19738
+ };
19739
+ cut.then(() => {
19740
+ shared.settled = true;
19741
+ });
19742
+ this.cuts.set(key, shared);
19743
+ return shared;
19744
+ }
19745
+ /** How many cuts are held — diagnostics and tests. */
19746
+ size() {
19747
+ return this.cuts.size;
19748
+ }
19749
+ /** Drop cuts past their TTL. Called on every use AND from the notification
19750
+ * centre's periodic drain tick, so bytes do not outlive the last row. */
19751
+ evictAged(now) {
19752
+ for (const [key, c] of this.cuts) if (now - c.createdAt > NC_RECORDED_CUT_MEMO_TTL_MS) this.cuts.delete(key);
19753
+ }
19754
+ /** Make room for one more: aged entries first, then the oldest. */
19755
+ evict(now) {
19756
+ this.evictAged(now);
19757
+ while (this.cuts.size >= NC_RECORDED_CUT_MEMO_MAX) {
19758
+ const oldest = this.cuts.keys().next();
19759
+ if (oldest.done === true) break;
19760
+ this.cuts.delete(oldest.value);
19761
+ }
19762
+ }
19763
+ };
19764
+ //#endregion
19670
19765
  //#region src/notification-center/media-anchor.ts
19671
19766
  /**
19672
19767
  * Decide the footage anchor for one subject. Pure, total, and the ONLY place
@@ -19816,6 +19911,20 @@ function composeRuleAiBody(input) {
19816
19911
  if (input.templateNamesAi) return input.body;
19817
19912
  return input.body.length === 0 ? input.aiText : `${input.body}\n${input.aiText}`;
19818
19913
  }
19914
+ /**
19915
+ * `work`, or `onTimeout` once `ms` has passed — whichever is first. `work` must
19916
+ * never reject. The hard edge of the recorded-footage wait: the recorder's own
19917
+ * `deadlineMs` is checked between polls, and an RPC in flight is not.
19918
+ */
19919
+ function settleWithin(work, ms, onTimeout) {
19920
+ return new Promise((resolve) => {
19921
+ const timer = setTimeout(() => resolve(onTimeout), ms);
19922
+ work.then((value) => {
19923
+ clearTimeout(timer);
19924
+ resolve(value);
19925
+ });
19926
+ });
19927
+ }
19819
19928
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
19820
19929
  /**
19821
19930
  * How long the broker retains the footage window a notification is cut from,
@@ -19834,6 +19943,27 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
19834
19943
  */
19835
19944
  var NC_CLIP_RING_RETENTION_SEC = 20;
19836
19945
  /**
19946
+ * How long a notification whose moment the ring has lost waits for its
19947
+ * RECORDED cut (and the gif derived from it) before it ships without (D686).
19948
+ *
19949
+ * The notification is HELD for this: a notifier is one-shot, so the footage
19950
+ * rides the single message or does not ride at all. Measured on the
19951
+ * 2026-09-30 pick-up on 615: export 1 s, derive 262 ms — so the bound is
19952
+ * almost never reached, and when it is the render is queued behind something
19953
+ * that holds the recorder's one ffmpeg lane for minutes (a timelapse), which
19954
+ * no wait worth holding a notification for would outlast.
19955
+ *
19956
+ * Why not longer: the outbox drains rows ONE AT A TIME, so this is also the
19957
+ * worst case by which every row queued behind this one is delayed. A ring cut
19958
+ * of a live moment already blocks the drain for its post-roll (6 s) plus the
19959
+ * render; 30 s is the ceiling on top of that, reached only when the recorder
19960
+ * is wedged. Why not shorter: a moment that reaches this path is already
19961
+ * older than the ring (≥ 20 s, a pick-up ≥ 5 min), so a few more seconds cost
19962
+ * the operator nothing, and a bound under ~10 s would drop the gif whenever
19963
+ * the recorder is merely busy.
19964
+ */
19965
+ var NC_RECORDED_FOOTAGE_WAIT_MS = 3e4;
19966
+ /**
19837
19967
  * The bounded wait for a still when the rule asked for NO footage.
19838
19968
  *
19839
19969
  * Track media lands 1.5–5.5 s after the trigger (device 615, 2026-08-12), so a
@@ -19911,9 +20041,8 @@ var NcDispatcher = class {
19911
20041
  * {@link NC_ANNOTATED_FALLBACK_MEMO_MAX}. */
19912
20042
  reportedAnnotatedFallbacks = /* @__PURE__ */ new Set();
19913
20043
  texts;
19914
- /** Recorded-clip follow-ups still running, detached from their delivery.
19915
- * Held only so {@link settleRecordedFootage} can await them in a test. */
19916
- recordedFootage = /* @__PURE__ */ new Set();
20044
+ /** One recorded cut per moment, and the recorder-busy breaker (D686). */
20045
+ recordedCuts = new NcRecordedCutMemo();
19917
20046
  constructor(deps) {
19918
20047
  this.deps = deps;
19919
20048
  this.now = deps.now ?? (() => Date.now());
@@ -19921,6 +20050,18 @@ var NcDispatcher = class {
19921
20050
  this.targetCacheTtlMs = deps.targetCacheTtlMs ?? DEFAULT_TARGET_CACHE_TTL_MS;
19922
20051
  this.texts = deps.texts ?? new NcTextCatalog();
19923
20052
  }
20053
+ /**
20054
+ * Drop held recorded cuts past their TTL (re-review R4). Driven by the
20055
+ * notification centre's drain tick, so a clip's bytes do not stay resident
20056
+ * after the last row that could use them.
20057
+ */
20058
+ sweepRecordedCuts(now) {
20059
+ this.recordedCuts.evictAged(now);
20060
+ }
20061
+ /** How many recorded cuts are held — diagnostics and tests. */
20062
+ recordedCutsHeld() {
20063
+ return this.recordedCuts.size();
20064
+ }
19924
20065
  /** The outbox `deliver` executor. */
19925
20066
  async deliver(entry) {
19926
20067
  if (this.deps.isRuleTargetDisabled !== void 0) {
@@ -20043,7 +20184,6 @@ var NcDispatcher = class {
20043
20184
  ...dropped.length > 0 ? { dropped } : {}
20044
20185
  }
20045
20186
  });
20046
- this.startRecordedFootage(entry, target, notification);
20047
20187
  return {
20048
20188
  ok: true,
20049
20189
  ...verdict !== null ? { confirm: verdict } : {},
@@ -20588,7 +20728,7 @@ var NcDispatcher = class {
20588
20728
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
20589
20729
  ...params,
20590
20730
  ...tapUrl !== null ? { clickUrl: tapUrl } : {},
20591
- tag: entry.payload.group?.key ?? entry.ruleId,
20731
+ ...entry.payload.group !== void 0 ? { tag: entry.payload.group.key } : {},
20592
20732
  ...systemEvent !== void 0 ? systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {} : { deviceId: subject.deviceId },
20593
20733
  ...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
20594
20734
  ...systemEvent !== void 0 ? { metadata: {
@@ -20860,13 +21000,10 @@ var NcDispatcher = class {
20860
21000
  preRollSec,
20861
21001
  retentionSec: this.ringRetentionSec(entry.payload.subject.deviceId),
20862
21002
  wanted: [...wantGif ? ["gif"] : [], ...wantMp4 ? ["video"] : []],
20863
- next: this.recordedFollowUp(entry).kinds.length > 0 ? "recorded-clip-follow-up" : "no-clip"
21003
+ next: this.recordedFootagePlan(entry).kinds.length > 0 ? "recorded-cut" : "no-clip"
20864
21004
  }
20865
21005
  });
20866
- return {
20867
- attempted: false,
20868
- attachments: []
20869
- };
21006
+ return this.cutRecordedFootage(entry, anchor, preRollSec);
20870
21007
  }
20871
21008
  const attachments = [];
20872
21009
  try {
@@ -20901,176 +21038,238 @@ var NcDispatcher = class {
20901
21038
  };
20902
21039
  }
20903
21040
  /**
20904
- * Order the RECORDED cut for a moment the ring could not serve — after the
20905
- * alert has already gone out.
20906
- *
20907
- * ## Why it is here and not in `renderFootage`
21041
+ * Cut the footage from the RECORDINGS, for a moment the ring could not
21042
+ * serve, and hand it to the ONE notification this row sends (D686).
20908
21043
  *
20909
- * Cutting from the recordings queues an ffmpeg render on the recording node,
20910
- * behind whatever that engine is already doing (renders are serialised one at
20911
- * a time, and a scheduled timelapse holds the lane for minutes). Awaiting it
20912
- * inside the media resolve would move the alarm's latency from "as long as
20913
- * the post-roll" to "as long as the recorder's queue" — trading a punctual
20914
- * alert for a complete one, which is precisely the wrong trade for the
20915
- * notification that tells an operator someone is at the door.
21044
+ * ## Why the notification waits for it
20916
21045
  *
20917
- * A GIF rule is served here too (D682): a package pick-up is concluded five
20918
- * minutes after it happened, so a gif anchored on the pick-up is always
20919
- * outside the ring. The gif is derived from THIS cut's bytes
20920
- * (`deriveGifFromClip`) — one cut, never a second renderer — and a moment the
20921
- * recordings cannot serve ships NO gif, by named reason, rather than a gif of
20922
- * the wrong time.
21046
+ * A notifier is one-shot. The earlier design (D682) sent the alert first and
21047
+ * the recorded footage as a SECOND send with the same `tag`, on the promise
21048
+ * that "a phone collapses the two" — Zentik honours no tag, so every such
21049
+ * row reached the operator as two messages (615, 2026-09-30). A moment that
21050
+ * reaches this path is already older than the ring (a pick-up is concluded
21051
+ * minutes after it happened), so waiting a few more seconds costs nothing;
21052
+ * a second message costs the operator a duplicate every time.
20923
21053
  *
20924
- * So the alert's latency is UNCHANGED, and **the clip is attached later**, as
20925
- * a follow-up send to the same target carrying the same `tag` — a phone
20926
- * collapses the two, so what the operator sees is the notification gaining
20927
- * its video rather than a second alert. That is an explicit behaviour change
20928
- * and not a silent one: the log says `recorded-clip-follow-up` on the refusal
20929
- * line, and says so again when the follow-up lands.
21054
+ * The wait is bounded by {@link NC_RECORDED_FOOTAGE_WAIT_MS} and the bound is
21055
+ * handed to the recorder as `deadlineMs`, so a render nobody waits for any
21056
+ * more is abandoned, not left running. A cut that fails, throws or is not
21057
+ * ready in time costs the footage and never the notification — it ships
21058
+ * with the still, and the reason is logged per camera.
20930
21059
  *
20931
- * ## The choice is the same one, asked once more
21060
+ * A GIF rule is served here too (D682): the gif is derived from THIS cut's
21061
+ * bytes (`deriveGifFromClip`) — one cut, never a second renderer — and a
21062
+ * moment the recordings cannot serve ships NO gif, by named reason, rather
21063
+ * than a gif of the wrong time.
20932
21064
  *
20933
- * `footageSourceFor` is the ONE rule, and both callers ask it rather than one
20934
- * of them remembering the other's answer. It is monotone in the clock, so the
20935
- * later ask here can only ever agree with, or become stricter than, the one
20936
- * `renderFootage` made — it can never decide "the ring" for a window the ring
20937
- * has already dropped. A second spelling of the arithmetic is what would be
20938
- * unsafe; a second CALL is not.
20939
- *
20940
- * Never throws, never rejects: this runs detached from a delivery that has
20941
- * already succeeded, and an unhandled rejection here would be a crash charged
20942
- * to a notification that arrived perfectly.
21065
+ * `footageSourceFor` already said `recordings` for this row: the caller asks
21066
+ * it once, and this runs only on that answer.
20943
21067
  */
20944
- startRecordedFootage(entry, target, notification) {
21068
+ async cutRecordedFootage(entry, anchor, preRollSec) {
21069
+ const plan = this.recordedFootagePlan(entry);
21070
+ this.reportRecordedRefusals(entry, plan, anchor);
20945
21071
  const cut = this.deps.cutRecordedClip;
20946
- const plan = this.recordedFollowUp(entry);
20947
- const wanted = plan.kinds;
20948
- if (cut === void 0 || wanted.length === 0 && plan.gifRefused === void 0 && plan.videoRefused === void 0) return;
20949
- const preRollSec = entry.payload.mediaClipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
20950
- const anchor = mediaAnchor(entry.payload.subject);
20951
- if (footageSourceFor({
20952
- anchorMs: anchor.atMs,
20953
- nowMs: this.now(),
21072
+ if (cut === void 0 || plan.kinds.length === 0) return {
21073
+ attempted: false,
21074
+ attachments: []
21075
+ };
21076
+ const startedAt = this.now();
21077
+ const videoSpeed = entry.payload.mediaClipSpeed;
21078
+ const window = {
21079
+ deviceId: entry.payload.subject.deviceId,
21080
+ aroundMs: anchor.atMs,
20954
21081
  preRollSec,
20955
- ringRetentionSec: this.ringRetentionSec(entry.payload.subject.deviceId)
20956
- }) !== "recordings") return;
20957
- const deviceId = entry.payload.subject.deviceId;
20958
- if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif follow-up: none — the gif is not cut from the recordings", {
20959
- tags: { deviceId },
20960
- meta: {
21082
+ postRollSec: entry.payload.mediaClipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC,
21083
+ ...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
21084
+ recordId: entry.recordId
21085
+ };
21086
+ const key = recordedCutKey(window);
21087
+ let shared = this.recordedCuts.find(key, startedAt);
21088
+ let boundMs = NC_RECORDED_FOOTAGE_WAIT_MS;
21089
+ if (shared !== null && !shared.settled) {
21090
+ if (this.recordedCuts.isBusy(startedAt)) {
21091
+ const outcome = {
21092
+ kind: "recorder-busy",
21093
+ busyUntil: this.recordedCuts.busyUntilMs()
21094
+ };
21095
+ this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
21096
+ return {
21097
+ attempted: false,
21098
+ attachments: []
21099
+ };
21100
+ }
21101
+ boundMs = Math.min(NC_RECORDED_FOOTAGE_WAIT_MS, shared.deadlineMs + NC_RECORDED_CUT_JOIN_GRACE_MS - startedAt);
21102
+ if (boundMs <= 0) {
21103
+ this.reportRecordedOutcome(entry, plan.kinds, anchor, { kind: "timed-out" }, startedAt);
21104
+ return {
21105
+ attempted: false,
21106
+ attachments: []
21107
+ };
21108
+ }
21109
+ }
21110
+ if (shared === null) {
21111
+ if (this.recordedCuts.isBusy(startedAt)) {
21112
+ const outcome = {
21113
+ kind: "recorder-busy",
21114
+ busyUntil: this.recordedCuts.busyUntilMs()
21115
+ };
21116
+ this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
21117
+ return {
21118
+ attempted: false,
21119
+ attachments: []
21120
+ };
21121
+ }
21122
+ const deadlineMs = startedAt + NC_RECORDED_FOOTAGE_WAIT_MS;
21123
+ shared = this.recordedCuts.start(key, startedAt, deadlineMs, () => cut({
21124
+ ...window,
20961
21125
  ruleId: entry.ruleId,
20962
- eventId: entry.recordId,
20963
- targetId: target.id,
20964
- reason: plan.gifRefused,
20965
- anchoredOn: anchor.source,
20966
- ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20967
- next: wanted
21126
+ deadlineMs
21127
+ }));
21128
+ }
21129
+ const outcome = await settleWithin(this.attachRecordedCut(entry, plan.kinds, shared), boundMs, { kind: "timed-out" });
21130
+ if (outcome.kind === "timed-out") this.recordedCuts.noteTimedOut(this.now());
21131
+ this.reportRecordedOutcome(entry, plan.kinds, anchor, outcome, startedAt);
21132
+ return {
21133
+ attempted: true,
21134
+ attachments: outcome.kind === "attached" ? outcome.attachments : []
21135
+ };
21136
+ }
21137
+ /** The shared cut, then this row's containers of it — never rejects. */
21138
+ async attachRecordedCut(entry, wanted, shared) {
21139
+ const result = await shared.cut;
21140
+ if (result.kind === "threw") return {
21141
+ kind: "threw",
21142
+ error: result.error
21143
+ };
21144
+ if (result.kind === "refused") return {
21145
+ kind: "cut-refused",
21146
+ reason: result.result.reason
21147
+ };
21148
+ try {
21149
+ const attachments = await this.recordedAttachments(entry, wanted, result.mp4, shared);
21150
+ if (attachments.length === 0) return { kind: "no-gif" };
21151
+ return {
21152
+ kind: "attached",
21153
+ attachments,
21154
+ profile: result.profile,
21155
+ bytes: result.mp4.byteLength
21156
+ };
21157
+ } catch (err) {
21158
+ return {
21159
+ kind: "threw",
21160
+ error: String(err)
21161
+ };
21162
+ }
21163
+ }
21164
+ /**
21165
+ * Why a kind the rule asked for is not cut from the recordings — said per
21166
+ * camera, because "where is my gif" is always asked about a camera.
21167
+ * `no-derive` included: a wiring seam, but a silent one is a dropped gif.
21168
+ */
21169
+ reportRecordedRefusals(entry, plan, anchor) {
21170
+ const common = {
21171
+ ruleId: entry.ruleId,
21172
+ eventId: entry.recordId,
21173
+ targetId: entry.targetId,
21174
+ anchoredOn: anchor.source,
21175
+ ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
21176
+ next: plan.kinds
21177
+ };
21178
+ const tags = { deviceId: entry.payload.subject.deviceId };
21179
+ if (plan.gifRefused !== void 0) this.deps.logger.info("recorded gif: none — the gif is not cut from the recordings", {
21180
+ tags,
21181
+ meta: {
21182
+ ...common,
21183
+ reason: plan.gifRefused
20968
21184
  }
20969
21185
  });
20970
- if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video follow-up: none — the video is not cut from the recordings", {
20971
- tags: { deviceId },
21186
+ if (plan.videoRefused !== void 0) this.deps.logger.info("recorded video: none — the video is not cut from the recordings", {
21187
+ tags,
20972
21188
  meta: {
20973
- ruleId: entry.ruleId,
20974
- eventId: entry.recordId,
20975
- targetId: target.id,
20976
- reason: plan.videoRefused,
20977
- anchoredOn: anchor.source,
20978
- ...anchor.fallback !== void 0 ? { anchorFallback: anchor.fallback } : {},
20979
- next: wanted
21189
+ ...common,
21190
+ reason: plan.videoRefused
20980
21191
  }
20981
21192
  });
20982
- if (wanted.length === 0) return;
20983
- const videoSpeed = entry.payload.mediaClipSpeed;
20984
- const pending = (async () => {
20985
- const result = await cut({
20986
- deviceId,
20987
- aroundMs: anchor.atMs,
20988
- preRollSec,
20989
- postRollSec: entry.payload.mediaClipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC,
20990
- ...videoSpeed !== void 0 ? { speed: videoSpeed } : {},
20991
- ruleId: entry.ruleId,
20992
- recordId: entry.recordId
20993
- });
20994
- if (!result.ok) {
20995
- this.deps.logger.info("recorded footage follow-up: none — the recorded cut failed", {
20996
- tags: { deviceId },
21193
+ }
21194
+ /** One line per recorded cut, whatever became of it, tagged per camera. The
21195
+ * ring line said `next: recorded-cut`; this is where that story ends. */
21196
+ reportRecordedOutcome(entry, wanted, anchor, outcome, startedAt) {
21197
+ const tags = { deviceId: entry.payload.subject.deviceId };
21198
+ const common = {
21199
+ ruleId: entry.ruleId,
21200
+ eventId: entry.recordId,
21201
+ targetId: entry.targetId,
21202
+ wanted,
21203
+ anchoredOn: anchor.source
21204
+ };
21205
+ switch (outcome.kind) {
21206
+ case "attached":
21207
+ this.deps.logger.info("recorded footage attached", {
21208
+ tags,
20997
21209
  meta: {
20998
- ruleId: entry.ruleId,
20999
- eventId: entry.recordId,
21000
- targetId: target.id,
21001
- reason: result.reason,
21002
- wanted
21210
+ ...common,
21211
+ profile: outcome.profile,
21212
+ bytes: outcome.bytes,
21213
+ attachments: outcome.attachments.map((a) => a.mediaType),
21214
+ waitedMs: this.now() - startedAt
21003
21215
  }
21004
21216
  });
21005
21217
  return;
21006
- }
21007
- const attachments = await this.recordedAttachments(entry, wanted, result.mp4);
21008
- if (attachments.length === 0) {
21009
- this.deps.logger.info("recorded clip follow-up: none — the gif could not be derived", {
21010
- tags: { deviceId },
21218
+ case "cut-refused":
21219
+ this.deps.logger.info("recorded footage: none — the recorded cut failed", {
21220
+ tags,
21011
21221
  meta: {
21012
- ruleId: entry.ruleId,
21013
- eventId: entry.recordId,
21014
- targetId: target.id,
21015
- reason: "gif-derive-failed",
21016
- wanted
21222
+ ...common,
21223
+ reason: outcome.reason
21017
21224
  }
21018
21225
  });
21019
21226
  return;
21020
- }
21021
- const withUrls = await this.withArtifactUrls(entry, attachments);
21022
- const sent = await this.deps.send({
21023
- addonId: target.addonId,
21024
- targetId: target.id,
21025
- notification: {
21026
- ...notification,
21027
- attachments: withUrls.attachments
21028
- }
21029
- });
21030
- if (!sent.success) {
21031
- this.deps.logger.warn("recorded clip follow-up: the send failed", {
21032
- tags: { deviceId },
21227
+ case "no-gif":
21228
+ this.deps.logger.info("recorded footage: none — the gif could not be derived", {
21229
+ tags,
21033
21230
  meta: {
21034
- ruleId: entry.ruleId,
21035
- eventId: entry.recordId,
21036
- targetId: target.id,
21037
- error: sent.error ?? "send failed"
21231
+ ...common,
21232
+ reason: "gif-derive-failed"
21038
21233
  }
21039
21234
  });
21040
21235
  return;
21041
- }
21042
- this.deps.logger.info("recorded clip follow-up delivered", {
21043
- tags: { deviceId },
21044
- meta: {
21045
- ruleId: entry.ruleId,
21046
- eventId: entry.recordId,
21047
- target: target.name,
21048
- profile: result.profile,
21049
- bytes: result.mp4.byteLength,
21050
- attachments: attachments.map((a) => a.mediaType),
21051
- anchoredOn: anchor.source,
21052
- lagMs: this.now() - entry.updatedAt
21053
- }
21054
- });
21055
- })().catch((err) => {
21056
- this.deps.logger.warn("recorded clip follow-up threw", {
21057
- tags: { deviceId },
21058
- meta: {
21059
- ruleId: entry.ruleId,
21060
- eventId: entry.recordId,
21061
- error: String(err)
21062
- }
21063
- });
21064
- });
21065
- this.recordedFootage.add(pending);
21066
- pending.finally(() => {
21067
- this.recordedFootage.delete(pending);
21068
- });
21236
+ case "threw":
21237
+ this.deps.logger.warn("recorded footage: none — the recorded cut threw", {
21238
+ tags,
21239
+ meta: {
21240
+ ...common,
21241
+ reason: "threw",
21242
+ error: outcome.error
21243
+ }
21244
+ });
21245
+ return;
21246
+ case "timed-out":
21247
+ this.deps.logger.warn("recorded footage: none — not ready inside the bound", {
21248
+ tags,
21249
+ meta: {
21250
+ ...common,
21251
+ reason: "timed-out",
21252
+ boundMs: NC_RECORDED_FOOTAGE_WAIT_MS,
21253
+ busyForMs: NC_RECORDER_BUSY_MS
21254
+ }
21255
+ });
21256
+ return;
21257
+ case "recorder-busy":
21258
+ this.deps.logger.info("recorded footage: none — the recorder is busy", {
21259
+ tags,
21260
+ meta: {
21261
+ ...common,
21262
+ reason: "recorder-busy",
21263
+ busyUntil: outcome.busyUntil
21264
+ }
21265
+ });
21266
+ return;
21267
+ }
21069
21268
  }
21070
21269
  /**
21071
- * What a recorded follow-up for this row would CARRY — the one answer both
21072
- * the refusal line (`next`) and the follow-up read, so the log cannot promise
21073
- * a follow-up the dispatcher will not send.
21270
+ * What the recorded cut for this row would CARRY — the one answer both the
21271
+ * ring line (`next`) and the cut read, so the log cannot promise a cut the
21272
+ * dispatcher will not order.
21074
21273
  *
21075
21274
  * A video when the rule asked for one. A gif only for a package PICK-UP
21076
21275
  * whose moment is KNOWN (D682): the gif is derived from the recorded cut's
@@ -21084,7 +21283,7 @@ var NcDispatcher = class {
21084
21283
  * gif OR video — cutting the recordings there is exactly the doormat D682
21085
21284
  * removed (see {@link NcRecordedGifRefusal}, {@link NcRecordedVideoRefusal}).
21086
21285
  */
21087
- recordedFollowUp(entry) {
21286
+ recordedFootagePlan(entry) {
21088
21287
  if (this.deps.cutRecordedClip === void 0) return { kinds: [] };
21089
21288
  const gifRefused = this.recordedGifRefusal(entry);
21090
21289
  const videoRefused = this.recordedVideoRefusal(entry);
@@ -21115,48 +21314,39 @@ var NcDispatcher = class {
21115
21314
  if (anchorFellBack(mediaAnchor(subject), true)) return "moment-unknown";
21116
21315
  }
21117
21316
  /**
21118
- * The follow-up's attachments, gif first and video second — the order the
21119
- * ring path ships them in. A gif that could not be derived is left out (its
21120
- * reason already logged); the video, when asked for, still ships.
21317
+ * The recorded attachments, gif first and video second — the order the ring
21318
+ * path ships them in. A gif that could not be derived is left out (its
21319
+ * reason already logged); the video, when asked for, still ships. A derive
21320
+ * is not started once the bound has passed: nobody would wait for it.
21121
21321
  */
21122
- async recordedAttachments(entry, wanted, mp4) {
21322
+ async recordedAttachments(entry, wanted, mp4, shared) {
21123
21323
  const out = [];
21124
21324
  const derive = this.deps.deriveGifFromClip;
21125
21325
  if (wanted.includes("gif") && derive !== void 0) {
21126
- const gif = await derive({
21326
+ if (shared.gif === null && this.now() < shared.deadlineMs) shared.gif = derive({
21127
21327
  deviceId: entry.payload.subject.deviceId,
21128
21328
  mp4,
21129
21329
  ...entry.payload.mediaClipSpeed !== void 0 ? { videoSpeed: entry.payload.mediaClipSpeed } : {},
21130
21330
  ruleId: entry.ruleId,
21131
21331
  recordId: entry.recordId
21132
21332
  });
21133
- if (gif.ok) out.push({
21333
+ const gif = shared.gif === null ? null : await shared.gif;
21334
+ if (gif !== null && gif.ok) out.push({
21134
21335
  mediaType: "gif",
21135
- bytes: gif.gif,
21336
+ bytes: copyBytes(gif.gif),
21136
21337
  mime: "image/gif",
21137
21338
  name: "event.gif"
21138
21339
  });
21139
21340
  }
21140
21341
  if (wanted.includes("video")) out.push({
21141
21342
  mediaType: "video",
21142
- bytes: mp4,
21343
+ bytes: copyBytes(mp4),
21143
21344
  mime: "video/mp4",
21144
21345
  name: "event.mp4"
21145
21346
  });
21146
21347
  return out;
21147
21348
  }
21148
21349
  /**
21149
- * Settle every recorded-clip follow-up this dispatcher has in flight.
21150
- *
21151
- * A TEST SEAM, and the honest kind: the follow-up is detached by design, so
21152
- * without this a test can only assert the absence of something that has not
21153
- * happened yet. Production never calls it — the outbox's `deliver` is done
21154
- * when the alert is out, which is the whole point.
21155
- */
21156
- async settleRecordedFootage() {
21157
- while (this.recordedFootage.size > 0) await Promise.all([...this.recordedFootage]);
21158
- }
21159
- /**
21160
21350
  * Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
21161
21351
  *
21162
21352
  * Runs for EVERY rule now, footage or not, and concurrently with the render:
@@ -35597,6 +35787,7 @@ var NotificationCenter = class NotificationCenter {
35597
35787
  async drainTick() {
35598
35788
  await this.snoozes.load();
35599
35789
  this.sweepGroups(this.now());
35790
+ this.dispatcher.sweepRecordedCuts(this.now());
35600
35791
  await this.outbox.drainOnce();
35601
35792
  await this.deliverDueDigests();
35602
35793
  this.drainTicks += 1;
@@ -39687,6 +39878,13 @@ var TapeFrameSchema = require_clip_model_registry.strictObject({
39687
39878
  h: require_clip_model_registry.number(),
39688
39879
  rootModelId: require_clip_model_registry.string().optional(),
39689
39880
  shed: require_clip_model_registry.literal(true).optional(),
39881
+ /**
39882
+ * The `package-detection` step RAN on this frame (`FrameResult.
39883
+ * packageLooked`, D687) — what makes an empty doormat on a tape an absence
39884
+ * rather than silence. Absent when it did not run. Added under v1: optional,
39885
+ * and a tape written before it simply never attests a look.
39886
+ */
39887
+ pl: require_clip_model_registry.literal(true).optional(),
39690
39888
  dets: require_clip_model_registry.array(TapeDetectionSchema),
39691
39889
  disc: require_clip_model_registry.array(TapeDiscardSchema),
39692
39890
  inj: require_clip_model_registry.array(require_clip_model_registry.string()),
@@ -52625,6 +52823,7 @@ function buildFrameRecord(input) {
52625
52823
  h: input.frame.height,
52626
52824
  ...input.frame.rootModelId !== void 0 ? { rootModelId: input.frame.rootModelId } : {},
52627
52825
  ...input.frame.shed === true ? { shed: true } : {},
52826
+ ...input.frame.packageLooked === true ? { pl: true } : {},
52628
52827
  dets,
52629
52828
  disc: tapeDiscards(input.frame.discarded),
52630
52829
  inj,
@@ -64356,15 +64555,6 @@ var EventMediaService = class {
64356
64555
  /** How often the render is polled while it runs. */
64357
64556
  var NC_RECORDED_CLIP_POLL_MS = 1e3;
64358
64557
  /**
64359
- * Hard bound on one notification clip render.
64360
- *
64361
- * Far below the timelapse renderer's 30 min, and deliberately: this is a
64362
- * ten-second window, and a render that has not finished in two minutes is
64363
- * queued behind something else entirely (renders are serialised one ffmpeg at
64364
- * a time). Waiting longer would hold a follow-up nobody is still waiting for.
64365
- */
64366
- var NC_RECORDED_CLIP_TIMEOUT_MS = 12e4;
64367
- /**
64368
64558
  * How long the recorder keeps the file we are about to read and delete.
64369
64559
  *
64370
64560
  * `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
@@ -64429,6 +64619,20 @@ async function cutRecordedClip(ports, req) {
64429
64619
  reason: "not-recorded"
64430
64620
  };
64431
64621
  }
64622
+ if (ports.now() >= req.deadlineMs) {
64623
+ ports.logger.info("recorded clip: the bound was spent before the render", {
64624
+ tags,
64625
+ meta: {
64626
+ ...common,
64627
+ reason: "timed-out",
64628
+ deadlineMs: req.deadlineMs
64629
+ }
64630
+ });
64631
+ return {
64632
+ ok: false,
64633
+ reason: "timed-out"
64634
+ };
64635
+ }
64432
64636
  const pick = pickProfile(availability.ranges, {
64433
64637
  startMs: window.fromMs,
64434
64638
  endMs: window.toMs
@@ -64479,27 +64683,25 @@ async function cutRecordedClip(ports, req) {
64479
64683
  };
64480
64684
  }
64481
64685
  const pollMs = ports.pollMs ?? NC_RECORDED_CLIP_POLL_MS;
64482
- const timeoutMs = ports.timeoutMs ?? NC_RECORDED_CLIP_TIMEOUT_MS;
64483
- const deadline = ports.now() + timeoutMs;
64484
64686
  let state = record.state;
64485
64687
  let failure = record.error;
64486
64688
  while (state === "queued" || state === "rendering") {
64487
- if (ports.now() >= deadline) {
64689
+ if (ports.now() >= req.deadlineMs) {
64488
64690
  ports.logger.warn("recorded clip: the render did not finish inside its bound", {
64489
64691
  tags,
64490
64692
  meta: {
64491
64693
  ...common,
64492
- cause: "render-timeout",
64694
+ reason: "timed-out",
64493
64695
  exportId: record.id,
64494
64696
  profile: pick.profile,
64495
- timeoutMs,
64697
+ deadlineMs: req.deadlineMs,
64496
64698
  state
64497
64699
  }
64498
64700
  });
64499
- await discard(ports, record.id);
64701
+ await abandon(ports, record.id);
64500
64702
  return {
64501
64703
  ok: false,
64502
- reason: "cut-failed"
64704
+ reason: "timed-out"
64503
64705
  };
64504
64706
  }
64505
64707
  await ports.sleep(pollMs);
@@ -64595,6 +64797,19 @@ async function cutRecordedClip(ports, req) {
64595
64797
  }
64596
64798
  /** Best-effort removal of the temporary file. Its `maxLifeMs` is the backstop,
64597
64799
  * so a failure here costs disk for fifteen minutes and is worth one line. */
64800
+ /** A render nobody waits for any more: cancel it, then delete its row and file. */
64801
+ async function abandon(ports, exportId) {
64802
+ const cancel = ports.cancelExport;
64803
+ if (cancel !== void 0) try {
64804
+ await cancel({ exportId });
64805
+ } catch (err) {
64806
+ ports.logger.debug("recorded clip: could not cancel the abandoned export", { meta: {
64807
+ exportId,
64808
+ error: String(err)
64809
+ } });
64810
+ }
64811
+ await discard(ports, exportId);
64812
+ }
64598
64813
  async function discard(ports, exportId) {
64599
64814
  const del = ports.deleteExport;
64600
64815
  if (del === void 0) return;
@@ -64630,8 +64845,8 @@ function decodeBase64(base64) {
64630
64845
  * (`PACKAGE_STILLNESS_TTL_MS`). Once its footage is anchored on the pick-up
64631
64846
  * itself rather than on the conclusion (`media-anchor.ts`), the window is far
64632
64847
  * older than any clip ring (≤ 60 s, `NC_CLIP_RETENTION_MAX_SEC`), so the ring
64633
- * refuses it. A gif-only rule then had NOTHING: the recorded follow-up served
64634
- * only a rule that asked for a video. The honest fix for "the gif shows the
64848
+ * refuses it. A gif-only rule then had NOTHING: the recorded cut served only a
64849
+ * rule that asked for a video. The honest fix for "the gif shows the
64635
64850
  * empty doormat" is not "no gif" — it is the gif of the pick-up, from the
64636
64851
  * recordings.
64637
64852
  *
@@ -64702,7 +64917,7 @@ async function deriveRecordedGif(ports, req) {
64702
64917
  }
64703
64918
  const out = ran.ok ? ran.stdout : null;
64704
64919
  if (out === null || out.byteLength === 0) {
64705
- ports.logger.warn("recorded gif: the derive failed — the follow-up carries no gif", {
64920
+ ports.logger.warn("recorded gif: the derive failed — the notification carries no gif", {
64706
64921
  tags,
64707
64922
  meta: {
64708
64923
  ...common,
@@ -67105,6 +67320,22 @@ function messageOf(err) {
67105
67320
  function isDetailNotAttachedRefusal(err) {
67106
67321
  return messageOf(err).includes(DETAIL_NOT_ATTACHED_MARKER);
67107
67322
  }
67323
+ /**
67324
+ * The runner's TYPED handle-expired refusal (D688): the frame handle a dispatch
67325
+ * named is past the native lease age bound, and the registry will refuse it on
67326
+ * every later ask. TWIN of `addon-pipeline/src/pipeline-runner/detail-park.ts`
67327
+ * `DETAIL_HANDLE_EXPIRED_MARKER` — keep the two identical.
67328
+ *
67329
+ * Why the dispatcher cares: unlike not-attached this is about ONE handle, not
67330
+ * the session — the parked frame still serves — but the handle itself is
67331
+ * finished. Naming it again (next tick, or through the native-strict crop rung)
67332
+ * can only be refused again; on the live fleet that was ~7 refusals per handle.
67333
+ */
67334
+ var DETAIL_HANDLE_EXPIRED_MARKER = "runDetailSubtree-refused:handle-expired";
67335
+ /** Did this `runDetailSubtree` error carry the runner's handle-expired marker? */
67336
+ function isDetailHandleExpiredRefusal(err) {
67337
+ return messageOf(err).includes(DETAIL_HANDLE_EXPIRED_MARKER);
67338
+ }
67108
67339
  //#endregion
67109
67340
  //#region src/pipeline-analytics/detail-scheduler.ts
67110
67341
  /** Default backoff/period when a step's cadence omits `minIntervalMs`. */
@@ -67561,6 +67792,7 @@ var TrackDetailDispatcher = class {
67561
67792
  className: frame.className
67562
67793
  };
67563
67794
  const steps = composeDetailSteps(req, (trackId) => this.deps.hasTrackLabel?.(trackId) ?? false);
67795
+ let handleRefused = false;
67564
67796
  if (frame.frameHandle !== void 0) try {
67565
67797
  const primary = await this.deps.runDetailSubtree({
67566
67798
  deviceId,
@@ -67574,7 +67806,10 @@ var TrackDetailDispatcher = class {
67574
67806
  this.warnNotAttachedThrottled(deviceId, dev, req);
67575
67807
  return null;
67576
67808
  }
67577
- this.deps.logger.debug("detail primary call failed — trying frame fallback", {
67809
+ if (isDetailHandleExpiredRefusal(err)) {
67810
+ handleRefused = true;
67811
+ this.retireRefusedHandle(deviceId, dev, req, frame);
67812
+ } else this.deps.logger.debug("detail primary call failed — trying frame fallback", {
67578
67813
  tags: { deviceId },
67579
67814
  meta: {
67580
67815
  trackId: req.trackId,
@@ -67624,7 +67859,7 @@ var TrackDetailDispatcher = class {
67624
67859
  }
67625
67860
  });
67626
67861
  }
67627
- if (this.deps.captureCropBase64 !== void 0) try {
67862
+ if (this.deps.captureCropBase64 !== void 0 && frame.frameHandle !== void 0 && !handleRefused) try {
67628
67863
  const cropJpeg = await this.deps.captureCropBase64(frame);
67629
67864
  if (cropJpeg !== null) {
67630
67865
  const retry = await this.deps.runDetailSubtree({
@@ -67653,6 +67888,35 @@ var TrackDetailDispatcher = class {
67653
67888
  return null;
67654
67889
  }
67655
67890
  /**
67891
+ * The runner refused `frame`'s handle as past the lease age bound (D688).
67892
+ * That verdict is final for the handle, so the track's stored association
67893
+ * loses it: later dispatches skip rung 1 and the native-strict crop rung
67894
+ * (both would name the same handle and be refused again — ~7 times per
67895
+ * handle on the live fleet) and go straight to the parked frame, until a
67896
+ * newly OBSERVED frame brings a fresh handle via {@link onFrame}.
67897
+ *
67898
+ * Only the refused handle is dropped: if a newer frame replaced it while the
67899
+ * dispatch was in flight, that one stands. The line is therefore emitted once
67900
+ * per (track, handle) — a concurrent dispatch refused on the same handle
67901
+ * finds it already gone and says nothing.
67902
+ */
67903
+ retireRefusedHandle(deviceId, dev, req, frame) {
67904
+ const current = dev.tracks.get(req.trackId);
67905
+ if (current === void 0 || current.frameHandle !== frame.frameHandle) return;
67906
+ const { frameHandle: refused, ...withoutHandle } = current;
67907
+ dev.tracks.set(req.trackId, withoutHandle);
67908
+ this.deps.logger.info("detail dispatch: frame handle refused as expired — retired for this track, parked frame only until a fresh observation", {
67909
+ tags: { deviceId },
67910
+ meta: {
67911
+ trackId: req.trackId,
67912
+ stepId: req.stepId,
67913
+ reason: "handle-expired",
67914
+ handle: refused === void 0 ? null : `${refused.shmId}#${refused.slot}#${refused.seq}`,
67915
+ parkedFrameRung: this.deps.captureParkedFrame !== void 0
67916
+ }
67917
+ });
67918
+ }
67919
+ /**
67656
67920
  * The typed refusal is EXPECTED for a track that outlives the runner's
67657
67921
  * detail park grace (a closing track's last dispatches), so it logs its own
67658
67922
  * line — the reason is knowable, unlike the every-rung-missed warn — and
@@ -73940,6 +74204,15 @@ var PackageDropSettingsSchema = require_clip_model_registry.object({
73940
74204
  /** Emit `package-picked-up` when a delivered package's entry departs. */
73941
74205
  packageDropPickupEnabled: require_clip_model_registry.boolean().default(true),
73942
74206
  /**
74207
+ * Conclude a pick-up EARLY on positive evidence (D687): three looks of the
74208
+ * package step spanning ≥ 10 s that found the doormat empty and unoccluded,
74209
+ * with somebody in the drop-off zone around the disappearance — instead of
74210
+ * waiting out the 300 s TTL. OFF by default: the one pick-up the stored data
74211
+ * could replay (615, 2026-09-30) is no sample to judge the false-departure
74212
+ * risk by. Silence always keeps the TTL.
74213
+ */
74214
+ packageDropEarlyPickupEnabled: require_clip_model_registry.boolean().default(false),
74215
+ /**
73943
74216
  * A parcel needs a deliverer: a stillness candidate only ever promotes to
73944
74217
  * `delivered` if a non-package track (person/vehicle/animal) was active IN
73945
74218
  * A CONFIGURED PACKAGE DROP-OFF ZONE within this many seconds BEFORE the
@@ -73978,6 +74251,7 @@ function resolvePackageDropSettings(raw) {
73978
74251
  packageDropMinBboxAreaFrac: pick("packageDropMinBboxAreaFrac"),
73979
74252
  packageDropMaxBboxAreaFrac: pick("packageDropMaxBboxAreaFrac"),
73980
74253
  packageDropPickupEnabled: pick("packageDropPickupEnabled"),
74254
+ packageDropEarlyPickupEnabled: pick("packageDropEarlyPickupEnabled"),
73981
74255
  packageDropActivityWindowSec: pick("packageDropActivityWindowSec"),
73982
74256
  packageDropClassFilter: pick("packageDropClassFilter")
73983
74257
  };
@@ -76311,11 +76585,129 @@ function trackIsStill(input) {
76311
76585
  spanFrac: metrics.pathSpanFrac
76312
76586
  };
76313
76587
  }
76588
+ //#endregion
76589
+ //#region src/pipeline-analytics/pipeline/package/package-stillness-tracker.ts
76590
+ /**
76591
+ * PackageStillnessTracker — the DEDICATED promotion path for packages.
76592
+ *
76593
+ * ## Why this exists (the arithmetic)
76594
+ *
76595
+ * The generic `StationaryObjectRegistry` used to promote a track that recorded
76596
+ * 4 positions inside a 30 s window — a criterion built for a car parking in
76597
+ * device 617's lot and observed on EVERY frame. (It has since moved to repeated
76598
+ * evidence across many sightings, `stationary-evidence.ts`; the impossibility
76599
+ * below is what forced this path to exist, and the class SKIP in the generic
76600
+ * loop is what keeps it separate now.)
76601
+ *
76602
+ * `package-detection` is not observed on every frame. It is a root step gated
76603
+ * by the `MotionSettleGovernor`, which runs it on a motion-settle edge or, in
76604
+ * the worst case, on a 45 s safety poll. Four positions in 30 s at one
76605
+ * position per 45 s is not a tuning problem, it is an impossibility — and it is
76606
+ * why `PackageDropDetector` never fired once in its life (0 `pa-pkg-*` rows on
76607
+ * every camera, ever, verified 2026-08-13).
76608
+ *
76609
+ * Measured on device 615 (the only camera that has ever produced a `package`
76610
+ * box): three separate tracks at 02:01:08, 02:03:14 and 02:05:29, each alive
76611
+ * for 4–5 s, each carrying TWO frames ~1.2 s apart, each on a NEW trackId. The
76612
+ * facts to accommodate are therefore:
76613
+ *
76614
+ * - the track DIES between governor rounds → re-association is by POSITION
76615
+ * (IoU), never by trackId;
76616
+ * - a round can be minutes apart → there is NO look-back window, only a count
76617
+ * of distinct FRAMES plus the operator's optional dwell;
76618
+ * - a burst of frames inside one settle session COUNTS: three frames ~400 ms
76619
+ * apart is three hits, which is enough to confirm the box is still there
76620
+ * without waiting for a second governor round after the courier left.
76621
+ * Device 615 2026-08-24 13:40: a real parcel expired at rounds=1 /
76622
+ * dwell 1458 ms vs 15 s — the 5 s round-gap plus 15 s dwell were the miss.
76623
+ *
76624
+ * The generic registry is untouched: a parked car still promotes exactly as
76625
+ * before. `scripts/check-package-promotion-satisfiable.ts` fails the build if
76626
+ * anyone breaks the relation between these constants and the governor's poll.
76627
+ *
76628
+ * ## Contract
76629
+ *
76630
+ * Pure logic with an injected clock and id factory — `observe` takes the frame
76631
+ * timestamp and never reads `Date.now()`. It emits {@link StationaryEntry}
76632
+ * values in the exact shape `PackageDropDetector.onStationaryChange` already
76633
+ * consumes, so the detector is unchanged by this path: `promoted` feeds
76634
+ * `appeared`, `departed` feeds `departed`, and the entry id is stable across
76635
+ * both so a pick-up pairs to its delivery.
76636
+ *
76637
+ * Expiry is judged on the frame clock inside `observe`, which is called on
76638
+ * every pipeline frame (an empty `sightings` list when the package step did
76639
+ * not run or found nothing). A camera producing no frames concludes nothing
76640
+ * there — and that is load-bearing, not an accident: the FIRST frame after a
76641
+ * silence runs the governor's poll (it is always due after a TTL of silence)
76642
+ * and re-sights a parcel that is still there before the expiry is judged.
76643
+ *
76644
+ * `sweep` judges the same TTL off a timer, so a pick-up is not held until the
76645
+ * next frame happens to arrive (615, 2026-09-30: 29 s). It concludes ONLY a
76646
+ * promoted parcel with an ABSENCE READ since its last sighting — a frame on
76647
+ * which the package step LOOKED (`looked`) and found no box at that place,
76648
+ * with no person or vehicle box over it (`occluders`). A parcel nobody looked
76649
+ * for since is left for the next frame's look. Silence is not evidence.
76650
+ */
76651
+ /**
76652
+ * Distinct FRAMES a box must be re-seen on before it is a parked package.
76653
+ * Three is the same bar as tracker `minHits`: one frame is a flicker, two
76654
+ * can still be a passer-by's bag in a single glance, three is "it stayed".
76655
+ * Counted per sighting, not per 5 s governor round — a settle burst of
76656
+ * three frames in ~1 s is a confirmation, not one collapsed round.
76657
+ */
76658
+ var PACKAGE_STILLNESS_MIN_HITS = 3;
76659
+ /**
76660
+ * Minimum spacing between two observations for them to count as separate
76661
+ * pick-up-slack windows. Must be ≤ the governor's poll interval. No longer
76662
+ * used for promotion (hits count every frame).
76663
+ */
76664
+ var PACKAGE_STILLNESS_ROUND_GAP_MS = 5e3;
76665
+ /**
76666
+ * How long a candidate survives without being re-associated, in OBSERVED time.
76667
+ * Must comfortably outlast several governor polls — the real inter-round gap
76668
+ * measured on 615 was ~2 minutes, not the nominal 45 s, because a
76669
+ * session-dispatch camera decodes nothing between motion sessions.
76670
+ */
76671
+ var PACKAGE_STILLNESS_TTL_MS = 5 * 6e4;
76672
+ /**
76673
+ * Association floor. Deliberately looser than the registry's `SUPPRESS_IOU`
76674
+ * (0.6): a box re-detected minutes later by a fresh track jitters more than one
76675
+ * confirmed frame-to-frame. Measured jitter on 615's doormat across 4 minutes
76676
+ * was IoU ≈ 0.98, so 0.3 is generous without being meaningless.
76677
+ */
76678
+ var PACKAGE_STILLNESS_ASSOC_IOU = .3;
76679
+ /**
76680
+ * Two absence reads closer than this are ONE look, not two. A settle burst
76681
+ * runs the package step 5 s apart and a poll 45 s apart, so a real second
76682
+ * look is always further away; two frames of one moment (a re-dispatch, a
76683
+ * recovery pass) must not count as agreement.
76684
+ */
76685
+ var PACKAGE_ABSENCE_READ_MIN_GAP_MS = 2e3;
76686
+ /**
76687
+ * Early pick-up (D687, per-camera opt-in): absence reads a promoted parcel
76688
+ * needs before it may depart ahead of its TTL. Three is the same bar as
76689
+ * promotion — one empty look is a missed detection, two can be one wobble of
76690
+ * the detector, three agreeing is "it is gone" (D49: a read that destroys
76691
+ * work needs another to agree).
76692
+ */
76693
+ var PACKAGE_EARLY_PICKUP_MIN_READS = 3;
76694
+ /**
76695
+ * …and the span those reads must cover, first to last. A settle burst looks
76696
+ * every 5 s, so 10 s is three burst runs: the parcel is still gone after the
76697
+ * collector has had time to put it back down.
76698
+ */
76699
+ var PACKAGE_EARLY_PICKUP_MIN_SPAN_MS = 1e4;
76700
+ /**
76701
+ * Absence reads the frame-free SWEEP needs before it may conclude a TTL
76702
+ * (fix round 1, D49): one read can be stale by minutes, or a detector
76703
+ * wobble; a second must agree, and the most recent look must be one of them.
76704
+ */
76705
+ var PACKAGE_SWEEP_MIN_READS = 2;
76314
76706
  var DEFAULT_PACKAGE_STILLNESS_CONFIG = {
76315
- minHits: 3,
76316
- roundGapMs: 5e3,
76317
- ttlMs: 5 * 6e4,
76318
- assocIou: .3
76707
+ minHits: PACKAGE_STILLNESS_MIN_HITS,
76708
+ roundGapMs: PACKAGE_STILLNESS_ROUND_GAP_MS,
76709
+ ttlMs: PACKAGE_STILLNESS_TTL_MS,
76710
+ assocIou: PACKAGE_STILLNESS_ASSOC_IOU
76319
76711
  };
76320
76712
  var PackageStillnessTracker = class {
76321
76713
  config;
@@ -76358,37 +76750,117 @@ var PackageStillnessTracker = class {
76358
76750
  next[index] = parked;
76359
76751
  promoted.push(toEntry(parked));
76360
76752
  }
76753
+ const present = input.rawPackageBoxes !== void 0 ? withRawPresence(next, consumed, input.rawPackageBoxes, input.timestamp, this.config.assocIou) : next;
76754
+ const looked = input.looked === true && input.rawPackageBoxes !== void 0 ? withLook(present, consumed, input, input.rawPackageBoxes, this.config.assocIou) : present;
76755
+ const windowMs = input.activityWindowMs ?? 0;
76756
+ const early = /* @__PURE__ */ new Set();
76757
+ const unbacked = [];
76758
+ const read = looked.map((c) => {
76759
+ if (input.earlyPickup !== true || c.earlyUnbackedReported || !hasEarlyEvidence(c)) return c;
76760
+ const reason = this.earlyUnbackedReason(c, windowMs, input.lastActivityAt);
76761
+ if (reason === null) return c;
76762
+ unbacked.push({
76763
+ id: c.id,
76764
+ reason
76765
+ });
76766
+ return {
76767
+ ...c,
76768
+ earlyUnbackedReported: true
76769
+ };
76770
+ });
76771
+ return {
76772
+ promoted,
76773
+ ...this.expire({
76774
+ deviceId: input.deviceId,
76775
+ candidates: read,
76776
+ isExpired: (c) => {
76777
+ if (input.timestamp - c.lastSeenAt > this.config.ttlMs) return true;
76778
+ if (input.earlyPickup !== true || !hasEarlyEvidence(c)) return false;
76779
+ if (this.earlyUnbackedReason(c, windowMs, input.lastActivityAt) !== null) return false;
76780
+ early.add(c.id);
76781
+ return true;
76782
+ },
76783
+ activityWindowMs: windowMs,
76784
+ ...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
76785
+ }),
76786
+ earlyDepartureIds: [...early],
76787
+ earlyEvidenceUnbacked: unbacked
76788
+ };
76789
+ }
76790
+ /** Why complete early evidence may NOT conclude, or null when it may. */
76791
+ earlyUnbackedReason(c, activityWindowMs, lastActivityAt) {
76792
+ if (activityWindowMs <= 0) return "no-activity-window";
76793
+ if (lastActivityAt === void 0 || lastActivityAt < c.lastSeenAt - this.config.roundGapMs) return "nobody-around";
76794
+ return null;
76795
+ }
76796
+ /**
76797
+ * Judge the TTL without a frame. Concludes only a PROMOTED parcel whose TTL
76798
+ * ran out AND that carries at least one absence read since its last
76799
+ * sighting; every other candidate is left for the next frame, whose look
76800
+ * decides it. Never promotes. Immutable, never throws.
76801
+ */
76802
+ sweep(input) {
76803
+ const candidates = this.byDevice.get(input.deviceId) ?? [];
76804
+ return {
76805
+ promoted: [],
76806
+ ...this.expire({
76807
+ deviceId: input.deviceId,
76808
+ candidates,
76809
+ isExpired: (c) => c.promotedAt !== null && c.absenceReads.length >= PACKAGE_SWEEP_MIN_READS && c.lastLook === "absent" && input.timestamp - c.lastSeenAt > this.config.ttlMs,
76810
+ activityWindowMs: input.activityWindowMs ?? 0,
76811
+ ...input.lastActivityAt !== void 0 ? { lastActivityAt: input.lastActivityAt } : {}
76812
+ }),
76813
+ earlyDepartureIds: [],
76814
+ earlyEvidenceUnbacked: []
76815
+ };
76816
+ }
76817
+ /** The devices holding a candidate — what a sweep has to visit. */
76818
+ devicesWithCandidates() {
76819
+ return [...this.byDevice.keys()];
76820
+ }
76821
+ /**
76822
+ * Expiry. A promoted candidate that ages out is a departure (it feeds the
76823
+ * pick-up pairing) — but only a departure with somebody around the
76824
+ * disappearance is a PICK-UP; an unwitnessed vanish is the detector losing
76825
+ * the box. An unpromoted candidate that ages out is reported with its
76826
+ * counters: it expired in silence once (2026-08-18 13:41, a true parcel)
76827
+ * and that silence cost the diagnosis a day.
76828
+ */
76829
+ expire(input) {
76361
76830
  const departed = [];
76362
76831
  const departedWithoutActivity = [];
76363
76832
  const expired = [];
76364
76833
  const kept = [];
76365
- const windowMs = input.activityWindowMs ?? 0;
76366
- for (const c of next) {
76367
- if (input.timestamp - c.lastSeenAt > this.config.ttlMs) {
76368
- if (c.promotedAt !== null) if (windowMs <= 0 || input.lastActivityAt !== void 0 && input.lastActivityAt >= c.lastSeenAt - this.config.roundGapMs) departed.push(toEntry(c));
76369
- else departedWithoutActivity.push(toEntry(c));
76370
- else expired.push({
76371
- id: c.id,
76372
- className: c.className,
76373
- bbox: { ...c.bbox },
76374
- hits: c.hits,
76375
- firstSeenAt: c.firstSeenAt,
76376
- lastSeenAt: c.lastSeenAt,
76377
- activityBackedAtOpen: c.activityBackedAtOpen
76378
- });
76834
+ for (const c of input.candidates) {
76835
+ if (!input.isExpired(c)) {
76836
+ kept.push(c);
76837
+ continue;
76838
+ }
76839
+ if (c.promotedAt === null) {
76840
+ expired.push(expiredReport(c));
76379
76841
  continue;
76380
76842
  }
76381
- kept.push(c);
76843
+ if (this.pickupBacked(c, input.activityWindowMs, input.lastActivityAt)) departed.push(toEntry(c));
76844
+ else departedWithoutActivity.push(toEntry(c));
76382
76845
  }
76383
76846
  if (kept.length === 0) this.byDevice.delete(input.deviceId);
76384
76847
  else this.byDevice.set(input.deviceId, kept);
76385
76848
  return {
76386
- promoted,
76387
76849
  departed,
76388
76850
  departedWithoutActivity,
76389
76851
  expired
76390
76852
  };
76391
76853
  }
76854
+ /**
76855
+ * A pick-up needs somebody around the DISAPPEARANCE: activity at or after
76856
+ * the box's last sighting (one round-gap of slack — the taker is tracked in
76857
+ * the same frames that stop showing the box). The DELIVERER's activity
76858
+ * minutes earlier must not count, or every short-dwell vanish would read as
76859
+ * a pick-up. A window of 0 disables the gate.
76860
+ */
76861
+ pickupBacked(c, activityWindowMs, lastActivityAt) {
76862
+ return activityWindowMs <= 0 || lastActivityAt !== void 0 && lastActivityAt >= c.lastSeenAt - this.config.roundGapMs;
76863
+ }
76392
76864
  /** Drop a device's candidates (unbind / step disabled). */
76393
76865
  forgetDevice(deviceId) {
76394
76866
  this.byDevice.delete(deviceId);
@@ -76431,6 +76903,9 @@ var PackageStillnessTracker = class {
76431
76903
  lastSeenAt: input.timestamp,
76432
76904
  hits: 1,
76433
76905
  promotedAt: null,
76906
+ absenceReads: [],
76907
+ lastLook: null,
76908
+ earlyUnbackedReported: false,
76434
76909
  activityBackedAtOpen,
76435
76910
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
76436
76911
  ...s.label !== void 0 ? { label: s.label } : {},
@@ -76449,12 +76924,91 @@ function confirm(prev, input, s) {
76449
76924
  frameHeight: input.frameHeight,
76450
76925
  lastSeenAt: input.timestamp,
76451
76926
  hits: prev.hits + 1,
76927
+ absenceReads: [],
76928
+ lastLook: "present",
76929
+ earlyUnbackedReported: false,
76452
76930
  ...s.trackId !== void 0 ? { sourceTrackId: s.trackId } : {},
76453
76931
  ...s.label !== void 0 ? { label: s.label } : {},
76454
76932
  ...s.confidence !== void 0 ? { confidence: s.confidence } : {},
76455
76933
  ...s.keyFrameMediaId !== void 0 ? { keyFrameMediaId: s.keyFrameMediaId } : {}
76456
76934
  };
76457
76935
  }
76936
+ /**
76937
+ * A promoted parcel the tracker did not re-sight but the package step's RAW
76938
+ * output shows at its place (IoU ≥ the association floor): the detector saw
76939
+ * it, whatever the tracker did with the box. It is SEEN — `lastSeenAt` moves,
76940
+ * so the TTL ages from here and the pick-up instant is this last presence —
76941
+ * and every absence read so far is contradicted.
76942
+ */
76943
+ function withRawPresence(candidates, sighted, raw, timestamp, assocIou) {
76944
+ return candidates.map((c) => {
76945
+ if (c.promotedAt === null || sighted.has(c.id)) return c;
76946
+ if (!raw.some((b) => iou(b, c.bbox) >= assocIou)) return c;
76947
+ return {
76948
+ ...c,
76949
+ lastSeenAt: timestamp,
76950
+ absenceReads: [],
76951
+ lastLook: "present"
76952
+ };
76953
+ });
76954
+ }
76955
+ /**
76956
+ * Fold this frame's LOOK into every promoted candidate it did not re-sight.
76957
+ *
76958
+ * Judged against what the package step RETURNED (`raw`), never against the
76959
+ * tracker's sightings: a raw box at the place is a PRESENCE even when the
76960
+ * tracker dropped it, and it contradicts every absence read so far (D49). A
76961
+ * look whose view of the place was covered by a person or vehicle saw the
76962
+ * occluder, and says nothing — but it is the latest look, so the sweep waits
76963
+ * for a later one.
76964
+ */
76965
+ function withLook(candidates, sighted, input, raw, assocIou) {
76966
+ const occluders = input.occluders ?? [];
76967
+ return candidates.map((c) => {
76968
+ if (c.promotedAt === null || sighted.has(c.id)) return c;
76969
+ if (raw.some((b) => iou(b, c.bbox) >= assocIou)) return {
76970
+ ...c,
76971
+ absenceReads: [],
76972
+ lastLook: "present"
76973
+ };
76974
+ if (occluders.some((o) => boxesOverlap(o, c.bbox))) return {
76975
+ ...c,
76976
+ lastLook: "occluded"
76977
+ };
76978
+ const last = c.absenceReads[c.absenceReads.length - 1];
76979
+ if (last !== void 0 && input.timestamp - last < PACKAGE_ABSENCE_READ_MIN_GAP_MS) return {
76980
+ ...c,
76981
+ lastLook: "absent"
76982
+ };
76983
+ return {
76984
+ ...c,
76985
+ absenceReads: [...c.absenceReads, input.timestamp],
76986
+ lastLook: "absent"
76987
+ };
76988
+ });
76989
+ }
76990
+ /** Enough agreeing absence reads, over enough time, to call a parcel gone
76991
+ * ahead of its TTL (D687). */
76992
+ function hasEarlyEvidence(c) {
76993
+ if (c.promotedAt === null) return false;
76994
+ const first = c.absenceReads[0];
76995
+ const last = c.absenceReads[c.absenceReads.length - 1];
76996
+ return c.absenceReads.length >= PACKAGE_EARLY_PICKUP_MIN_READS && first !== void 0 && last !== void 0 && last - first >= PACKAGE_EARLY_PICKUP_MIN_SPAN_MS;
76997
+ }
76998
+ function boxesOverlap(a, b) {
76999
+ 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);
77000
+ }
77001
+ function expiredReport(c) {
77002
+ return {
77003
+ id: c.id,
77004
+ className: c.className,
77005
+ bbox: { ...c.bbox },
77006
+ hits: c.hits,
77007
+ firstSeenAt: c.firstSeenAt,
77008
+ lastSeenAt: c.lastSeenAt,
77009
+ activityBackedAtOpen: c.activityBackedAtOpen
77010
+ };
77011
+ }
76458
77012
  function toEntry(c) {
76459
77013
  return {
76460
77014
  id: c.id,
@@ -76473,6 +77027,60 @@ function toEntry(c) {
76473
77027
  };
76474
77028
  }
76475
77029
  //#endregion
77030
+ //#region src/pipeline-analytics/pipeline/package/package-look-evidence.ts
77031
+ function packageLookEvidence(input) {
77032
+ const raw = [];
77033
+ for (const d of input.frame.detections) {
77034
+ if (d.kind !== "first-level" || !input.packageClasses.has(d.macroClass)) continue;
77035
+ raw.push({
77036
+ x: d.bbox.x,
77037
+ y: d.bbox.y,
77038
+ w: d.bbox.width,
77039
+ h: d.bbox.height
77040
+ });
77041
+ }
77042
+ for (const d of input.frame.discarded ?? []) {
77043
+ if (!input.packageClasses.has(d.macroClass)) continue;
77044
+ const [x1, y1, x2, y2] = d.bbox;
77045
+ raw.push({
77046
+ x: x1,
77047
+ y: y1,
77048
+ w: x2 - x1,
77049
+ h: y2 - y1
77050
+ });
77051
+ }
77052
+ return {
77053
+ looked: input.frame.packageLooked === true,
77054
+ occluders: input.tracked.filter((t) => !input.packageClasses.has(t.className)).map((t) => ({
77055
+ x: t.bbox.x,
77056
+ y: t.bbox.y,
77057
+ w: t.bbox.w,
77058
+ h: t.bbox.h
77059
+ })),
77060
+ rawPackageBoxes: raw,
77061
+ earlyPickup: input.earlyPickupEnabled
77062
+ };
77063
+ }
77064
+ //#endregion
77065
+ //#region src/pipeline-analytics/pipeline/package/package-stillness-sweep.ts
77066
+ function sweepPackageStillness(deps) {
77067
+ for (const deviceId of deps.tracker.devicesWithCandidates()) try {
77068
+ sweepOne(deps, deviceId);
77069
+ } catch (err) {
77070
+ deps.onDeviceError(deviceId, err);
77071
+ }
77072
+ }
77073
+ function sweepOne(deps, deviceId) {
77074
+ const lastActivityAt = deps.lastActivityAtFor(deviceId);
77075
+ const outcome = deps.tracker.sweep({
77076
+ deviceId,
77077
+ timestamp: deps.now,
77078
+ activityWindowMs: deps.activityWindowMsFor(deviceId),
77079
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
77080
+ });
77081
+ if (outcome.departed.length > 0 || outcome.departedWithoutActivity.length > 0 || outcome.expired.length > 0) deps.onOutcome(deviceId, outcome, deps.now);
77082
+ }
77083
+ //#endregion
76476
77084
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
76477
77085
  /**
76478
77086
  * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
@@ -90520,6 +91128,13 @@ function buildGlobalSettingsSchema(cap) {
90520
91128
  description: "Also emit a package-picked-up event when the delivered package leaves.",
90521
91129
  default: PACKAGE_DROP_DEFAULTS.packageDropPickupEnabled
90522
91130
  },
91131
+ {
91132
+ type: "boolean",
91133
+ key: "packageDropEarlyPickupEnabled",
91134
+ label: "Early pick-up",
91135
+ 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.",
91136
+ default: PACKAGE_DROP_DEFAULTS.packageDropEarlyPickupEnabled
91137
+ },
90523
91138
  {
90524
91139
  type: "slider",
90525
91140
  key: "packageDropMinFrames",
@@ -96186,7 +96801,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
96186
96801
  createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
96187
96802
  getExport: (input) => api.recordingExport.getExport.query(input),
96188
96803
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
96189
- deleteExport: (input) => api.recordingExport.deleteExport.mutate(input)
96804
+ deleteExport: (input) => api.recordingExport.deleteExport.mutate(input),
96805
+ cancelExport: (input) => api.recordingExport.cancelExport.mutate(input)
96190
96806
  }, req),
96191
96807
  deriveGifFromClip: (req) => deriveRecordedGif(buildRecordedGifPorts({
96192
96808
  logger: logger.child("RecordedGif"),
@@ -97193,6 +97809,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
97193
97809
  startSweepTimers() {
97194
97810
  this.ttlSweepTimer = setInterval(() => {
97195
97811
  this.sweepExpiredTracks();
97812
+ this.runPackageStillnessSweep(Date.now());
97196
97813
  }, TTL_SWEEP_INTERVAL_MS);
97197
97814
  this.faceReembedAutoTimer = setInterval(() => {
97198
97815
  this.maybeAutoReembedFaces().then(() => this.faceAlignmentJobs?.maybeAutoRunPhotoPass());
@@ -98344,7 +98961,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
98344
98961
  settings: packageSettings,
98345
98962
  frameWidth: result.frameWidth,
98346
98963
  frameHeight: result.frameHeight,
98347
- timestamp: result.timestamp
98964
+ timestamp: result.timestamp,
98965
+ frame
98348
98966
  });
98349
98967
  if (this.detailDispatcher && detailSteps && detailSteps.length > 0) {
98350
98968
  const dispatcher = this.detailDispatcher;
@@ -101194,8 +101812,77 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
101194
101812
  minHits: input.settings.packageDropMinFrames,
101195
101813
  minDwellMs: input.settings.packageDropDwellSec * 1e3,
101196
101814
  activityWindowMs: input.settings.packageDropActivityWindowSec * 1e3,
101197
- ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
101815
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101816
+ ...packageLookEvidence({
101817
+ frame: input.frame,
101818
+ tracked: input.tracked,
101819
+ packageClasses: input.packageClasses,
101820
+ earlyPickupEnabled: input.settings.packageDropEarlyPickupEnabled
101821
+ })
101822
+ });
101823
+ this.bridgePackageStillness({
101824
+ deviceId: input.deviceId,
101825
+ outcome,
101826
+ timestamp: input.timestamp,
101827
+ settings: input.settings,
101828
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101829
+ via: "frame"
101830
+ });
101831
+ }
101832
+ /**
101833
+ * Judge the package TTL without a frame (F4) — on the existing TTL sweep
101834
+ * tick, for the devices holding a candidate. The tracker concludes only a
101835
+ * parcel the step looked for and did not find; see
101836
+ * `PackageStillnessTracker.sweep`.
101837
+ */
101838
+ /**
101839
+ * The tick's entry point: a throw here is an uncaught exception in a timer
101840
+ * callback, so it is caught and named (review M3). Per-device throws are
101841
+ * already caught inside the driver, so one camera cannot stop the others.
101842
+ */
101843
+ runPackageStillnessSweep(now) {
101844
+ try {
101845
+ this.sweepPackageStillness(now);
101846
+ } catch (err) {
101847
+ this.ctx.logger.warn("package stillness sweep threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
101848
+ }
101849
+ }
101850
+ sweepPackageStillness(now) {
101851
+ if (this.packageDropDetector === null) return;
101852
+ sweepPackageStillness({
101853
+ tracker: this.packageStillness,
101854
+ now,
101855
+ activityWindowMsFor: (deviceId) => this.packageDropSettingsFromCache(deviceId).packageDropActivityWindowSec * 1e3,
101856
+ lastActivityAtFor: (deviceId) => this.lastNonPackageActivityAt.get(deviceId),
101857
+ onOutcome: (deviceId, outcome, at) => {
101858
+ const lastActivityAt = this.lastNonPackageActivityAt.get(deviceId);
101859
+ this.bridgePackageStillness({
101860
+ deviceId,
101861
+ outcome,
101862
+ timestamp: at,
101863
+ settings: this.packageDropSettingsFromCache(deviceId),
101864
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101865
+ via: "sweep"
101866
+ });
101867
+ },
101868
+ onDeviceError: (deviceId, err) => {
101869
+ this.ctx.logger.warn("package stillness sweep threw", {
101870
+ tags: { deviceId },
101871
+ meta: { error: err instanceof Error ? err.message : String(err) }
101872
+ });
101873
+ }
101198
101874
  });
101875
+ }
101876
+ /**
101877
+ * Bridge one stillness outcome into `PackageDropDetector` — the SAME
101878
+ * `onStationaryChange` contract the generic registry uses. A frame's outcome
101879
+ * and a sweep's take this one path, so a pick-up concluded without a frame
101880
+ * is logged, paired and notified exactly like one concluded on a frame.
101881
+ */
101882
+ bridgePackageStillness(input) {
101883
+ if (this.packageDropDetector === null) return;
101884
+ const tags = { deviceId: input.deviceId };
101885
+ const { outcome, lastActivityAt } = input;
101199
101886
  for (const entry of outcome.promoted) {
101200
101887
  const centroid = {
101201
101888
  x: entry.bbox.x + entry.bbox.w / 2,
@@ -101245,7 +101932,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
101245
101932
  meta: {
101246
101933
  entryId: entry.id,
101247
101934
  className: entry.className,
101248
- unseenForMs: input.timestamp - entry.lastConfirmedAt
101935
+ unseenForMs: input.timestamp - entry.lastConfirmedAt,
101936
+ via: input.via,
101937
+ rule: outcome.earlyDepartureIds.includes(entry.id) ? "early" : "ttl"
101249
101938
  }
101250
101939
  });
101251
101940
  this.packageBackground?.noteRealPickup({
@@ -101287,7 +101976,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
101287
101976
  className: entry.className,
101288
101977
  bbox: entry.bbox,
101289
101978
  unseenForMs: input.timestamp - entry.lastConfirmedAt,
101290
- ...lastActivityAt !== void 0 ? { lastActivityAt } : {}
101979
+ ...lastActivityAt !== void 0 ? { lastActivityAt } : {},
101980
+ via: input.via
101291
101981
  }
101292
101982
  });
101293
101983
  this.packageBackground?.noteGhostCycle({
@@ -101306,6 +101996,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
101306
101996
  });
101307
101997
  }).catch(() => void 0);
101308
101998
  }
101999
+ for (const u of outcome.earlyEvidenceUnbacked) this.ctx.logger.info("early pick-up evidence complete — nobody around, left to the TTL", {
102000
+ tags,
102001
+ meta: {
102002
+ entryId: u.id,
102003
+ reason: u.reason,
102004
+ via: input.via
102005
+ }
102006
+ });
101309
102007
  for (const ex of outcome.expired) {
101310
102008
  const observedDwellMs = ex.lastSeenAt - ex.firstSeenAt;
101311
102009
  const requiredDwellMs = input.settings.packageDropDwellSec * 1e3;