@camstack/addon-post-analysis 1.2.273 → 1.2.275

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.
@@ -1,4 +1,4 @@
1
- import { $ as TrackSourceSchema, $t as literal, A as NcRuleInputSchema, At as resolveLocationMode, B as OpsLogEntrySchema, Bt as CamProfileSchema, C as MediaFileKindEnum, Ct as occupancyScope, Dt as plateGalleryCapability, E as NC_DEFAULT_SNOOZE_MINUTES, Et as pipelineAnalyticsCapability, F as NcSnoozeInputSchema, Ft as systemEventFilterApplies, G as SCENE_DEFAULT_ANCHOR_THRESHOLD, Gt as hydrateSchema, H as RECORDING_EXPORT_MAX_READ_BYTES, Ht as createEvent, I as NcSnoozeSchema, It as vectorDimFromBase64, J as SceneMonitorSchema, Jt as sleep$1, K as SCENE_DEFAULT_UNCOVERED_POLICY, Kt as isDeviceScopedCap, L as NcSnoozeSuppressedSchema, Lt as zoneAnalyticsCapability, M as NcRuleSchema, Mt as sceneMonitorCapability, N as NcRuleTargetSchema, Nt as storageOccupancyCapability, O as NC_TAXONOMY, Ot as readDeviceStateFrom, P as NcScheduleSchema, Pt as subKindsOf, Q as TimelapseRuleSchema, Qt as discriminatedUnion, R as NcSystemEventKindSchema, Rt as errMsg$1, S as MOTION_CLOSE_AFTER_MS, St as notificationRulesCapability, T as NC_CONDITION_CATALOG, Tt as pickClusterStepModels, U as RetrainStatusSchema, Ut as customAction, Vt as DeviceType, Wt as defineCustomActions, X as TimelapseRuleInputSchema, Xt as array, Y as TIMELAPSE_DENSE_FLOOR_SEC, Yt as _enum, Z as TimelapseRulePatchSchema, Zt as boolean, _ as FailureCounters, _t as isMediaOwnerType, a as CLUSTER_MODEL_SCOPED_STEPS, an as unknown, at as buildEventKindDescriptor, b as MAX_BIRTH_DECISION_RECORDS, bt as kebabToCamel, dt as evictionPolicyOfLocation, en as number, et as addonWidgetsSourceCapability, f as DeclaredDevices, ft as faceGalleryCapability, g as FULL_IMAGE_BBOX, gt as isEventOwnerType, h as FIRST_LEVEL_MACRO_CLASSES, ht as isDetectionMacroClass, i as BirthDecisionRecordSchema, in as string, it as audioModeOf, j as NcRulePatchSchema, k as NcConditionDescriptorSchema, kt as readTimelapseGeneratedAt, lt as encodeVectorBase64, m as EVENT_OWNER_TYPES, n as ArchivedDebugNoteSchema, nn as partialRecord, nt as assertTimelapseCadences, o as COCO_TO_MACRO, on as EventCategory, ot as cosineSimilarity$1, p as EVENT_KIND_BY_CAP, pt as failureContributionCapability, q as SCENE_DIVERGED, qt as nodePin, r as BaseDevice, rn as record, rt as audioMetricsCapability, s as DEFAULT_EVENT_COLOR, st as deriveRecordingMode, t as AUDIO_MACRO_LABELS, tn as object, tt as alarmPanelCapability, u as DETECTION_MACRO_CLASSES, ut as evaluateSensorEdge, v as LabelAttributionSchema, vt as isScheduleActive, w as NC_ALARM_SYSTEM_EVENT_KINDS, xt as mayWriteToLocation, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isSourceCap, z as NcTaxonomySchema, zt as BaseAddon } from "../dist-PZVHV70g.mjs";
1
+ import { $ as SCENE_DIVERGED, $t as hydrateSchema, A as NcConditionDescriptorSchema, At as occupancyScope, B as NcSystemEventKindSchema, Bt as storageOccupancyCapability, C as MediaFileKindEnum, Ct as isKnownTemplateVar, Dt as kebabToCamel, E as NC_DEFAULT_SNOOZE_MINUTES, Et as isSourceCap, F as NcRuleUpdateInputSchema, Ft as readDeviceStateFrom, G as OpsLogEntrySchema, Gt as zoneAnalyticsCapability, H as NcTemplatePreviewInputSchema, Ht as systemEventFilterApplies, I as NcScheduleSchema, It as readTimelapseGeneratedAt, J as RECORDING_SOURCE_CAMSTACK_ADDON, Jt as CamProfileSchema, Kt as errMsg$1, L as NcSnoozeInputSchema, Lt as resolveLocationMode, M as NcRulePatchSchema, Mt as pickClusterStepModels, N as NcRuleSchema, Nt as pipelineAnalyticsCapability, O as NC_TAXONOMY, Ot as mayWriteToLocation, P as NcRuleTargetSchema, Pt as plateGalleryCapability, Q as SCENE_DEFAULT_UNCOVERED_POLICY, Qt as defineCustomActions, R as NcSnoozeSchema, S as MOTION_CLOSE_AFTER_MS, St as isEventOwnerType, T as NC_CONDITION_CATALOG, Tt as isScheduleActive, U as NcTemplatePreviewSchema, Ut as templateVarsFor, V as NcTaxonomySchema, Vt as subKindsOf, W as NcTemplateVarDescriptorSchema, Wt as vectorDimFromBase64, Xt as createEvent, Y as RetrainStatusSchema, Yt as DeviceType, Z as SCENE_DEFAULT_ANCHOR_THRESHOLD, Zt as customAction, _ as FailureCounters, _t as evictionPolicyOfLocation, a as CLUSTER_MODEL_SCOPED_STEPS, an as boolean, at as TrackSourceSchema, b as MAX_BIRTH_DECISION_RECORDS, cn as number, ct as assertTimelapseCadences, dn as record, dt as buildEventKindDescriptor, en as isDeviceScopedCap, et as SceneMonitorSchema, f as DeclaredDevices, fn as string, ft as cosineSimilarity$1, g as FULL_IMAGE_BBOX, gt as evaluateSensorEdge, h as FIRST_LEVEL_MACRO_CLASSES, ht as encodeVectorBase64, i as BirthDecisionRecordSchema, in as array, it as TimelapseRuleSchema, j as NcRuleInputSchema, k as NC_TEMPLATE_VARS, kt as notificationRulesCapability, ln as object, lt as audioMetricsCapability, m as EVENT_OWNER_TYPES, mn as EventCategory, n as ArchivedDebugNoteSchema, nn as sleep$1, nt as TimelapseRuleInputSchema, o as COCO_TO_MACRO, on as discriminatedUnion, ot as addonWidgetsSourceCapability, p as EVENT_KIND_BY_CAP, pn as unknown, pt as deriveRecordingMode, q as RECORDING_EXPORT_MAX_READ_BYTES, qt as BaseAddon, r as BaseDevice, rn as _enum, rt as TimelapseRulePatchSchema, s as DEFAULT_EVENT_COLOR, sn as literal, st as alarmPanelCapability, t as AUDIO_MACRO_LABELS, tn as nodePin, tt as TIMELAPSE_DENSE_FLOOR_SEC, u as DETECTION_MACRO_CLASSES, un as partialRecord, ut as audioModeOf, v as LabelAttributionSchema, vt as faceGalleryCapability, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as isMediaOwnerType, xt as isDetectionMacroClass, y as MAX_ARCHIVED_DEBUG_NOTES, yt as failureContributionCapability, z as NcSnoozeSuppressedSchema, zt as sceneMonitorCapability } from "../dist-D3Yub96m.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1454,7 +1454,8 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1454
1454
  return {
1455
1455
  ...this.readDelays(),
1456
1456
  announceArm: this.config.get("announceArm"),
1457
- announceTargets: [...this.config.get("announceTargets")]
1457
+ announceTargets: [...this.config.get("announceTargets")],
1458
+ nonBlocking: []
1458
1459
  };
1459
1460
  }
1460
1461
  /**
@@ -10815,6 +10816,23 @@ function requireCapCaller(method, caller) {
10815
10816
  return caller;
10816
10817
  }
10817
10818
  //#endregion
10819
+ //#region src/notification-center/clock-text.ts
10820
+ /**
10821
+ * `{{time}}` — hours and minutes in the NOTIFICATION language.
10822
+ *
10823
+ * It used to be `toLocaleTimeString()` in the server's own locale, so an
10824
+ * Italian hub running an English container sent "8:05:13 PM" inside an
10825
+ * Italian sentence. The time zone is the host's unless a caller (a spec)
10826
+ * pins one.
10827
+ */
10828
+ function ncClockText(language, atMs, timeZone) {
10829
+ return new Intl.DateTimeFormat(language, {
10830
+ hour: "2-digit",
10831
+ minute: "2-digit",
10832
+ ...timeZone !== void 0 ? { timeZone } : {}
10833
+ }).format(new Date(atMs));
10834
+ }
10835
+ //#endregion
10818
10836
  //#region src/notification-center/system-event-tap-url.ts
10819
10837
  /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
10820
10838
  var ADMIN_ADDONS_PATH = "/system/addons";
@@ -12783,8 +12801,7 @@ var NcDispatcher = class {
12783
12801
  async buildNotification(entry, target) {
12784
12802
  const subject = entry.payload.subject;
12785
12803
  const systemEvent = subject.systemEvent;
12786
- const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
12787
- const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
12804
+ const deviceName = await notificationDeviceName(entry, this.deps.getDeviceName);
12788
12805
  const alarmSource = systemEvent?.alarmSource;
12789
12806
  const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
12790
12807
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
@@ -13538,17 +13555,25 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
13538
13555
  function percentOf(confidence) {
13539
13556
  return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
13540
13557
  }
13558
+ /** What `{{camera}}` renders on a system row that names no device. */
13559
+ var NC_DEVICELESS_CAMERA_NAME = "system";
13541
13560
  /**
13542
- * The variable map ONE render pass sees: scalars, then the composites built
13543
- * from them, then the params a system-event row froze at intake.
13561
+ * The name `{{camera}}` renders for a row — the ONE derivation, shared by the
13562
+ * dispatcher and the template-variable parity spec.
13544
13563
  *
13545
- * The three groups are layered in that order deliberately. A composite
13546
- * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13547
- * visible to the fragments it was built from — that is the depth bound, and it
13548
- * is enforced by `text-compose.ts` never being handed this map. The frozen
13549
- * params go last because they are the row's own truth about a system event and
13550
- * outrank the live vocabulary for exactly those names.
13564
+ * A system row that names no device (a node, a backup, an update) renders the
13565
+ * filler {@link NC_DEVICELESS_CAMERA_NAME}: there is no camera to name, which
13566
+ * is why the catalog offers `{{camera}}` on a system rule only for the kinds
13567
+ * that carry a device (template-vars.ts). Everything else asks the directory,
13568
+ * and falls back to `camera <id>` when it cannot answer.
13551
13569
  */
13570
+ async function notificationDeviceName(entry, getDeviceName) {
13571
+ const subject = entry.payload.subject;
13572
+ const systemEvent = subject.systemEvent;
13573
+ if (systemEvent !== void 0 && systemEvent.deviceId === void 0) return NC_DEVICELESS_CAMERA_NAME;
13574
+ const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
13575
+ return await getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
13576
+ }
13552
13577
  /** Class composition of a group, busiest classes in first-seen order. */
13553
13578
  function classCounts(members) {
13554
13579
  const counts = /* @__PURE__ */ new Map();
@@ -13558,7 +13583,18 @@ function classCounts(members) {
13558
13583
  count
13559
13584
  }));
13560
13585
  }
13561
- function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13586
+ /**
13587
+ * The variable map ONE render pass sees: scalars, then the composites built
13588
+ * from them, then the params a system-event row froze at intake.
13589
+ *
13590
+ * The three groups are layered in that order deliberately. A composite
13591
+ * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13592
+ * visible to the fragments it was built from — that is the depth bound, and it
13593
+ * is enforced by `text-compose.ts` never being handed this map. The frozen
13594
+ * params go last because they are the row's own truth about a system event and
13595
+ * outrank the live vocabulary for exactly those names.
13596
+ */
13597
+ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
13562
13598
  const subject = entry.payload.subject;
13563
13599
  const occupancy = subject.occupancy;
13564
13600
  const systemEvent = subject.systemEvent;
@@ -13566,7 +13602,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13566
13602
  const group = entry.payload.group;
13567
13603
  return {
13568
13604
  camera: deviceName,
13569
- class: detection?.className ?? subject.className,
13605
+ class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
13570
13606
  label: detection?.label ?? subject.label ?? "",
13571
13607
  zones: zoneLabels.join(", "),
13572
13608
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -13579,10 +13615,14 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13579
13615
  }),
13580
13616
  detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
13581
13617
  inZones: ncInZonesText(texts, zoneLabels),
13582
- scope: occupancy?.zone ?? deviceName,
13618
+ scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
13583
13619
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
13584
13620
  confidence: percentOf(detection?.confidence ?? subject.confidence),
13585
- time: new Date(subject.timestamp).toLocaleTimeString(),
13621
+ time: ncClockText(texts.language, subject.timestamp, timeZone),
13622
+ sounds: subject.audio?.labels.join(", ") ?? "",
13623
+ hitPercent: subject.audio?.hitPercent !== void 0 ? `${Math.round(subject.audio.hitPercent)}` : "",
13624
+ db: subject.audio?.peakDbfs !== void 0 ? `${Math.round(subject.audio.peakDbfs)}` : "",
13625
+ eventType: subject.eventType ?? "",
13586
13626
  rule: entry.payload.ruleName,
13587
13627
  count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
13588
13628
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
@@ -17413,12 +17453,22 @@ var NcRuleStore = class {
17413
17453
  await this.ledger.put(rule);
17414
17454
  return rule;
17415
17455
  }
17416
- /** Apply a partial patch. Immutable: returns the NEW rule object. */
17417
- async update(ruleId, patch) {
17456
+ /**
17457
+ * Apply a partial patch. Immutable: returns the NEW rule object.
17458
+ *
17459
+ * `clear` names optional fields to REMOVE before the merge — an omitted key
17460
+ * keeps its stored value, so this is how an editor empties one. Setting and
17461
+ * clearing the same key is a contradiction and is refused rather than
17462
+ * resolved by ordering.
17463
+ */
17464
+ async update(ruleId, patch, clear = []) {
17418
17465
  const existing = this.ledger.get(ruleId);
17419
17466
  if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
17467
+ const conflict = clear.find((key) => patch[key] !== void 0);
17468
+ if (conflict !== void 0) throw new Error(`notification rule patch both sets and clears '${conflict}'`);
17469
+ const cleared = new Set(clear);
17420
17470
  const candidate = {
17421
- ...existing,
17471
+ ...Object.fromEntries(Object.entries(existing).filter(([key]) => !cleared.has(key))),
17422
17472
  ...patch,
17423
17473
  id: existing.id,
17424
17474
  createdBy: existing.createdBy,
@@ -19326,13 +19376,18 @@ function clockOf$2(atMs) {
19326
19376
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19327
19377
  }
19328
19378
  /** Template vocabulary. Deliberately the SAME names the detection and timelapse
19329
- * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones. */
19330
- function templateVars$1(input) {
19379
+ * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
19380
+ *
19381
+ * Exported as `summaryTemplateVars` — not just an implementation detail of
19382
+ * {@link buildSummaryOutboxInputs} — so the parity spec
19383
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
19384
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
19385
+ function summaryTemplateVars(input) {
19331
19386
  return {
19332
19387
  rule: input.ruleName,
19333
19388
  from: clockOf$2(input.window.startMs),
19334
19389
  to: clockOf$2(input.window.endMs),
19335
- time: clockOf$2(input.generatedAt),
19390
+ time: ncClockText(input.texts.language, input.generatedAt),
19336
19391
  events: String(input.tileCount),
19337
19392
  cameras: String(input.cameraCount),
19338
19393
  matched: String(input.matchedCount),
@@ -19439,7 +19494,7 @@ function summaryCaptionText(input) {
19439
19494
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
19440
19495
  */
19441
19496
  function buildSummaryOutboxInputs(input) {
19442
- const vars = templateVars$1(input);
19497
+ const vars = summaryTemplateVars(input);
19443
19498
  const optedOut = new Set(input.disabledTargetIds ?? []);
19444
19499
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
19445
19500
  key: "summary.title",
@@ -21663,6 +21718,67 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
21663
21718
  return files;
21664
21719
  }
21665
21720
  //#endregion
21721
+ //#region src/notification-center/template-preview.ts
21722
+ /**
21723
+ * `previewTemplate` — render a rule's draft title/body against EXAMPLE values,
21724
+ * so an operator sees `{{scope}}: {{count}}/{{capacity}}` become
21725
+ * `Cancello: 3/2` before saving the rule, not after the first real event.
21726
+ *
21727
+ * Pure with respect to the caller: it never sends anything, and the only
21728
+ * side-channel it reads is the live `NcTextCatalog` handed in, so a preview
21729
+ * always speaks the hub's CURRENT language and CURRENT overrides — the same
21730
+ * words a delivered notification would use.
21731
+ */
21732
+ /**
21733
+ * The instant `{{time}}` is previewed at — fixed, and in UTC, so the example
21734
+ * is the same on every host: "20:05" in Italian, "08:05 PM" in English.
21735
+ */
21736
+ var PREVIEW_INSTANT_MS = Date.UTC(2026, 8, 24, 20, 5);
21737
+ var PREVIEW_TIME_ZONE = "UTC";
21738
+ /**
21739
+ * The example map: every declared example, with the COMPOSITES rendered by the
21740
+ * real composers so the preview speaks the hub's language exactly as a
21741
+ * delivered notification would.
21742
+ */
21743
+ function exampleVars(texts) {
21744
+ return {
21745
+ ...Object.fromEntries(NC_TEMPLATE_VARS.filter((d) => d.dynamic === void 0).map((d) => [d.name, d.example])),
21746
+ subject: ncSubjectText(texts, {
21747
+ className: "person",
21748
+ label: "Mario",
21749
+ labelKind: "identity"
21750
+ }),
21751
+ inZones: ncInZonesText(texts, ["Cancello"]),
21752
+ op: ncOccupancyOp(texts, true),
21753
+ time: ncClockText(texts.language, PREVIEW_INSTANT_MS, PREVIEW_TIME_ZONE),
21754
+ detectionSummary: ncDetectionSummary(texts, [{
21755
+ className: "person",
21756
+ count: 2
21757
+ }, {
21758
+ className: "car",
21759
+ count: 1
21760
+ }])
21761
+ };
21762
+ }
21763
+ var DYNAMIC_EXAMPLE = "3";
21764
+ function previewTemplate(texts, input) {
21765
+ const ctx = input.context;
21766
+ const allowed = new Set(templateVarsFor(ctx).map((d) => d.name));
21767
+ const examples = exampleVars(texts);
21768
+ const used = [input.template.title ?? "", input.template.body ?? ""].flatMap((t) => [...templatePlaceholders(t)]);
21769
+ const unknown = [...new Set(used.filter((name) => !isKnownTemplateVar(name, ctx)))];
21770
+ const vars = {};
21771
+ for (const name of used) {
21772
+ if (!isKnownTemplateVar(name, ctx)) continue;
21773
+ vars[name] = allowed.has(name) ? examples[name] ?? "" : DYNAMIC_EXAMPLE;
21774
+ }
21775
+ return {
21776
+ title: renderTemplate(input.template.title, vars),
21777
+ body: renderTemplate(input.template.body, vars),
21778
+ unknown
21779
+ };
21780
+ }
21781
+ //#endregion
21666
21782
  //#region src/notification-center/text-catalog-view.ts
21667
21783
  /**
21668
21784
  * The text catalog, projected for an EDITOR — Phase 2's read model, pure.
@@ -22102,14 +22218,19 @@ function clockOf(atMs) {
22102
22218
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
22103
22219
  }
22104
22220
  /** Template vocabulary. Deliberately the same NAMES the rule templates use
22105
- * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
22106
- function templateVars(input) {
22221
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones.
22222
+ *
22223
+ * Exported as `timelapseTemplateVars` — not just an implementation detail of
22224
+ * {@link buildTimelapseOutboxInputs} — so the parity spec
22225
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
22226
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
22227
+ function timelapseTemplateVars(input) {
22107
22228
  return {
22108
22229
  camera: input.deviceName ?? `camera ${input.deviceId}`,
22109
22230
  rule: input.ruleName,
22110
22231
  from: clockOf(input.window.startMs),
22111
22232
  to: clockOf(input.window.endMs),
22112
- time: clockOf(input.generatedAt),
22233
+ time: ncClockText(input.texts.language, input.generatedAt),
22113
22234
  coverage: String(input.coverage.percent),
22114
22235
  events: String(input.denseRanges),
22115
22236
  ...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
@@ -22190,7 +22311,7 @@ function timelapseRecordId(deviceId, windowEndMs) {
22190
22311
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
22191
22312
  */
22192
22313
  function buildTimelapseOutboxInputs(input) {
22193
- const vars = templateVars(input);
22314
+ const vars = timelapseTemplateVars(input);
22194
22315
  const optedOut = new Set(input.disabledTargetIds ?? []);
22195
22316
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
22196
22317
  key: "timelapse.title",
@@ -23173,7 +23294,8 @@ function consumablesSliceOf(event) {
23173
23294
  var NC_ALARM_SETTINGS_FALLBACK = {
23174
23295
  ...DEFAULT_DELAYS,
23175
23296
  announceArm: false,
23176
- announceTargets: []
23297
+ announceTargets: [],
23298
+ nonBlocking: []
23177
23299
  };
23178
23300
  /**
23179
23301
  * How often the "matched NO rule" report may fire per device. Long enough that
@@ -24423,6 +24545,10 @@ var NotificationCenter = class NotificationCenter {
24423
24545
  get textCatalogEditor() {
24424
24546
  return this.textEditor;
24425
24547
  }
24548
+ /** The live catalog every producer renders with — the preview must use the same one. */
24549
+ get textCatalog() {
24550
+ return this.texts;
24551
+ }
24426
24552
  /**
24427
24553
  * Produce the last closed window for one camera of one timelapse rule, now.
24428
24554
  *
@@ -25285,17 +25411,19 @@ var NotificationCenter = class NotificationCenter {
25285
25411
  } });
25286
25412
  return { rule: created };
25287
25413
  },
25288
- updateRule: async ({ ruleId, patch, caller }) => {
25414
+ updateRule: async ({ ruleId, patch, clear, caller }) => {
25289
25415
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
25290
- if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
25416
+ const clearsUsers = clear?.includes("targetUsers") ?? false;
25417
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0 || clearsUsers) {
25291
25418
  const existing = this.rules.get(ruleId);
25292
- NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
25419
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], clearsUsers ? void 0 : patch.targetUsers ?? existing?.targetUsers);
25293
25420
  }
25294
25421
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
25295
- const updated = await this.rules.update(ruleId, safePatch);
25422
+ const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
25296
25423
  this.logger.info("notification rule updated", { meta: {
25297
25424
  ruleId,
25298
- by: caller.userId
25425
+ by: caller.userId,
25426
+ ...clear && clear.length > 0 ? { cleared: clear } : {}
25299
25427
  } });
25300
25428
  return { rule: updated };
25301
25429
  },
@@ -25312,6 +25440,8 @@ var NotificationCenter = class NotificationCenter {
25312
25440
  catalog: [...NC_CONDITION_CATALOG],
25313
25441
  taxonomy: NC_TAXONOMY
25314
25442
  }),
25443
+ getTemplateCatalog: async () => ({ vars: [...NC_TEMPLATE_VARS] }),
25444
+ previewTemplate: async (input) => previewTemplate(this.texts, input),
25315
25445
  getHistory: async ({ filter }) => {
25316
25446
  const entries = await this.outbox.queryHistory({
25317
25447
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -26499,6 +26629,12 @@ var NotificationCenter = class NotificationCenter {
26499
26629
  ...subject.observedAt !== void 0 ? { observedAt: subject.observedAt } : {},
26500
26630
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
26501
26631
  ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
26632
+ ...subject.audioWindow !== void 0 ? { audio: {
26633
+ labels: subject.audioWindow.labels,
26634
+ ...subject.audioWindow.mode === "level" ? { hitPercent: subject.audioWindow.hitPercent } : {},
26635
+ ...subject.audioWindow.peakDbfs !== void 0 ? { peakDbfs: subject.audioWindow.peakDbfs } : {}
26636
+ } } : {},
26637
+ ...subject.eventType !== void 0 ? { eventType: subject.eventType } : {},
26502
26638
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
26503
26639
  }
26504
26640
  };
@@ -27687,14 +27823,13 @@ var ncActions = defineCustomActions({
27687
27823
  catalog: array(NcConditionDescriptorSchema),
27688
27824
  taxonomy: NcTaxonomySchema
27689
27825
  })),
27826
+ "nc.getTemplateCatalog": customAction(object({}), object({ vars: array(NcTemplateVarDescriptorSchema) })),
27827
+ "nc.previewTemplate": customAction(NcTemplatePreviewInputSchema, NcTemplatePreviewSchema, { kind: "mutation" }),
27690
27828
  "nc.createRule": customAction(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
27691
27829
  kind: "mutation",
27692
27830
  caller: "required"
27693
27831
  }),
27694
- "nc.updateRule": customAction(object({
27695
- ruleId: string(),
27696
- patch: NcRulePatchSchema
27697
- }), object({ rule: NcRuleSchema }), {
27832
+ "nc.updateRule": customAction(NcRuleUpdateInputSchema, object({ rule: NcRuleSchema }), {
27698
27833
  kind: "mutation",
27699
27834
  caller: "required"
27700
27835
  }),
@@ -27713,14 +27848,17 @@ var ncActions = defineCustomActions({
27713
27848
  /**
27714
27849
  * THE AI SECTION of the ordinary rules — read and write.
27715
27850
  *
27716
- * A pair of its own rather than two more keys on `nc.createRule` /
27717
- * `nc.updateRule`, and that is the whole point of the design: those two
27718
- * validate against `NcRuleInputSchema` from `@camstack/types`, which the
27719
- * addon resolves from the `@camstack/server` closure at runtime — so a new
27720
- * key on them is SILENTLY STRIPPED at save until a framework train lands and
27721
- * is installed. These two validate against a schema that lives in the addon
27722
- * (`rule-ai.ts`), so a field added to it is live on the next
27723
- * `camstack deploy packages/addon-post-analysis`.
27851
+ * A pair of its own rather than two more keys on the rule CRUD, and that is
27852
+ * the whole point of the design. The addon builds self-contained and INLINES
27853
+ * `@camstack/types`, so its own copy of `NcRuleInputSchema` is never the
27854
+ * problem and nothing here fails at load. The coupling is the HUB: the cap
27855
+ * route (`notificationRules.createRule` / `updateRule`, which the admin
27856
+ * editor uses) validates input against the copy of `@camstack/types` that
27857
+ * `@camstack/system` inlines (`bundleTypesMainEntry`), so a new key on
27858
+ * `NcRuleInputSchema` is SILENTLY STRIPPED there until a `@camstack/server`
27859
+ * release carrying it is installed. These two validate only here, against a
27860
+ * schema that lives in the addon (`rule-ai.ts`), so a field added to it is
27861
+ * live on the next `camstack deploy packages/addon-post-analysis`.
27724
27862
  *
27725
27863
  * Scoped exactly as the rule CRUD is: reading needs a rule the caller can
27726
27864
  * SEE, writing needs one they own (admins bypass ownership, never the caller
@@ -28212,6 +28350,12 @@ function makeNcActionHandlers(deps) {
28212
28350
  catalog: [...NC_CONDITION_CATALOG],
28213
28351
  taxonomy: NC_TAXONOMY
28214
28352
  }),
28353
+ "nc.getTemplateCatalog": async () => ({ vars: [...NC_TEMPLATE_VARS] }),
28354
+ "nc.previewTemplate": async (input) => {
28355
+ const texts = deps.previewTexts;
28356
+ if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
28357
+ return previewTemplate(texts, input);
28358
+ },
28215
28359
  "nc.createRule": async (input, caller) => {
28216
28360
  const c = requireCaller$1(caller);
28217
28361
  const parsed = NcRuleInputSchema.parse(input.rule);
@@ -28232,10 +28376,11 @@ function makeNcActionHandlers(deps) {
28232
28376
  const patch = NcRulePatchSchema.parse(input.patch);
28233
28377
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
28234
28378
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
28235
- const rule = await deps.ruleStore.update(input.ruleId, safe);
28379
+ const rule = await deps.ruleStore.update(input.ruleId, safe, input.clear ?? []);
28236
28380
  deps.logger.info("nc rule updated", { meta: {
28237
28381
  ruleId: rule.id,
28238
- owner: c.userId
28382
+ owner: c.userId,
28383
+ ...input.clear && input.clear.length > 0 ? { cleared: input.clear } : {}
28239
28384
  } });
28240
28385
  return { rule };
28241
28386
  },
@@ -41691,6 +41836,160 @@ var TrackResidentState = class {
41691
41836
  if (resident.rasterFallback === void 0) resident.rasterFallback = fallback;
41692
41837
  }
41693
41838
  };
41839
+ /**
41840
+ * The shipped limits.
41841
+ *
41842
+ * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
41843
+ * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
41844
+ * camera that cannot clear these has genuinely not been watched enough.
41845
+ * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
41846
+ * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
41847
+ */
41848
+ var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
41849
+ minTracks: 12,
41850
+ minSamples: 200,
41851
+ maxTracks: 200,
41852
+ minIntervalSec: .02,
41853
+ maxIntervalSec: 3,
41854
+ quantile: .25,
41855
+ floorFactor: .02,
41856
+ ceilFactor: .25,
41857
+ minBar: .001,
41858
+ maxBar: 1
41859
+ };
41860
+ /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
41861
+ * would read as a measured zero (D393). */
41862
+ function quantileOf(values, p) {
41863
+ if (values.length === 0) return NaN;
41864
+ const sorted = [...values].sort((a, b) => a - b);
41865
+ const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
41866
+ const lo = Math.floor(idx);
41867
+ const hi = Math.ceil(idx);
41868
+ const at = sorted[lo];
41869
+ const next = sorted[hi];
41870
+ if (at === void 0 || next === void 0) return NaN;
41871
+ return at + (next - at) * (idx - lo);
41872
+ }
41873
+ /**
41874
+ * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
41875
+ *
41876
+ * A pair of consecutive positions contributes a sample only when the interval
41877
+ * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
41878
+ * has a positive diagonal. A gap outside that band is a session boundary or a
41879
+ * duplicate read, not a measurement of how fast anything travelled; a box with
41880
+ * no size is unknown geometry, and unknown is never a sample.
41881
+ */
41882
+ function collectStillnessSamples(tracks, limits) {
41883
+ const speeds = [];
41884
+ const intervals = [];
41885
+ const diagonals = [];
41886
+ let trackCount = 0;
41887
+ const read = tracks.slice(0, limits.maxTracks);
41888
+ for (const track of read) {
41889
+ const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
41890
+ let contributed = false;
41891
+ for (let i = 1; i < positions.length; i++) {
41892
+ const prev = positions[i - 1];
41893
+ const cur = positions[i];
41894
+ if (prev === void 0 || cur === void 0) continue;
41895
+ const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
41896
+ if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
41897
+ const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
41898
+ if (!(diagonal > 0)) continue;
41899
+ const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
41900
+ speeds.push(travelled / diagonal / dtSec);
41901
+ intervals.push(dtSec);
41902
+ diagonals.push(diagonal);
41903
+ contributed = true;
41904
+ }
41905
+ if (contributed) trackCount++;
41906
+ }
41907
+ return {
41908
+ speeds,
41909
+ intervals,
41910
+ diagonals,
41911
+ trackCount,
41912
+ tracksRead: read.length
41913
+ };
41914
+ }
41915
+ function measure$1(samples) {
41916
+ return {
41917
+ trackCount: samples.trackCount,
41918
+ sampleCount: samples.speeds.length,
41919
+ p25: quantileOf(samples.speeds, .25),
41920
+ p50: quantileOf(samples.speeds, .5),
41921
+ p90: quantileOf(samples.speeds, .9),
41922
+ medianIntervalSec: quantileOf(samples.intervals, .5),
41923
+ medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
41924
+ };
41925
+ }
41926
+ /**
41927
+ * Choose this camera's stillness speed bar, or refuse with a named reason.
41928
+ *
41929
+ * The order of the gates is the order of the questions: is there anything at
41930
+ * all, is it enough tracks, is it enough samples, and did anything on this
41931
+ * camera ever actually move. The last one is not pedantry — the whole method
41932
+ * scales the bar against the camera's own travel, so with no travel there is
41933
+ * nothing to scale against and the default is the honest answer.
41934
+ */
41935
+ function calibrateStillnessSpeed(samples, limits) {
41936
+ const measurement = measure$1(samples);
41937
+ if (samples.tracksRead === 0) return {
41938
+ ok: false,
41939
+ reason: "no-tracks",
41940
+ measurement
41941
+ };
41942
+ if (samples.trackCount < limits.minTracks) return {
41943
+ ok: false,
41944
+ reason: "too-few-tracks",
41945
+ measurement
41946
+ };
41947
+ if (samples.speeds.length < limits.minSamples) return {
41948
+ ok: false,
41949
+ reason: "too-few-samples",
41950
+ measurement
41951
+ };
41952
+ if (!(measurement.p90 > 0)) return {
41953
+ ok: false,
41954
+ reason: "no-motion-evidence",
41955
+ measurement
41956
+ };
41957
+ const rawBar = quantileOf(samples.speeds, limits.quantile);
41958
+ const floor = measurement.p90 * limits.floorFactor;
41959
+ const ceiling = measurement.p90 * limits.ceilFactor;
41960
+ let bar = rawBar;
41961
+ let clampedBy = "none";
41962
+ if (bar < floor) {
41963
+ bar = floor;
41964
+ clampedBy = "floor";
41965
+ } else if (bar > ceiling) {
41966
+ bar = ceiling;
41967
+ clampedBy = "ceiling";
41968
+ }
41969
+ if (bar < limits.minBar) {
41970
+ bar = limits.minBar;
41971
+ clampedBy = "min";
41972
+ } else if (bar > limits.maxBar) {
41973
+ bar = limits.maxBar;
41974
+ clampedBy = "max";
41975
+ }
41976
+ return {
41977
+ ok: true,
41978
+ bar,
41979
+ rawBar,
41980
+ clampedBy,
41981
+ measurement
41982
+ };
41983
+ }
41984
+ /** One line an operator can read: what was measured, from how much, and what
41985
+ * rule produced the stored number. Kept short — it rides the settings blob. */
41986
+ function describeStillnessCalibration(outcome) {
41987
+ const m = outcome.measurement;
41988
+ const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
41989
+ const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
41990
+ if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
41991
+ return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
41992
+ }
41694
41993
  //#endregion
41695
41994
  //#region src/pipeline-analytics/pipeline/plate-clustering.ts
41696
41995
  /**
@@ -47641,6 +47940,182 @@ async function retentionScopeDeviceIds(deps, onError) {
47641
47940
  return [...ids];
47642
47941
  }
47643
47942
  //#endregion
47943
+ //#region src/pipeline-analytics/stillness-calibration-actions.ts
47944
+ /**
47945
+ * stillness-calibration-actions — "measure this camera's own stillness scale".
47946
+ *
47947
+ * ## Why an `addons.custom` action and not a cap method
47948
+ *
47949
+ * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
47950
+ * lives in `@camstack/types`, which travels inside the `@camstack/server`
47951
+ * closure, so a new one is not callable until a server train ships. Here the
47952
+ * schema lives in this addon's own dist and the hub forwards an opaque
47953
+ * envelope, so `camstack deploy` is the whole delivery.
47954
+ *
47955
+ * ## Why `apply` is explicit and not a fallback
47956
+ *
47957
+ * A calibration run READS recorded tracks and does arithmetic. Applying it
47958
+ * WRITES the camera's settings and changes what the tracker calls settled from
47959
+ * the next frame on. Those are different enough in consequence that a button
47960
+ * labelled "calibrate" must not choose between them on the operator's behalf —
47961
+ * the run reports, the operator applies. A REFUSED run never writes the bar
47962
+ * whatever `apply` says; it writes only the note, so the refusal and its reason
47963
+ * are visible in the form instead of being a toast that scrolls away.
47964
+ */
47965
+ /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
47966
+ * self-describing and a new reason is a deliberate edit in both places. */
47967
+ var StillnessRefusalSchema = _enum([
47968
+ "no-tracks",
47969
+ "too-few-tracks",
47970
+ "too-few-samples",
47971
+ "no-motion-evidence"
47972
+ ]);
47973
+ var StillnessMeasurementSchema = object({
47974
+ /** Tracks that contributed at least one speed sample. */
47975
+ trackCount: number().int(),
47976
+ /** Tracks the pass actually read, after the cost bound. */
47977
+ tracksRead: number().int(),
47978
+ sampleCount: number().int(),
47979
+ /** Speed quantiles, subject-box-diagonals per second. `null` when there was
47980
+ * nothing to measure — never 0, which would read as a measured stillness
47981
+ * (D393). */
47982
+ p25: number().nullable(),
47983
+ p50: number().nullable(),
47984
+ p90: number().nullable(),
47985
+ /** Median gap between stored positions (s) — the camera's real cadence. */
47986
+ medianIntervalSec: number().nullable(),
47987
+ /** Median subject box diagonal (px) the speeds were normalised by. */
47988
+ medianSubjectDiagonalPx: number().nullable()
47989
+ });
47990
+ var stillnessCalibrationActions = defineCustomActions({
47991
+ /**
47992
+ * `admin` because it WRITES a per-camera detection threshold when `apply` is
47993
+ * set, and because the report describes how the camera's subjects move,
47994
+ * which is not a viewer-level fact.
47995
+ */
47996
+ "stillness.calibrate": customAction(object({
47997
+ deviceId: number().int(),
47998
+ /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
47999
+ * row, so the window is a suggestion and `maxTracks` is the real bound. */
48000
+ sinceHours: number().min(.25).max(720).default(72),
48001
+ /** Hard cost bound on tracks read, newest first. */
48002
+ maxTracks: number().int().min(1).max(1e3).default(200),
48003
+ /**
48004
+ * Write the chosen bar onto the camera. Never implicit, and never done at
48005
+ * all for a refused run.
48006
+ */
48007
+ apply: boolean().default(false)
48008
+ }), object({
48009
+ deviceId: number().int(),
48010
+ /** The window actually read (epoch ms). */
48011
+ since: number().int(),
48012
+ until: number().int(),
48013
+ accepted: boolean(),
48014
+ /** Why it refused. Absent on an accepted run. */
48015
+ reason: StillnessRefusalSchema.optional(),
48016
+ /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
48017
+ * never 0, which the settings layer reads as "uncalibrated". */
48018
+ bar: number().optional(),
48019
+ /** The raw quantile before the rails, so the clamp is arguable. */
48020
+ rawBar: number().optional(),
48021
+ clampedBy: _enum([
48022
+ "none",
48023
+ "floor",
48024
+ "ceiling",
48025
+ "min",
48026
+ "max"
48027
+ ]).optional(),
48028
+ measurement: StillnessMeasurementSchema,
48029
+ /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
48030
+ * before any write, so the operator sees both numbers. */
48031
+ previousBar: number().nullable(),
48032
+ /** Whether the camera's settings were actually written. */
48033
+ applied: boolean(),
48034
+ /** The one-line note stored on the camera (accepted or refused). */
48035
+ note: string()
48036
+ }), {
48037
+ kind: "mutation",
48038
+ auth: "admin"
48039
+ }) });
48040
+ //#endregion
48041
+ //#region src/pipeline-analytics/stillness-settings.ts
48042
+ /**
48043
+ * Per-device STILLNESS SCALE — the one number that says how fast a subject on
48044
+ * THIS camera has to move before it is travelling.
48045
+ *
48046
+ * A threshold belongs to whoever GENERATES the event, per device, never baked
48047
+ * into a classifier default. The judgement this feeds is
48048
+ * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
48049
+ * `pipeline/tracker/stillness-scale.ts`, which also carries the live
48050
+ * measurement showing why one number cannot serve a 30 m driveway and a 2 m
48051
+ * doorway.
48052
+ *
48053
+ * Cascade and shape mirror `stationary-settings` / `media-settings`: a
48054
+ * per-device override on top of the default, resolved per FIELD, and a parse
48055
+ * that never throws on a bad blob.
48056
+ *
48057
+ * **Absent behaves exactly as today.** The device store is a flat numeric blob
48058
+ * with no room for "unset", so the stored form of absent is `0` — and `0` is
48059
+ * not a bar anyone could want (it would mean nothing on this camera is ever
48060
+ * still) and not a value the calibration can produce (`minBar`). The decision
48061
+ * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
48062
+ * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
48063
+ * absolute `velocityThreshold` path.
48064
+ */
48065
+ var StillnessSettingsSchema = object({
48066
+ /**
48067
+ * The camera's "not travelling" bar: centroid speed as a fraction of the
48068
+ * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
48069
+ *
48070
+ * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
48071
+ * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
48072
+ * transits).
48073
+ */
48074
+ stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
48075
+ /**
48076
+ * When the number above was last CHOSEN — by a calibration run or by the
48077
+ * operator. `0` = never. Purely a readout; nothing gates on it.
48078
+ */
48079
+ stillnessCalibratedAt: number().int().min(0).default(0),
48080
+ /**
48081
+ * What the last calibration run measured and decided, in one line
48082
+ * (`describeStillnessCalibration`) — including a REFUSAL and its named
48083
+ * reason, because "why is this camera still on the default" has to be
48084
+ * answerable without re-running anything.
48085
+ */
48086
+ stillnessCalibrationNote: string().max(400).default("")
48087
+ });
48088
+ var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
48089
+ /**
48090
+ * Resolve a per-device store blob into typed stillness settings. Unknown or
48091
+ * invalid fields fall back to the default for that field — including a bar
48092
+ * outside the calibration's own rails, which is refused rather than clamped:
48093
+ * a number the calibration could never have produced is not evidence about
48094
+ * this camera, and silently bending it into range would present it as if it
48095
+ * were.
48096
+ */
48097
+ function resolveStillnessSettings(raw) {
48098
+ const pick = (key) => {
48099
+ const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
48100
+ return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
48101
+ };
48102
+ return {
48103
+ stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48104
+ stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48105
+ stillnessCalibrationNote: pick("stillnessCalibrationNote")
48106
+ };
48107
+ }
48108
+ /**
48109
+ * The bar the decision layer gets: a positive, in-rails number, or
48110
+ * `undefined` for "this camera is not calibrated — judge it exactly as
48111
+ * before". The ONE place the stored-zero convention is interpreted.
48112
+ */
48113
+ function resolveStillSpeedFracPerSec(settings) {
48114
+ const bar = settings.stillnessSpeedFracPerSec;
48115
+ if (!(bar >= .001) || bar > 1) return void 0;
48116
+ return bar;
48117
+ }
48118
+ //#endregion
47644
48119
  //#region src/pipeline-analytics/viewer-settings-actions.ts
47645
48120
  /**
47646
48121
  * Viewer settings snapshots — hub-shared named blobs of the viewer's stores.
@@ -47860,336 +48335,6 @@ function makeViewerSettingsActionHandlers(store) {
47860
48335
  };
47861
48336
  }
47862
48337
  //#endregion
47863
- //#region src/pipeline-analytics/stillness-calibration-actions.ts
47864
- /**
47865
- * stillness-calibration-actions — "measure this camera's own stillness scale".
47866
- *
47867
- * ## Why an `addons.custom` action and not a cap method
47868
- *
47869
- * Same reason as `face.rescoreTrack` beside it: a cap method's wire schema
47870
- * lives in `@camstack/types`, which travels inside the `@camstack/server`
47871
- * closure, so a new one is not callable until a server train ships. Here the
47872
- * schema lives in this addon's own dist and the hub forwards an opaque
47873
- * envelope, so `camstack deploy` is the whole delivery.
47874
- *
47875
- * ## Why `apply` is explicit and not a fallback
47876
- *
47877
- * A calibration run READS recorded tracks and does arithmetic. Applying it
47878
- * WRITES the camera's settings and changes what the tracker calls settled from
47879
- * the next frame on. Those are different enough in consequence that a button
47880
- * labelled "calibrate" must not choose between them on the operator's behalf —
47881
- * the run reports, the operator applies. A REFUSED run never writes the bar
47882
- * whatever `apply` says; it writes only the note, so the refusal and its reason
47883
- * are visible in the form instead of being a toast that scrolls away.
47884
- */
47885
- /** Mirrors `StillnessCalibrationRefusal` — kept literal so the wire schema is
47886
- * self-describing and a new reason is a deliberate edit in both places. */
47887
- var StillnessRefusalSchema = _enum([
47888
- "no-tracks",
47889
- "too-few-tracks",
47890
- "too-few-samples",
47891
- "no-motion-evidence"
47892
- ]);
47893
- var StillnessMeasurementSchema = object({
47894
- /** Tracks that contributed at least one speed sample. */
47895
- trackCount: number().int(),
47896
- /** Tracks the pass actually read, after the cost bound. */
47897
- tracksRead: number().int(),
47898
- sampleCount: number().int(),
47899
- /** Speed quantiles, subject-box-diagonals per second. `null` when there was
47900
- * nothing to measure — never 0, which would read as a measured stillness
47901
- * (D393). */
47902
- p25: number().nullable(),
47903
- p50: number().nullable(),
47904
- p90: number().nullable(),
47905
- /** Median gap between stored positions (s) — the camera's real cadence. */
47906
- medianIntervalSec: number().nullable(),
47907
- /** Median subject box diagonal (px) the speeds were normalised by. */
47908
- medianSubjectDiagonalPx: number().nullable()
47909
- });
47910
- var stillnessCalibrationActions = defineCustomActions({
47911
- /**
47912
- * `admin` because it WRITES a per-camera detection threshold when `apply` is
47913
- * set, and because the report describes how the camera's subjects move,
47914
- * which is not a viewer-level fact.
47915
- */
47916
- "stillness.calibrate": customAction(object({
47917
- deviceId: number().int(),
47918
- /** How far back to read. Bounded: this pass parses ~10 kB of trajectory a
47919
- * row, so the window is a suggestion and `maxTracks` is the real bound. */
47920
- sinceHours: number().min(.25).max(720).default(72),
47921
- /** Hard cost bound on tracks read, newest first. */
47922
- maxTracks: number().int().min(1).max(1e3).default(200),
47923
- /**
47924
- * Write the chosen bar onto the camera. Never implicit, and never done at
47925
- * all for a refused run.
47926
- */
47927
- apply: boolean().default(false)
47928
- }), object({
47929
- deviceId: number().int(),
47930
- /** The window actually read (epoch ms). */
47931
- since: number().int(),
47932
- until: number().int(),
47933
- accepted: boolean(),
47934
- /** Why it refused. Absent on an accepted run. */
47935
- reason: StillnessRefusalSchema.optional(),
47936
- /** The chosen bar, subject-box-diagonals per second. Absent on a refusal —
47937
- * never 0, which the settings layer reads as "uncalibrated". */
47938
- bar: number().optional(),
47939
- /** The raw quantile before the rails, so the clamp is arguable. */
47940
- rawBar: number().optional(),
47941
- clampedBy: _enum([
47942
- "none",
47943
- "floor",
47944
- "ceiling",
47945
- "min",
47946
- "max"
47947
- ]).optional(),
47948
- measurement: StillnessMeasurementSchema,
47949
- /** The bar this camera was on BEFORE the run — `null` = uncalibrated. Read
47950
- * before any write, so the operator sees both numbers. */
47951
- previousBar: number().nullable(),
47952
- /** Whether the camera's settings were actually written. */
47953
- applied: boolean(),
47954
- /** The one-line note stored on the camera (accepted or refused). */
47955
- note: string()
47956
- }), {
47957
- kind: "mutation",
47958
- auth: "admin"
47959
- }) });
47960
- /**
47961
- * The shipped limits.
47962
- *
47963
- * `minTracks` 12 / `minSamples` 200: the four live cameras sampled above
47964
- * produced 697–8739 samples from 42–51 tracks in a single 60-track read, so a
47965
- * camera that cannot clear these has genuinely not been watched enough.
47966
- * `maxTracks` 200 bounds the read: the `positions` blob averages ~10 kB a row
47967
- * (`track-store.ts`), so this pass is ~2 MB of JSON at its worst.
47968
- */
47969
- var DEFAULT_STILLNESS_CALIBRATION_LIMITS = {
47970
- minTracks: 12,
47971
- minSamples: 200,
47972
- maxTracks: 200,
47973
- minIntervalSec: .02,
47974
- maxIntervalSec: 3,
47975
- quantile: .25,
47976
- floorFactor: .02,
47977
- ceilFactor: .25,
47978
- minBar: .001,
47979
- maxBar: 1
47980
- };
47981
- /** Linear-interpolated quantile. `NaN` for an empty list — never 0, which
47982
- * would read as a measured zero (D393). */
47983
- function quantileOf(values, p) {
47984
- if (values.length === 0) return NaN;
47985
- const sorted = [...values].sort((a, b) => a - b);
47986
- const idx = (sorted.length - 1) * Math.min(Math.max(p, 0), 1);
47987
- const lo = Math.floor(idx);
47988
- const hi = Math.ceil(idx);
47989
- const at = sorted[lo];
47990
- const next = sorted[hi];
47991
- if (at === void 0 || next === void 0) return NaN;
47992
- return at + (next - at) * (idx - lo);
47993
- }
47994
- /**
47995
- * Per-sample centroid speeds for a camera, in subject-box-diagonals per second.
47996
- *
47997
- * A pair of consecutive positions contributes a sample only when the interval
47998
- * between them is inside `[minIntervalSec, maxIntervalSec]` and the later box
47999
- * has a positive diagonal. A gap outside that band is a session boundary or a
48000
- * duplicate read, not a measurement of how fast anything travelled; a box with
48001
- * no size is unknown geometry, and unknown is never a sample.
48002
- */
48003
- function collectStillnessSamples(tracks, limits) {
48004
- const speeds = [];
48005
- const intervals = [];
48006
- const diagonals = [];
48007
- let trackCount = 0;
48008
- const read = tracks.slice(0, limits.maxTracks);
48009
- for (const track of read) {
48010
- const positions = [...track.positions].sort((a, b) => a.timestamp - b.timestamp);
48011
- let contributed = false;
48012
- for (let i = 1; i < positions.length; i++) {
48013
- const prev = positions[i - 1];
48014
- const cur = positions[i];
48015
- if (prev === void 0 || cur === void 0) continue;
48016
- const dtSec = (cur.timestamp - prev.timestamp) / 1e3;
48017
- if (dtSec < limits.minIntervalSec || dtSec > limits.maxIntervalSec) continue;
48018
- const diagonal = Math.hypot(cur.bbox.w, cur.bbox.h);
48019
- if (!(diagonal > 0)) continue;
48020
- const travelled = Math.hypot(cur.x - prev.x, cur.y - prev.y);
48021
- speeds.push(travelled / diagonal / dtSec);
48022
- intervals.push(dtSec);
48023
- diagonals.push(diagonal);
48024
- contributed = true;
48025
- }
48026
- if (contributed) trackCount++;
48027
- }
48028
- return {
48029
- speeds,
48030
- intervals,
48031
- diagonals,
48032
- trackCount,
48033
- tracksRead: read.length
48034
- };
48035
- }
48036
- function measure$1(samples) {
48037
- return {
48038
- trackCount: samples.trackCount,
48039
- sampleCount: samples.speeds.length,
48040
- p25: quantileOf(samples.speeds, .25),
48041
- p50: quantileOf(samples.speeds, .5),
48042
- p90: quantileOf(samples.speeds, .9),
48043
- medianIntervalSec: quantileOf(samples.intervals, .5),
48044
- medianSubjectDiagonalPx: quantileOf(samples.diagonals, .5)
48045
- };
48046
- }
48047
- /**
48048
- * Choose this camera's stillness speed bar, or refuse with a named reason.
48049
- *
48050
- * The order of the gates is the order of the questions: is there anything at
48051
- * all, is it enough tracks, is it enough samples, and did anything on this
48052
- * camera ever actually move. The last one is not pedantry — the whole method
48053
- * scales the bar against the camera's own travel, so with no travel there is
48054
- * nothing to scale against and the default is the honest answer.
48055
- */
48056
- function calibrateStillnessSpeed(samples, limits) {
48057
- const measurement = measure$1(samples);
48058
- if (samples.tracksRead === 0) return {
48059
- ok: false,
48060
- reason: "no-tracks",
48061
- measurement
48062
- };
48063
- if (samples.trackCount < limits.minTracks) return {
48064
- ok: false,
48065
- reason: "too-few-tracks",
48066
- measurement
48067
- };
48068
- if (samples.speeds.length < limits.minSamples) return {
48069
- ok: false,
48070
- reason: "too-few-samples",
48071
- measurement
48072
- };
48073
- if (!(measurement.p90 > 0)) return {
48074
- ok: false,
48075
- reason: "no-motion-evidence",
48076
- measurement
48077
- };
48078
- const rawBar = quantileOf(samples.speeds, limits.quantile);
48079
- const floor = measurement.p90 * limits.floorFactor;
48080
- const ceiling = measurement.p90 * limits.ceilFactor;
48081
- let bar = rawBar;
48082
- let clampedBy = "none";
48083
- if (bar < floor) {
48084
- bar = floor;
48085
- clampedBy = "floor";
48086
- } else if (bar > ceiling) {
48087
- bar = ceiling;
48088
- clampedBy = "ceiling";
48089
- }
48090
- if (bar < limits.minBar) {
48091
- bar = limits.minBar;
48092
- clampedBy = "min";
48093
- } else if (bar > limits.maxBar) {
48094
- bar = limits.maxBar;
48095
- clampedBy = "max";
48096
- }
48097
- return {
48098
- ok: true,
48099
- bar,
48100
- rawBar,
48101
- clampedBy,
48102
- measurement
48103
- };
48104
- }
48105
- /** One line an operator can read: what was measured, from how much, and what
48106
- * rule produced the stored number. Kept short — it rides the settings blob. */
48107
- function describeStillnessCalibration(outcome) {
48108
- const m = outcome.measurement;
48109
- const n = (v) => Number.isFinite(v) ? v.toFixed(4) : "unknown";
48110
- const evidence = `${m.sampleCount} samples / ${m.trackCount} tracks, p25 ${n(m.p25)}, p50 ${n(m.p50)}, p90 ${n(m.p90)} subject-diagonals/s`;
48111
- if (!outcome.ok) return `refused (${outcome.reason}) — ${evidence}`;
48112
- return `bar ${n(outcome.bar)} (raw ${n(outcome.rawBar)}, clamp ${outcome.clampedBy}) — ${evidence}`;
48113
- }
48114
- //#endregion
48115
- //#region src/pipeline-analytics/stillness-settings.ts
48116
- /**
48117
- * Per-device STILLNESS SCALE — the one number that says how fast a subject on
48118
- * THIS camera has to move before it is travelling.
48119
- *
48120
- * A threshold belongs to whoever GENERATES the event, per device, never baked
48121
- * into a classifier default. The judgement this feeds is
48122
- * `StateAnalyzer.speedSaysSettled`; the measurement behind the number is
48123
- * `pipeline/tracker/stillness-scale.ts`, which also carries the live
48124
- * measurement showing why one number cannot serve a 30 m driveway and a 2 m
48125
- * doorway.
48126
- *
48127
- * Cascade and shape mirror `stationary-settings` / `media-settings`: a
48128
- * per-device override on top of the default, resolved per FIELD, and a parse
48129
- * that never throws on a bad blob.
48130
- *
48131
- * **Absent behaves exactly as today.** The device store is a flat numeric blob
48132
- * with no room for "unset", so the stored form of absent is `0` — and `0` is
48133
- * not a bar anyone could want (it would mean nothing on this camera is ever
48134
- * still) and not a value the calibration can produce (`minBar`). The decision
48135
- * layer never sees it: {@link resolveStillSpeedFracPerSec} maps it to
48136
- * `undefined`, and `undefined` is what leaves `StateAnalyzer` on its original
48137
- * absolute `velocityThreshold` path.
48138
- */
48139
- var StillnessSettingsSchema = object({
48140
- /**
48141
- * The camera's "not travelling" bar: centroid speed as a fraction of the
48142
- * SUBJECT's own box diagonal, per second. `0` = not calibrated (see above).
48143
- *
48144
- * Measured p50 on the live hub 2026-09-19, for scale: 0.0136 (device 592,
48145
- * close view, mostly parked subjects) to 0.3081 (device 615, far view, only
48146
- * transits).
48147
- */
48148
- stillnessSpeedFracPerSec: number().min(0).max(1).default(0),
48149
- /**
48150
- * When the number above was last CHOSEN — by a calibration run or by the
48151
- * operator. `0` = never. Purely a readout; nothing gates on it.
48152
- */
48153
- stillnessCalibratedAt: number().int().min(0).default(0),
48154
- /**
48155
- * What the last calibration run measured and decided, in one line
48156
- * (`describeStillnessCalibration`) — including a REFUSAL and its named
48157
- * reason, because "why is this camera still on the default" has to be
48158
- * answerable without re-running anything.
48159
- */
48160
- stillnessCalibrationNote: string().max(400).default("")
48161
- });
48162
- var STILLNESS_DEFAULTS = StillnessSettingsSchema.parse({});
48163
- /**
48164
- * Resolve a per-device store blob into typed stillness settings. Unknown or
48165
- * invalid fields fall back to the default for that field — including a bar
48166
- * outside the calibration's own rails, which is refused rather than clamped:
48167
- * a number the calibration could never have produced is not evidence about
48168
- * this camera, and silently bending it into range would present it as if it
48169
- * were.
48170
- */
48171
- function resolveStillnessSettings(raw) {
48172
- const pick = (key) => {
48173
- const parsed = StillnessSettingsSchema.shape[key].safeParse(raw[key]);
48174
- return parsed.success ? parsed.data : STILLNESS_DEFAULTS[key];
48175
- };
48176
- return {
48177
- stillnessSpeedFracPerSec: pick("stillnessSpeedFracPerSec"),
48178
- stillnessCalibratedAt: pick("stillnessCalibratedAt"),
48179
- stillnessCalibrationNote: pick("stillnessCalibrationNote")
48180
- };
48181
- }
48182
- /**
48183
- * The bar the decision layer gets: a positive, in-rails number, or
48184
- * `undefined` for "this camera is not calibrated — judge it exactly as
48185
- * before". The ONE place the stored-zero convention is interpreted.
48186
- */
48187
- function resolveStillSpeedFracPerSec(settings) {
48188
- const bar = settings.stillnessSpeedFracPerSec;
48189
- if (!(bar >= .001) || bar > 1) return void 0;
48190
- return bar;
48191
- }
48192
- //#endregion
48193
48338
  //#region src/pipeline-analytics/viewer-settings-legacy-adoption.ts
48194
48339
  /**
48195
48340
  * @durable class=config owner=pipeline-analytics
@@ -48367,6 +48512,230 @@ async function adoptLegacyViewerSettingsSnapshots(deps) {
48367
48512
  return retired;
48368
48513
  }
48369
48514
  /**
48515
+ * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
48516
+ *
48517
+ * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
48518
+ * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
48519
+ * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
48520
+ * one encode per event over a ~12 s window, not a permanent transcode child
48521
+ * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
48522
+ * it was measured on a real 615 720p cut before being chosen: **0.23 s of
48523
+ * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
48524
+ * The re-encode is smaller than what it replaces.
48525
+ *
48526
+ * So both attachments now agree on the timeline as well as on the window: one
48527
+ * clip, one rate, two containers.
48528
+ */
48529
+ var DEFAULT_SPEED = 4;
48530
+ /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
48531
+ * at or below 1080p is an identity scale and the broker copies it. */
48532
+ var RING_MP4_MAX_WIDTH = 1920;
48533
+ /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
48534
+ var RING_MP4_FPS = 15;
48535
+ var NO_MEDIA = {
48536
+ mp4: null,
48537
+ gif: null,
48538
+ source: "none",
48539
+ startOffsetMs: null,
48540
+ endOffsetMs: null,
48541
+ profile: null,
48542
+ video: null
48543
+ };
48544
+ var EventMediaService = class {
48545
+ deps;
48546
+ constructor(deps) {
48547
+ this.deps = deps;
48548
+ }
48549
+ /**
48550
+ * Cut this event. Never throws — a notification that lost its media is still
48551
+ * a notification, and every branch that drops it logs why.
48552
+ */
48553
+ async cut(request) {
48554
+ if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
48555
+ const produced = await this.produce(request);
48556
+ if (produced !== null) return produced;
48557
+ return this.fallbackToRing(request);
48558
+ }
48559
+ async produce(request) {
48560
+ const deviceId = request.deviceId;
48561
+ const log = this.deps.logger;
48562
+ const kinds = [];
48563
+ if (request.wantMp4) kinds.push("mp4");
48564
+ if (request.wantGif) kinds.push("gif");
48565
+ let production;
48566
+ try {
48567
+ production = await this.deps.produce({
48568
+ deviceId,
48569
+ aroundMs: request.aroundMs,
48570
+ preSeconds: request.preRollSec,
48571
+ postSeconds: request.postRollSec,
48572
+ kinds,
48573
+ gifMaxWidth: 640,
48574
+ gifFps: 12,
48575
+ gifSpeed: 4,
48576
+ speed: request.speed ?? DEFAULT_SPEED,
48577
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
48578
+ });
48579
+ } catch (err) {
48580
+ log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
48581
+ tags: { deviceId },
48582
+ meta: { error: err instanceof Error ? err.message : String(err) }
48583
+ });
48584
+ return null;
48585
+ }
48586
+ const mp4 = await this.redeem(deviceId, production.media, "mp4");
48587
+ const gif = await this.redeem(deviceId, production.media, "gif");
48588
+ if (mp4 === null && gif === null) {
48589
+ log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
48590
+ tags: { deviceId },
48591
+ meta: {
48592
+ kinds: kinds.join(","),
48593
+ profile: production.profile
48594
+ }
48595
+ });
48596
+ return null;
48597
+ }
48598
+ const startOffsetMs = production.coverage.fromTs - request.aroundMs;
48599
+ const endOffsetMs = production.coverage.toTs - request.aroundMs;
48600
+ log.info("nc media: one production, every attachment", {
48601
+ tags: { deviceId },
48602
+ meta: {
48603
+ profile: production.profile,
48604
+ video: production.video,
48605
+ mp4Bytes: mp4?.byteLength ?? null,
48606
+ gifBytes: gif?.byteLength ?? null,
48607
+ startOffsetMs,
48608
+ endOffsetMs,
48609
+ sharedWindow: true
48610
+ }
48611
+ });
48612
+ return {
48613
+ mp4,
48614
+ gif,
48615
+ source: "produced",
48616
+ startOffsetMs,
48617
+ endOffsetMs,
48618
+ profile: production.profile,
48619
+ video: production.video
48620
+ };
48621
+ }
48622
+ /**
48623
+ * Redeem one artifact handle at the node that produced it.
48624
+ *
48625
+ * `null` is not an error here — a production simply may not carry the kind
48626
+ * (a gif whose derive failed says so on the broker's own log line). A handle
48627
+ * that FAILS to redeem is different and is logged, because it means the bytes
48628
+ * existed and did not arrive.
48629
+ */
48630
+ async redeem(deviceId, media, kind) {
48631
+ const artifact = media.find((m) => m.kind === kind);
48632
+ if (artifact === void 0) return null;
48633
+ try {
48634
+ const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
48635
+ if (res === null) {
48636
+ this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
48637
+ tags: { deviceId },
48638
+ meta: {
48639
+ kind,
48640
+ handle: artifact.handle,
48641
+ nodeId: artifact.nodeId
48642
+ }
48643
+ });
48644
+ return null;
48645
+ }
48646
+ const buf = Buffer.from(res.base64, "base64");
48647
+ if (buf.byteLength === 0) return null;
48648
+ const bytes = new Uint8Array(buf.byteLength);
48649
+ bytes.set(buf);
48650
+ return bytes;
48651
+ } catch (err) {
48652
+ this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
48653
+ tags: { deviceId },
48654
+ meta: {
48655
+ kind,
48656
+ handle: artifact.handle,
48657
+ nodeId: artifact.nodeId,
48658
+ error: err instanceof Error ? err.message : String(err)
48659
+ }
48660
+ });
48661
+ return null;
48662
+ }
48663
+ }
48664
+ /**
48665
+ * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
48666
+ *
48667
+ * Weaker than a production and deliberately so — the coherence here rests on
48668
+ * the two calls being given IDENTICAL parameters rather than on there being
48669
+ * one render. That is defensible because the ring only ever grows forward and
48670
+ * `windowAround` selects by wall clock around the same instant, so two calls
48671
+ * seconds apart choose the same packets; the one thing that could differ is
48672
+ * the rendition, so the profile is pinned to whatever the FIRST call actually
48673
+ * used and handed to the second.
48674
+ *
48675
+ * It exists for one reason: a hub whose `@camstack/server` predates the
48676
+ * production method must not stop attaching footage, and three of the four
48677
+ * live rules on this install ask for a gif and nothing else.
48678
+ */
48679
+ async fallbackToRing(request) {
48680
+ const deviceId = request.deviceId;
48681
+ const speed = request.speed ?? DEFAULT_SPEED;
48682
+ const window = {
48683
+ deviceId,
48684
+ aroundMs: request.aroundMs,
48685
+ preRollSec: request.preRollSec,
48686
+ postRollSec: request.postRollSec,
48687
+ speed,
48688
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
48689
+ };
48690
+ try {
48691
+ const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
48692
+ ...window,
48693
+ format: "mp4",
48694
+ maxWidth: RING_MP4_MAX_WIDTH,
48695
+ fps: RING_MP4_FPS
48696
+ }) : null;
48697
+ const gif = request.wantGif ? await this.deps.renderRingClip({
48698
+ ...window,
48699
+ format: "gif",
48700
+ maxWidth: 640,
48701
+ fps: 12 / 4,
48702
+ speed: 4
48703
+ }) : null;
48704
+ if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
48705
+ this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
48706
+ tags: { deviceId },
48707
+ meta: { aroundMs: request.aroundMs }
48708
+ });
48709
+ return NO_MEDIA;
48710
+ }
48711
+ this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
48712
+ tags: { deviceId },
48713
+ meta: {
48714
+ mp4Bytes: mp4?.byteLength ?? null,
48715
+ gifBytes: gif?.byteLength ?? null,
48716
+ profile: request.profile ?? null,
48717
+ sharedWindow: "by-parameters"
48718
+ }
48719
+ });
48720
+ return {
48721
+ mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
48722
+ gif: gif !== null && gif.byteLength > 0 ? gif : null,
48723
+ source: "ring-fallback",
48724
+ startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
48725
+ endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
48726
+ profile: request.profile ?? null,
48727
+ video: null
48728
+ };
48729
+ } catch (err) {
48730
+ this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
48731
+ tags: { deviceId },
48732
+ meta: { error: err instanceof Error ? err.message : String(err) }
48733
+ });
48734
+ return NO_MEDIA;
48735
+ }
48736
+ }
48737
+ };
48738
+ /**
48370
48739
  * How long the recorder keeps the file we are about to read and delete.
48371
48740
  *
48372
48741
  * `maxLifeMs` is `z.number().int().positive()` on the capability, so "do not
@@ -48620,230 +48989,6 @@ function decodeBase64(base64) {
48620
48989
  return null;
48621
48990
  }
48622
48991
  }
48623
- /**
48624
- * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
48625
- *
48626
- * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
48627
- * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
48628
- * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
48629
- * one encode per event over a ~12 s window, not a permanent transcode child
48630
- * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
48631
- * it was measured on a real 615 720p cut before being chosen: **0.23 s of
48632
- * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
48633
- * The re-encode is smaller than what it replaces.
48634
- *
48635
- * So both attachments now agree on the timeline as well as on the window: one
48636
- * clip, one rate, two containers.
48637
- */
48638
- var DEFAULT_SPEED = 4;
48639
- /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
48640
- * at or below 1080p is an identity scale and the broker copies it. */
48641
- var RING_MP4_MAX_WIDTH = 1920;
48642
- /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
48643
- var RING_MP4_FPS = 15;
48644
- var NO_MEDIA = {
48645
- mp4: null,
48646
- gif: null,
48647
- source: "none",
48648
- startOffsetMs: null,
48649
- endOffsetMs: null,
48650
- profile: null,
48651
- video: null
48652
- };
48653
- var EventMediaService = class {
48654
- deps;
48655
- constructor(deps) {
48656
- this.deps = deps;
48657
- }
48658
- /**
48659
- * Cut this event. Never throws — a notification that lost its media is still
48660
- * a notification, and every branch that drops it logs why.
48661
- */
48662
- async cut(request) {
48663
- if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
48664
- const produced = await this.produce(request);
48665
- if (produced !== null) return produced;
48666
- return this.fallbackToRing(request);
48667
- }
48668
- async produce(request) {
48669
- const deviceId = request.deviceId;
48670
- const log = this.deps.logger;
48671
- const kinds = [];
48672
- if (request.wantMp4) kinds.push("mp4");
48673
- if (request.wantGif) kinds.push("gif");
48674
- let production;
48675
- try {
48676
- production = await this.deps.produce({
48677
- deviceId,
48678
- aroundMs: request.aroundMs,
48679
- preSeconds: request.preRollSec,
48680
- postSeconds: request.postRollSec,
48681
- kinds,
48682
- gifMaxWidth: 640,
48683
- gifFps: 12,
48684
- gifSpeed: 4,
48685
- speed: request.speed ?? DEFAULT_SPEED,
48686
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48687
- });
48688
- } catch (err) {
48689
- log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
48690
- tags: { deviceId },
48691
- meta: { error: err instanceof Error ? err.message : String(err) }
48692
- });
48693
- return null;
48694
- }
48695
- const mp4 = await this.redeem(deviceId, production.media, "mp4");
48696
- const gif = await this.redeem(deviceId, production.media, "gif");
48697
- if (mp4 === null && gif === null) {
48698
- log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
48699
- tags: { deviceId },
48700
- meta: {
48701
- kinds: kinds.join(","),
48702
- profile: production.profile
48703
- }
48704
- });
48705
- return null;
48706
- }
48707
- const startOffsetMs = production.coverage.fromTs - request.aroundMs;
48708
- const endOffsetMs = production.coverage.toTs - request.aroundMs;
48709
- log.info("nc media: one production, every attachment", {
48710
- tags: { deviceId },
48711
- meta: {
48712
- profile: production.profile,
48713
- video: production.video,
48714
- mp4Bytes: mp4?.byteLength ?? null,
48715
- gifBytes: gif?.byteLength ?? null,
48716
- startOffsetMs,
48717
- endOffsetMs,
48718
- sharedWindow: true
48719
- }
48720
- });
48721
- return {
48722
- mp4,
48723
- gif,
48724
- source: "produced",
48725
- startOffsetMs,
48726
- endOffsetMs,
48727
- profile: production.profile,
48728
- video: production.video
48729
- };
48730
- }
48731
- /**
48732
- * Redeem one artifact handle at the node that produced it.
48733
- *
48734
- * `null` is not an error here — a production simply may not carry the kind
48735
- * (a gif whose derive failed says so on the broker's own log line). A handle
48736
- * that FAILS to redeem is different and is logged, because it means the bytes
48737
- * existed and did not arrive.
48738
- */
48739
- async redeem(deviceId, media, kind) {
48740
- const artifact = media.find((m) => m.kind === kind);
48741
- if (artifact === void 0) return null;
48742
- try {
48743
- const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
48744
- if (res === null) {
48745
- this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
48746
- tags: { deviceId },
48747
- meta: {
48748
- kind,
48749
- handle: artifact.handle,
48750
- nodeId: artifact.nodeId
48751
- }
48752
- });
48753
- return null;
48754
- }
48755
- const buf = Buffer.from(res.base64, "base64");
48756
- if (buf.byteLength === 0) return null;
48757
- const bytes = new Uint8Array(buf.byteLength);
48758
- bytes.set(buf);
48759
- return bytes;
48760
- } catch (err) {
48761
- this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
48762
- tags: { deviceId },
48763
- meta: {
48764
- kind,
48765
- handle: artifact.handle,
48766
- nodeId: artifact.nodeId,
48767
- error: err instanceof Error ? err.message : String(err)
48768
- }
48769
- });
48770
- return null;
48771
- }
48772
- }
48773
- /**
48774
- * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
48775
- *
48776
- * Weaker than a production and deliberately so — the coherence here rests on
48777
- * the two calls being given IDENTICAL parameters rather than on there being
48778
- * one render. That is defensible because the ring only ever grows forward and
48779
- * `windowAround` selects by wall clock around the same instant, so two calls
48780
- * seconds apart choose the same packets; the one thing that could differ is
48781
- * the rendition, so the profile is pinned to whatever the FIRST call actually
48782
- * used and handed to the second.
48783
- *
48784
- * It exists for one reason: a hub whose `@camstack/server` predates the
48785
- * production method must not stop attaching footage, and three of the four
48786
- * live rules on this install ask for a gif and nothing else.
48787
- */
48788
- async fallbackToRing(request) {
48789
- const deviceId = request.deviceId;
48790
- const speed = request.speed ?? DEFAULT_SPEED;
48791
- const window = {
48792
- deviceId,
48793
- aroundMs: request.aroundMs,
48794
- preRollSec: request.preRollSec,
48795
- postRollSec: request.postRollSec,
48796
- speed,
48797
- ...request.profile !== void 0 ? { profile: request.profile } : {}
48798
- };
48799
- try {
48800
- const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
48801
- ...window,
48802
- format: "mp4",
48803
- maxWidth: RING_MP4_MAX_WIDTH,
48804
- fps: RING_MP4_FPS
48805
- }) : null;
48806
- const gif = request.wantGif ? await this.deps.renderRingClip({
48807
- ...window,
48808
- format: "gif",
48809
- maxWidth: 640,
48810
- fps: 12 / 4,
48811
- speed: 4
48812
- }) : null;
48813
- if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
48814
- this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
48815
- tags: { deviceId },
48816
- meta: { aroundMs: request.aroundMs }
48817
- });
48818
- return NO_MEDIA;
48819
- }
48820
- this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
48821
- tags: { deviceId },
48822
- meta: {
48823
- mp4Bytes: mp4?.byteLength ?? null,
48824
- gifBytes: gif?.byteLength ?? null,
48825
- profile: request.profile ?? null,
48826
- sharedWindow: "by-parameters"
48827
- }
48828
- });
48829
- return {
48830
- mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
48831
- gif: gif !== null && gif.byteLength > 0 ? gif : null,
48832
- source: "ring-fallback",
48833
- startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
48834
- endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
48835
- profile: request.profile ?? null,
48836
- video: null
48837
- };
48838
- } catch (err) {
48839
- this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
48840
- tags: { deviceId },
48841
- meta: { error: err instanceof Error ? err.message : String(err) }
48842
- });
48843
- return NO_MEDIA;
48844
- }
48845
- }
48846
- };
48847
48992
  //#endregion
48848
48993
  //#region src/shared/frame/crop-extractor.ts
48849
48994
  /**
@@ -57114,6 +57259,41 @@ function resolveMediaSettings(raw) {
57114
57259
  snapshotMaxIdleMs: pick("snapshotMaxIdleMs")
57115
57260
  };
57116
57261
  }
57262
+ //#endregion
57263
+ //#region src/pipeline-analytics/our-recording-availability.ts
57264
+ /**
57265
+ * Coverage of OUR archive, asked of the `recording` collection by name.
57266
+ *
57267
+ * `recording` became a device collection on 2026-09-24 (D625): a camera may
57268
+ * have several sources of recorded coverage and every read names one. Every
57269
+ * read in this addon wants the same one — the footage OUR recorder wrote,
57270
+ * because everything downstream of it (a recorded clip cut, a timelapse, the
57271
+ * earliest-footage floor of a retention sweep) is about bytes we hold and can
57272
+ * export. A camera's own SD card is not an answer to any of those questions.
57273
+ *
57274
+ * It also owns the `read: 'unreadable'` rule, in one place: a read that FAILED
57275
+ * throws here rather than arriving downstream as an empty range list. Every
57276
+ * caller of this module treats "no ranges" as "recording is off for this
57277
+ * camera" and acts on it — dropping the attachment, skipping the render,
57278
+ * lowering the sweep floor — so folding a failed read into that shape would
57279
+ * make an unreachable recorder look exactly like a camera nobody records
57280
+ * (D393). The callers already handle a throw and log it as a read failure.
57281
+ */
57282
+ /** Our coverage of `[fromMs, toMs)`, or a throw naming the failed read. */
57283
+ async function ourRecordingAvailability(api, input) {
57284
+ const answer = await api.recording.getAvailability.query({
57285
+ deviceId: input.deviceId,
57286
+ provider: RECORDING_SOURCE_CAMSTACK_ADDON,
57287
+ fromMs: input.fromMs,
57288
+ toMs: input.toMs,
57289
+ ...input.profile !== void 0 ? { profile: input.profile } : {}
57290
+ });
57291
+ if (answer.read === "unreadable") throw new Error(`recording.getAvailability: the CamStack archive was unreadable for device ${String(input.deviceId)} — no claim is made about its footage`);
57292
+ return {
57293
+ ranges: answer.ranges,
57294
+ profilesWithFootage: answer.profilesWithFootage
57295
+ };
57296
+ }
57117
57297
  function toDetectionBbox(bbox) {
57118
57298
  return {
57119
57299
  x: bbox.x,
@@ -58405,6 +58585,17 @@ function resolveSearchThumbnailUrl(input) {
58405
58585
  return `${input.baseUrl}/${encodeURIComponent(id)}`;
58406
58586
  }
58407
58587
  //#endregion
58588
+ //#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
58589
+ /**
58590
+ * May a suppression verdict RETRACT this birth?
58591
+ *
58592
+ * `false` means hold: keep the track, log the held verdict, and let the
58593
+ * ordinary lifetime decide. It never means "confirmed".
58594
+ */
58595
+ function suppressionMayRetract(source) {
58596
+ return source !== "onboard";
58597
+ }
58598
+ //#endregion
58408
58599
  //#region src/pipeline-analytics/pipeline/package/package-area-gate.ts
58409
58600
  /**
58410
58601
  * Decide whether a package-class box is plausibly parcel-sized.
@@ -61098,65 +61289,6 @@ var StationaryEvidenceLedger = class {
61098
61289
  for (const row of excess) this.forgetKey(row.key);
61099
61290
  }
61100
61291
  };
61101
- //#endregion
61102
- //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
61103
- function planStationaryTrackDisposal(input) {
61104
- const trackIds = [input.liveTrackId];
61105
- if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
61106
- const keepMediaIds = /* @__PURE__ */ new Set();
61107
- if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
61108
- return {
61109
- trackIds,
61110
- keepMediaIds
61111
- };
61112
- }
61113
- /**
61114
- * Run the plan. The live track is dropped from RAM FIRST so it can never be
61115
- * persisted by a sweep racing this call; the cascade then removes what every
61116
- * track owned, sparing the entry's key frame, and deletes the row (a no-op for
61117
- * the never-persisted live track).
61118
- *
61119
- * Never silent: the line names the entity, what went and what was kept — a
61120
- * deleted track is dropped work, and the operator asked for it by name.
61121
- */
61122
- async function disposeStationaryTracks(deps, plan) {
61123
- const [liveTrackId] = plan.trackIds;
61124
- if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
61125
- const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
61126
- const result = await runTrackCascadeBatch({
61127
- registry: [...deps.leaves, sparingMedia],
61128
- trackStore: deps.trackStore,
61129
- deviceId: deps.deviceId,
61130
- onTrackCleanup: deps.onTrackCleanup,
61131
- onFailure: (trackId, err) => {
61132
- deps.logger.warn("stationary promotion: a source track could not be disposed of", {
61133
- tags: { deviceId: deps.deviceId },
61134
- meta: {
61135
- deviceId: deps.deviceId,
61136
- entryId: deps.entryId,
61137
- trackId,
61138
- error: String(err)
61139
- }
61140
- });
61141
- }
61142
- }, plan.trackIds);
61143
- deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
61144
- tags: { deviceId: deps.deviceId },
61145
- meta: {
61146
- deviceId: deps.deviceId,
61147
- entryId: deps.entryId,
61148
- trackIds: plan.trackIds,
61149
- keptMediaIds: [...plan.keepMediaIds],
61150
- deleted: result.deleted,
61151
- failed: result.failed,
61152
- mediaRows: result.tally.media
61153
- }
61154
- });
61155
- return {
61156
- deleted: result.deleted,
61157
- failed: result.failed
61158
- };
61159
- }
61160
61292
  /**
61161
61293
  * How long the same weak box must keep being seen before it may re-anchor.
61162
61294
  *
@@ -62098,6 +62230,97 @@ function rowToEntry(id, data) {
62098
62230
  };
62099
62231
  }
62100
62232
  //#endregion
62233
+ //#region src/pipeline-analytics/pipeline/stationary/stationary-track-disposal.ts
62234
+ function planStationaryTrackDisposal(input) {
62235
+ const trackIds = [input.liveTrackId];
62236
+ if (input.sourceTrackId !== void 0 && input.sourceTrackId !== input.liveTrackId) trackIds.push(input.sourceTrackId);
62237
+ const keepMediaIds = /* @__PURE__ */ new Set();
62238
+ if (input.entryKeyFrameMediaId !== void 0) keepMediaIds.add(input.entryKeyFrameMediaId);
62239
+ return {
62240
+ trackIds,
62241
+ keepMediaIds
62242
+ };
62243
+ }
62244
+ /**
62245
+ * Run the plan. The live track is dropped from RAM FIRST so it can never be
62246
+ * persisted by a sweep racing this call; the cascade then removes what every
62247
+ * track owned, sparing the entry's key frame, and deletes the row (a no-op for
62248
+ * the never-persisted live track).
62249
+ *
62250
+ * Never silent: the line names the entity, what went and what was kept — a
62251
+ * deleted track is dropped work, and the operator asked for it by name.
62252
+ */
62253
+ async function disposeStationaryTracks(deps, plan) {
62254
+ const [liveTrackId] = plan.trackIds;
62255
+ if (liveTrackId !== void 0) deps.trackStore.dropActive(liveTrackId);
62256
+ const sparingMedia = { deleteByTracks: (trackIds) => deps.media.deleteByTracks(trackIds, { spare: plan.keepMediaIds }) };
62257
+ const result = await runTrackCascadeBatch({
62258
+ registry: [...deps.leaves, sparingMedia],
62259
+ trackStore: deps.trackStore,
62260
+ deviceId: deps.deviceId,
62261
+ onTrackCleanup: deps.onTrackCleanup,
62262
+ onFailure: (trackId, err) => {
62263
+ deps.logger.warn("stationary promotion: a source track could not be disposed of", {
62264
+ tags: { deviceId: deps.deviceId },
62265
+ meta: {
62266
+ deviceId: deps.deviceId,
62267
+ entryId: deps.entryId,
62268
+ trackId,
62269
+ error: String(err)
62270
+ }
62271
+ });
62272
+ }
62273
+ }, plan.trackIds);
62274
+ deps.logger.info("stationary promotion: source tracks discarded — the entity keeps its key frame", {
62275
+ tags: { deviceId: deps.deviceId },
62276
+ meta: {
62277
+ deviceId: deps.deviceId,
62278
+ entryId: deps.entryId,
62279
+ trackIds: plan.trackIds,
62280
+ keptMediaIds: [...plan.keepMediaIds],
62281
+ deleted: result.deleted,
62282
+ failed: result.failed,
62283
+ mediaRows: result.tally.media
62284
+ }
62285
+ });
62286
+ return {
62287
+ deleted: result.deleted,
62288
+ failed: result.failed
62289
+ };
62290
+ }
62291
+ //#endregion
62292
+ //#region src/pipeline-analytics/pipeline/stationary-planes.ts
62293
+ /**
62294
+ * WHICH detection planes carry the stationary machinery.
62295
+ *
62296
+ * A plane qualifies when it produces OBJECT BOXES on decoded frames — because
62297
+ * that is what the registry needs to decide an object is parked: a box, a
62298
+ * class, and an observed clock that only advances while frames flow.
62299
+ *
62300
+ * Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
62301
+ * camera's own firmware, and the boxes it produces are paired with a decoded
62302
+ * frame in that frame's pixel space — the same plane, entered by a different
62303
+ * door. `sensor` and `audio` do not: they carry no geometry to park.
62304
+ *
62305
+ * It used to be `source === 'pipeline'`, in five places, each explaining that
62306
+ * the gate "runs on that plane". The reason given was WHERE the flood it was
62307
+ * written for had been measured (617, the ML plane), never an argument that the
62308
+ * firmware's boxes were unfit. The cost of the accident, on device 640: a still
62309
+ * object is never promoted, so it holds `hasLiveTrack` true for as long as it
62310
+ * sits there, and every detection session runs to its 120 s cap instead of
62311
+ * closing on cooldown — `closed at the max-hold cap with a subject still
62312
+ * present`. The camera's lazy stream is then torn down and rebuilt on the next
62313
+ * detection, on a battery camera.
62314
+ *
62315
+ * One predicate and not five comparisons, because five copies of a rule drift:
62316
+ * this repo has already paid for that with two authorities on one function
62317
+ * (D62).
62318
+ */
62319
+ /** Does this detection plane run the stationary registry, gate and promotion? */
62320
+ function planeRunsStationary(source) {
62321
+ return source === "pipeline" || source === "onboard";
62322
+ }
62323
+ //#endregion
62101
62324
  //#region src/pipeline-analytics/pipeline/suppressed-births.ts
62102
62325
  /**
62103
62326
  * Memory of births the confirmation gate rejected, per device.
@@ -63013,49 +63236,6 @@ function hasKeyFrame(source) {
63013
63236
  return source.keyFrame !== null && source.bbox !== null;
63014
63237
  }
63015
63238
  //#endregion
63016
- //#region src/pipeline-analytics/pipeline/stationary-planes.ts
63017
- /**
63018
- * WHICH detection planes carry the stationary machinery.
63019
- *
63020
- * A plane qualifies when it produces OBJECT BOXES on decoded frames — because
63021
- * that is what the registry needs to decide an object is parked: a box, a
63022
- * class, and an observed clock that only advances while frames flow.
63023
- *
63024
- * Both root detectors qualify. `pipeline` is the ML tree; `onboard` is the
63025
- * camera's own firmware, and the boxes it produces are paired with a decoded
63026
- * frame in that frame's pixel space — the same plane, entered by a different
63027
- * door. `sensor` and `audio` do not: they carry no geometry to park.
63028
- *
63029
- * It used to be `source === 'pipeline'`, in five places, each explaining that
63030
- * the gate "runs on that plane". The reason given was WHERE the flood it was
63031
- * written for had been measured (617, the ML plane), never an argument that the
63032
- * firmware's boxes were unfit. The cost of the accident, on device 640: a still
63033
- * object is never promoted, so it holds `hasLiveTrack` true for as long as it
63034
- * sits there, and every detection session runs to its 120 s cap instead of
63035
- * closing on cooldown — `closed at the max-hold cap with a subject still
63036
- * present`. The camera's lazy stream is then torn down and rebuilt on the next
63037
- * detection, on a battery camera.
63038
- *
63039
- * One predicate and not five comparisons, because five copies of a rule drift:
63040
- * this repo has already paid for that with two authorities on one function
63041
- * (D62).
63042
- */
63043
- /** Does this detection plane run the stationary registry, gate and promotion? */
63044
- function planeRunsStationary(source) {
63045
- return source === "pipeline" || source === "onboard";
63046
- }
63047
- //#endregion
63048
- //#region src/pipeline-analytics/pipeline/onboard-birth-authority.ts
63049
- /**
63050
- * May a suppression verdict RETRACT this birth?
63051
- *
63052
- * `false` means hold: keep the track, log the held verdict, and let the
63053
- * ordinary lifetime decide. It never means "confirmed".
63054
- */
63055
- function suppressionMayRetract(source) {
63056
- return source !== "onboard";
63057
- }
63058
- //#endregion
63059
63239
  //#region src/pipeline-analytics/rebuild-source-loader.ts
63060
63240
  /**
63061
63241
  * Assemble one track's rebuild inputs, reading at most ONE blob.
@@ -71627,9 +71807,111 @@ async function projectSensorMarkers(deps, data, timestamp) {
71627
71807
  }
71628
71808
  return landed;
71629
71809
  }
71810
+ //#endregion
71811
+ //#region src/pipeline-analytics/services/sensor-transition-gate.ts
71812
+ /**
71813
+ * The gate between "a `DeviceStateChanged` arrived for a mapped sensor cap"
71814
+ * and "the sensor actually CHANGED".
71815
+ *
71816
+ * ## What this fixes
71817
+ *
71818
+ * `handleSensorStateChanged` treated the ARRIVAL of the bus event as the
71819
+ * assertion, and never looked at the slice. It cannot: the kernel mirror
71820
+ * (`device-state-mirror.ts` `applySingleCapUpdate`) diffs the whole SLICE, and
71821
+ * a contact slice carries `lastChangedAt` beside `entryOpen`. Anything that
71822
+ * moves the timestamp without moving the boolean is a `DeviceStateChanged`,
71823
+ * and downstream it became a `SensorEvent` row and a synthetic `contact` track
71824
+ * on the linked camera's timeline.
71825
+ *
71826
+ * Measured on camera 615 / contact sensor 4131, 2026-08-29→30: **30 of 35**
71827
+ * contact events were one of two non-events.
71828
+ *
71829
+ * - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
71830
+ * (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
71831
+ * provider drops the `unavailable` push and writes the recovery, whose
71832
+ * `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
71833
+ * `last_changed`. The mirror diffs, and emits.
71834
+ * - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
71835
+ * `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
71836
+ * real state lands seconds later with a `lastChangedAt` hours old. Two
71837
+ * emissions, no transition.
71838
+ *
71839
+ * ## Why the predicate is imported and not written here
71840
+ *
71841
+ * `evaluateSensorEdge` (`@camstack/types`) is already THE edge predicate: the
71842
+ * virtual doorbell feeds it (`builtins/doorbell/trigger-engine.ts`) and so does
71843
+ * the recorder's sensor trigger (`addon-pipeline` `recorder/sensor-trigger.ts`).
71844
+ * Post-analysis was the third consumer of the same bus event and the only one
71845
+ * that did not. A second predicate here would diverge on the first cap added —
71846
+ * the exact failure [D62](../../../../../docs/decisions/adr-0062.md) names.
71847
+ *
71848
+ * ## Where it deliberately differs from the other two
71849
+ *
71850
+ * The doorbell and the recorder want the RISING edge only. A timeline marker
71851
+ * is not a doorbell: a door CLOSING is an event an operator wants to see, and
71852
+ * the taxonomy has one `contact` kind for both directions. So this gate accepts
71853
+ * `edge: 'rising'` **and** the `falling-edge` verdict, and rejects only the
71854
+ * verdicts that mean "nothing moved" — which is precisely the 30.
71855
+ *
71856
+ * ## Caps with no boolean level pass through untouched
71857
+ *
71858
+ * `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
71859
+ * ten that have a boolean whose edge means something. `doorbell`, `button`,
71860
+ * `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
71861
+ * — their ARRIVAL is the event, and three presses in a row are three markers.
71862
+ * Gating them would re-ship the failure of 2026-08-08, when camera 615's press
71863
+ * produced a row, a ring, a matched rule and no marker.
71864
+ *
71865
+ * ## A cover is a level whose level is a WORD
71866
+ *
71867
+ * `cover` is the sixteenth cap, and it is neither of the above. Its level is
71868
+ * the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
71869
+ * slice also carries `position`, which ticks all the way through a travel and
71870
+ * is republished unchanged by providers that poll. Passed through, every tick
71871
+ * and every republish would write a sensor-event row per linked camera and an
71872
+ * NC evaluation — so it transitions on the WORD only (`WORD_LEVEL_FIELD`):
71873
+ * `closed → opening → open` is two events, a position tick or a same-word
71874
+ * republish is a `no-change` drop.
71875
+ *
71876
+ * Its FIRST sighting follows the booleans' rule (`evaluateSensorEdge`), with
71877
+ * `closed` as the inactive word: `closed` seeds the baseline and is dropped
71878
+ * (`baseline-seeded-inactive`); any other word is an EVENT when the slice's
71879
+ * `lastChangedAt` clears the same floor, `max(startedAt, now - freshness)`,
71880
+ * and is otherwise seeded and dropped (`baseline-seeded-stale-active`). It has
71881
+ * to emit: after a runner respawn the memory is empty and the next slice is
71882
+ * usually the next REAL change, and on a two-word cover (Velux, most HA
71883
+ * covers: `closed`/`open` only) seeding it would lose the whole opening.
71884
+ *
71885
+ * Residual edges, both erring towards silence on the ambiguous case like the
71886
+ * booleans do: a cover that was already open and whose `lastChangedAt` is
71887
+ * older than the floor is not reported (a respawn cannot tell it from a
71888
+ * hydration); and a first sighting whose `lastChangedAt` is fresh only
71889
+ * because a POSITION tick moved it (the slice timestamp is not the word's)
71890
+ * is reported once as if the word had just changed. The alarm needs covers
71891
+ * to be eventful to see one open (D630).
71892
+ *
71893
+ * ## Nothing here logs, and no drop is silent
71894
+ *
71895
+ * The gate is pure towards the world (no bus, no logger, no clock of its own —
71896
+ * the caller brings `now`), like the recorder's router. But it sits on a path
71897
+ * that carries every camera's native `motion` slice at motion rate — ~0.6/s
71898
+ * fleet-wide — so a line per drop would be its own outage. It accumulates
71899
+ * instead, per `(deviceId, capName, reason)`, and the caller drains one summary
71900
+ * per window. A `debug` at 3.3/s that nobody read is how a 100 %-rejection
71901
+ * regime survived three months here.
71902
+ */
71903
+ /**
71904
+ * Caps whose level is a string word rather than a boolean: cap → slice field.
71905
+ * See "A cover is a level whose level is a WORD" above.
71906
+ */
71907
+ var WORD_LEVEL_FIELD = { cover: "state" };
71908
+ /** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
71909
+ var WORD_LEVEL_INACTIVE = { cover: "closed" };
71630
71910
  var SensorTransitionGate = class {
71631
71911
  /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
71632
71912
  lastValues = /* @__PURE__ */ new Map();
71913
+ /** Last observed word of a word-level cap, keyed by `${deviceId}:${capName}`. */
71914
+ lastWords = /* @__PURE__ */ new Map();
71633
71915
  /** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
71634
71916
  drops = /* @__PURE__ */ new Map();
71635
71917
  windowStartedAt;
@@ -71647,10 +71929,13 @@ var SensorTransitionGate = class {
71647
71929
  /**
71648
71930
  * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
71649
71931
  *
71650
- * A cap with no boolean level passes through unconditionally. A cap that HAS
71651
- * one passes only when its boolean moved — in either direction.
71932
+ * A cap with no level passes through unconditionally. A cap that HAS one
71933
+ * passes only when its level moved — a boolean in either direction, or a
71934
+ * word-level cap's word changing.
71652
71935
  */
71653
71936
  observe(deviceId, capName, slice) {
71937
+ const wordField = WORD_LEVEL_FIELD[capName];
71938
+ if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
71654
71939
  if (!isSourceCap(capName)) return { changed: true };
71655
71940
  const key = `${deviceId}:${capName}`;
71656
71941
  const verdict = evaluateSensorEdge({
@@ -71688,6 +71973,40 @@ var SensorTransitionGate = class {
71688
71973
  this.drops.clear();
71689
71974
  return out;
71690
71975
  }
71976
+ /**
71977
+ * A word-level cap: a transition is the word changing. The memory is
71978
+ * written only when the slice CARRIED a word, for the same reason as the
71979
+ * booleans — a malformed slice must not become a baseline.
71980
+ */
71981
+ observeWord(deviceId, capName, slice, wordField) {
71982
+ const raw = slice[wordField];
71983
+ if (typeof raw !== "string" || raw.length === 0) return this.drop(deviceId, capName, "non-word-value");
71984
+ const key = `${deviceId}:${capName}`;
71985
+ const prior = this.lastWords.get(key);
71986
+ this.lastWords.set(key, raw);
71987
+ if (prior === void 0) return this.firstWordSighting(deviceId, capName, slice, raw);
71988
+ if (prior === raw) return this.drop(deviceId, capName, "no-change");
71989
+ return { changed: true };
71990
+ }
71991
+ /**
71992
+ * The booleans' first-sighting rule, on a word: the resting word seeds; any
71993
+ * other word is an event only when the slice's own `lastChangedAt` clears
71994
+ * the SAME floor `evaluateSensorEdge` uses, from the same two knobs.
71995
+ */
71996
+ firstWordSighting(deviceId, capName, slice, word) {
71997
+ if (word === WORD_LEVEL_INACTIVE[capName]) return this.drop(deviceId, capName, "baseline-seeded-inactive");
71998
+ const changedAt = slice["lastChangedAt"];
71999
+ const floor = Math.max(this.startedAtMs, this.now() - this.freshnessMs);
72000
+ if (!(typeof changedAt === "number" && Number.isFinite(changedAt) && changedAt > 0 && changedAt >= floor)) return this.drop(deviceId, capName, "baseline-seeded-stale-active");
72001
+ return { changed: true };
72002
+ }
72003
+ drop(deviceId, capName, reason) {
72004
+ this.noteDrop(deviceId, capName, reason);
72005
+ return {
72006
+ changed: false,
72007
+ reason
72008
+ };
72009
+ }
71691
72010
  noteDrop(deviceId, capName, reason) {
71692
72011
  const key = `${deviceId}:${capName}:${reason}`;
71693
72012
  const prior = this.drops.get(key);
@@ -78266,7 +78585,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78266
78585
  injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) }),
78267
78586
  timelapse: {
78268
78587
  store: center.timelapseStore,
78269
- getRecordingConfig: (deviceId) => api.recording.getDeviceConfig.query({ deviceId }).catch(() => null),
78588
+ getRecordingConfig: (deviceId) => api.recordingArchive.getDeviceConfig.query({ deviceId }).catch(() => null),
78270
78589
  runNow: (input) => center.runTimelapseNow(input)
78271
78590
  },
78272
78591
  summary: {
@@ -78274,6 +78593,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78274
78593
  runNow: (input) => center.runSummaryNow(input)
78275
78594
  },
78276
78595
  texts: center.textCatalogEditor,
78596
+ previewTexts: center.textCatalog,
78277
78597
  ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
78278
78598
  artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
78279
78599
  ruleStatus: {
@@ -79359,7 +79679,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79359
79679
  logger: logger.child("RecordedClip"),
79360
79680
  now: () => Date.now(),
79361
79681
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
79362
- getAvailability: (input) => api.recording.getAvailability.query(input),
79682
+ getAvailability: (input) => ourRecordingAvailability(api, input),
79363
79683
  createExport: (input) => api.recordingExport.createExport.mutate({ ...input }),
79364
79684
  getExport: (input) => api.recordingExport.getExport.query(input),
79365
79685
  readExportBytes: (input) => api.recordingExport.readExportBytes.query(input),
@@ -79613,7 +79933,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79613
79933
  buildTimelapsePorts(api, stores) {
79614
79934
  return {
79615
79935
  getAvailability: async ({ deviceId, fromMs, toMs, profile }) => {
79616
- const availability = await api.recording.getAvailability.query({
79936
+ const availability = await ourRecordingAvailability(api, {
79617
79937
  deviceId,
79618
79938
  fromMs,
79619
79939
  toMs,
@@ -83804,7 +84124,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
83804
84124
  if (cached && Date.now() - cached.at < 10 * 6e4) return cached.value;
83805
84125
  let value = null;
83806
84126
  try {
83807
- const res = await this.ctx.api.recording.getAvailability.query({
84127
+ const res = await ourRecordingAvailability(this.ctx.api, {
83808
84128
  deviceId,
83809
84129
  fromMs: 0,
83810
84130
  toMs: Date.now()
@@ -85113,7 +85433,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85113
85433
  * A cap with no boolean level (`doorbell`, `button`, `lock-control`,
85114
85434
  * `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
85115
85435
  * the event, and gating it would re-ship the silence of 2026-08-08. A cap
85116
- * that HAS one passes only when the boolean moved, in either direction.
85436
+ * that HAS one passes only when the boolean moved, in either direction — or,
85437
+ * for `cover`, whose level is its `state` word, only when the word changed
85438
+ * (a position tick or a same-word republish is a drop).
85117
85439
  *
85118
85440
  * A slice that is not an object at all is refused rather than defaulted to
85119
85441
  * `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
@@ -85850,7 +86172,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85850
86172
  } });
85851
86173
  const deviceIds = input.deviceId !== void 0 ? [input.deviceId] : await trackStore.listDeviceIds();
85852
86174
  const recordedStills = new RecordedStillService(buildRecordedStillPorts({
85853
- api: this.ctx.api.recording,
86175
+ api: this.ctx.api.recordingArchive,
85854
86176
  logger: this.ctx.logger
85855
86177
  }));
85856
86178
  const deps = {