@camstack/addon-post-analysis 1.2.51 → 1.2.53

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,8 +1,7 @@
1
- import { $ as sleep, A as encodeVectorBase64, B as vectorDimFromBase64, C as alarmPanelCapability, D as customAction, E as cosineSimilarity, F as notificationRulesCapability, H as zoneAnalyticsCapability, I as pipelineAnalyticsCapability, J as DeviceType, L as plateGalleryCapability, N as isScheduleActive, O as defineCustomActions, P as kebabToCamel, Q as nodePin, R as readDeviceStateFrom, S as addonWidgetsSourceCapability, T as buildEventKindDescriptor, U as errMsg, V as videoclipsCapability, X as hydrateSchema, Y as createEvent, Z as isDeviceScopedCap, _ as OpsLogEntrySchema, a as LabelAttributionSchema, at as object, b as TimelapseRuleSchema, c as NC_TAXONOMY, ct as unknown, d as NcRulePatchSchema, et as _enum, f as NcRuleSchema, g as NcTaxonomySchema, h as NcSnoozeSuppressedSchema, i as EVENT_PAD_MS, it as number, j as faceGalleryCapability, l as NcConditionDescriptorSchema, lt as EventCategory, m as NcSnoozeSchema, n as DEFAULT_EVENT_COLOR, nt as boolean, o as MACRO_LABELS, ot as record, p as NcSnoozeInputSchema, q as BaseAddon, r as EVENT_KIND_BY_CAP, rt as literal, s as NC_CONDITION_CATALOG, st as string, t as BaseDevice, tt as array, u as NcRuleInputSchema, v as RetrainStatusSchema, w as audioMetricsCapability, x as TrackSourceSchema, y as TimelapseRuleInputSchema, z as subKindsOf } from "../dist-DTeVOZBt.mjs";
1
+ import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-Da97q0x6.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
5
- import { spawn } from "node:child_process";
6
5
  import sharp from "sharp";
7
6
  //#region src/notification-center/action-token.ts
8
7
  /**
@@ -693,77 +692,34 @@ var NcAlarmPanelDevice = class extends BaseDevice {
693
692
  }
694
693
  };
695
694
  //#endregion
696
- //#region src/notification-center/alarm/ensure-alarm-panel.ts
695
+ //#region src/notification-center/alarm/alarm-panel-declaration.ts
697
696
  /**
698
- * Bring the Notification Center's alarm panel into existence, once.
697
+ * The Notification Center's alarm panel, as a DECLARATION.
699
698
  *
700
- * Two idempotent steps — a fixed integration to hang from, and one device under
701
- * it. Both are `get-or-create`, because this runs on every boot and an operator
702
- * must never end up with a second panel after a restart.
699
+ * There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
700
+ * re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
701
+ * to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
702
+ * addon declares. What is left here is the *declaration*: the stable id, the
703
+ * seed name, the type and the class. Data, not procedure.
703
704
  *
704
- * The integration is FIXED: it exists because the addon says so, not because an
705
- * operator added it, and there is no add/delete flow for it. That is the whole
706
- * meaning of "a system integration" here — the same `integrations` row every
707
- * provider uses, created by its owner instead of by a form.
705
+ * The bug the hand-rolled version carried died in that move: it read the device
706
+ * index once, so a transiently empty answer took the create branch and
707
+ * re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
708
+ * second read to agree before it does anything destructive (D49).
708
709
  */
709
710
  /** Stable id of the one panel. Never derived from a name — a rename must not
710
711
  * mint a second device. */
711
712
  var NC_ALARM_STABLE_ID = "nc-alarm-panel";
713
+ /** Seeded ONCE, at create. A later boot never re-stamps it. */
712
714
  var NC_ALARM_DEVICE_NAME = "Alarm";
713
- /**
714
- * Idempotent. Returns the ids either way so the caller can hold onto the panel.
715
- *
716
- * Failures are the CALLER's to handle: a hub whose alarm could not be created
717
- * should say so loudly and keep notifying, not fail to boot — but that decision
718
- * does not belong in here.
719
- */
720
- async function ensureAlarmPanel(deps) {
721
- const existingIntegration = await deps.getIntegration(deps.addonId);
722
- if (existingIntegration !== null && !existingIntegration.fixed) {
723
- await deps.markFixed(existingIntegration.id);
724
- deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
725
- }
726
- const integration = existingIntegration ?? await deps.createIntegration({
727
- addonId: deps.addonId,
728
- name: deps.integrationName,
729
- info: { fixed: true }
730
- });
731
- if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
732
- const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
733
- deps.logger.info("alarm panel adopted", {
734
- tags: { deviceId: adopted.id },
735
- meta: {
736
- integrationId: integration.id,
737
- stableId: NC_ALARM_STABLE_ID
738
- }
739
- });
740
- return {
741
- integrationId: integration.id,
742
- deviceId: adopted.id,
743
- created: false
744
- };
745
- }
746
- const device = await deps.createDevice({
747
- stableId: NC_ALARM_STABLE_ID,
748
- integrationId: integration.id,
749
- name: NC_ALARM_DEVICE_NAME
750
- });
751
- deps.logger.info("alarm panel created", {
752
- tags: { deviceId: device.id },
753
- meta: {
754
- integrationId: integration.id,
755
- stableId: NC_ALARM_STABLE_ID
756
- }
757
- });
758
- return {
759
- integrationId: integration.id,
760
- deviceId: device.id,
761
- created: true
762
- };
763
- }
764
- /** The device class + type the caller passes to `kernel.devices.create`. */
765
- var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
766
- var NC_ALARM_DEVICE_TYPE = DeviceType.AlarmPanel;
715
+ /** Display name of the FIXED integration the panel hangs from. */
716
+ var NC_INTEGRATION_NAME = "Notification Center";
717
+ var NC_ALARM_DECLARATION = {
718
+ stableId: NC_ALARM_STABLE_ID,
719
+ name: NC_ALARM_DEVICE_NAME,
720
+ type: DeviceType.AlarmPanel,
721
+ DeviceClass: NcAlarmPanelDevice
722
+ };
767
723
  //#endregion
768
724
  //#region src/notification-center/artifact-url.ts
769
725
  /**
@@ -1103,556 +1059,219 @@ function mimeFromExtension(file) {
1103
1059
  return "application/octet-stream";
1104
1060
  }
1105
1061
  //#endregion
1106
- //#region src/notification-center/fmp4-clip/clip-assembler.ts
1107
- /**
1108
- * Drain a subscription into one mp4.
1109
- *
1110
- * The subscription is ALWAYS released before this settles, on every path. It
1111
- * holds an ffmpeg's fragments in a bounded queue; leaking one keeps a camera's
1112
- * child alive for a notification that was sent minutes ago.
1113
- */
1114
- async function assembleFmp4Clip(input) {
1115
- const now = input.now ?? Date.now;
1116
- const schedule = input.setTimeoutFn ?? setTimeout;
1117
- const parts = [];
1118
- let initBytes = 0;
1119
- let fragments = 0;
1120
- let totalBytes = 0;
1121
- let firstFragmentAt = null;
1122
- let lastFragmentAt = null;
1123
- let stop = null;
1124
- const iterator = input.subscription.packets()[Symbol.asyncIterator]();
1125
- try {
1126
- for (;;) {
1127
- const remaining = input.deadlineMs - now();
1128
- if (remaining <= 0) {
1129
- stop = "deadline";
1130
- break;
1131
- }
1132
- const packet = await nextWithin(iterator, remaining, schedule);
1133
- if (packet === TIMED_OUT) {
1134
- stop = "deadline";
1135
- break;
1136
- }
1137
- if (packet === null) {
1138
- stop = input.subscription.closedReason === "slow-consumer" ? "slow-consumer" : "producer-ended";
1139
- break;
1140
- }
1141
- if (packet.kind === "init") {
1142
- if (initBytes > 0) {
1143
- stop = "producer-ended";
1144
- break;
1145
- }
1146
- initBytes = packet.data.length;
1147
- totalBytes += packet.data.length;
1148
- parts.push(packet.data);
1149
- continue;
1150
- }
1151
- if (initBytes === 0) return {
1152
- ok: false,
1153
- refusal: "no-init"
1154
- };
1155
- const arrivedAt = now();
1156
- if (totalBytes + packet.data.length > input.maxBytes) {
1157
- stop = "byte-cap";
1158
- break;
1159
- }
1160
- parts.push(packet.data);
1161
- totalBytes += packet.data.length;
1162
- fragments += 1;
1163
- if (firstFragmentAt === null) firstFragmentAt = arrivedAt;
1164
- lastFragmentAt = arrivedAt;
1165
- if (packet.isLast) {
1166
- stop = "producer-ended";
1167
- break;
1168
- }
1169
- if (arrivedAt >= input.collectUntilMs) {
1170
- stop = "window";
1171
- break;
1172
- }
1173
- }
1174
- } finally {
1175
- input.subscription.release();
1176
- }
1177
- if (initBytes === 0) return {
1178
- ok: false,
1179
- refusal: "no-init"
1180
- };
1181
- if (fragments === 0) return {
1182
- ok: false,
1183
- refusal: "no-fragments"
1184
- };
1185
- const stoppedBecause = stop ?? "producer-ended";
1186
- return {
1187
- ok: true,
1188
- clip: {
1189
- bytes: concat(parts, totalBytes),
1190
- initBytes,
1191
- fragments,
1192
- arrivalSpanMs: firstFragmentAt === null || lastFragmentAt === null ? 0 : lastFragmentAt - firstFragmentAt,
1193
- stoppedBecause,
1194
- truncated: stoppedBecause !== "window"
1195
- }
1196
- };
1197
- }
1198
- /** One line for the log — a refused clip that says nothing reads as a bug. */
1199
- function clipAssemblyRefusalReason(refusal) {
1200
- switch (refusal) {
1201
- case "no-init": return "the first packet was a fragment, not an initialisation segment — the bytes would not decode";
1202
- case "no-fragments": return "the initialisation segment arrived but no media fragment did";
1203
- }
1062
+ //#region src/notification-center/action-buttons.ts
1063
+ /** Sequence names the rule actually declares. A button may only name one. */
1064
+ function declaredSequences(actions) {
1065
+ const names = /* @__PURE__ */ new Set();
1066
+ for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
1067
+ return names;
1204
1068
  }
1205
- var TIMED_OUT = Symbol("fmp4-clip-timeout");
1206
- /** `setTimeout`'s 32-bit ceiling. Above it Node fires after 1 ms. */
1207
- var MAX_TIMER_MS = 2 ** 31 - 1;
1208
1069
  /**
1209
- * The next packet, `null` when the stream completed, or {@link TIMED_OUT}.
1070
+ * Build the notification's buttons.
1210
1071
  *
1211
- * The plane's generator parks on a promise that only a publish resolves, so a
1212
- * camera that goes silent mid-clip parks it forever. Racing a timer is the only
1213
- * way out — and the abandoned `next()` is harmless because the caller releases
1214
- * the subscription immediately afterwards.
1072
+ * Returns an empty array rather than `undefined` — the caller decides whether
1073
+ * to set the field, and an empty array is the honest answer to "which buttons
1074
+ * survived", which `undefined` would conflate with "the rule declared none".
1215
1075
  */
1216
- async function nextWithin(iterator, withinMs, schedule) {
1217
- let timer = null;
1218
- const timeout = new Promise((resolve) => {
1219
- timer = schedule(() => resolve(TIMED_OUT), Math.min(withinMs, MAX_TIMER_MS));
1220
- timer.unref?.();
1221
- });
1222
- try {
1223
- return await Promise.race([iterator.next().then((r) => r.done === true ? null : r.value), timeout]);
1224
- } finally {
1225
- if (timer !== null) clearTimeout(timer);
1226
- }
1227
- }
1228
- function concat(parts, total) {
1229
- const out = new Uint8Array(total);
1230
- let offset = 0;
1231
- for (const part of parts) {
1232
- out.set(part, offset);
1233
- offset += part.length;
1076
+ function buildActionButtons(input) {
1077
+ const actions = input.actions;
1078
+ if (actions === void 0) return [];
1079
+ const buttons = actions.buttons ?? [];
1080
+ if (buttons.length === 0) return [];
1081
+ const known = declaredSequences(actions);
1082
+ const seen = /* @__PURE__ */ new Set();
1083
+ const out = [];
1084
+ for (const button of buttons) {
1085
+ if (!known.has(button.sequence)) continue;
1086
+ if (seen.has(button.id)) continue;
1087
+ seen.add(button.id);
1088
+ out.push({
1089
+ id: button.id,
1090
+ label: button.label,
1091
+ url: input.mintUrl({
1092
+ sequence: button.sequence,
1093
+ actionId: button.id
1094
+ }),
1095
+ ...button.icon !== void 0 ? { icon: button.icon } : {},
1096
+ ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
1097
+ });
1234
1098
  }
1235
1099
  return out;
1236
1100
  }
1237
- //#endregion
1238
- //#region src/notification-center/fmp4-clip/clip-source-pick.ts
1239
- /**
1240
- * The tallest frame a notification clip is cut from.
1241
- *
1242
- * 1080 rather than 720: the attachment is native-resolution H.264 that nothing
1243
- * re-encodes, and the operator asked for a real clip. A 4-second 1080p copy
1244
- * fragment measured ~500 KB on this fleet against ~6.35 MB at 4K, which is the
1245
- * difference between "every backend takes it" and "pushover drops it".
1246
- */
1247
- var CLIP_MAX_HEIGHT = 1080;
1248
- /** The seam copies; it never transcodes. That means H.264 and only H.264. */
1249
- var COPYABLE_CODEC = "h264";
1250
- function pickClipSource(entries) {
1251
- const enabled = entries.filter((e) => e.enabled);
1252
- if (enabled.length === 0) return {
1253
- ok: false,
1254
- refusal: "no-enabled-stream"
1255
- };
1256
- const copyable = enabled.filter((e) => normaliseCodec(e.codec) === COPYABLE_CODEC);
1257
- if (copyable.length === 0) return {
1258
- ok: false,
1259
- refusal: "no-h264-stream"
1260
- };
1261
- const sized = copyable.filter(hasUsableResolution);
1262
- if (sized.length === 0) return {
1263
- ok: false,
1264
- refusal: "no-resolution"
1265
- };
1266
- const withinCeiling = sized.filter((e) => e.resolution.height <= CLIP_MAX_HEIGHT);
1267
- const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? b.resolution.height - a.resolution.height : a.resolution.height - b.resolution.height)[0];
1268
- if (best === void 0) return {
1269
- ok: false,
1270
- refusal: "no-resolution"
1271
- };
1272
- return {
1273
- ok: true,
1274
- source: {
1275
- brokerId: best.brokerId,
1276
- profile: best.profile ?? best.brokerId,
1277
- url: best.url,
1278
- width: best.resolution.width,
1279
- height: best.resolution.height
1280
- }
1281
- };
1282
- }
1283
- /** One line for the log — a withheld clip that says nothing reads as a bug. */
1284
- function clipRefusalReason(refusal) {
1285
- switch (refusal) {
1286
- case "no-enabled-stream": return "the camera has no enabled profile slot to dial";
1287
- case "no-h264-stream": return "every enabled profile is H.265 — the seam copies and never transcodes, so the clip ring serves this camera";
1288
- case "no-resolution": return "no enabled profile declares a resolution, so no slot could be sized";
1289
- }
1290
- }
1291
- function hasUsableResolution(entry) {
1292
- const r = entry.resolution;
1293
- return r !== void 0 && r.width > 0 && r.height > 0;
1294
- }
1295
- /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
1296
- function normaliseCodec(codec) {
1297
- return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
1101
+ /**
1102
+ * Which of a rule's buttons name a sequence it does not have.
1103
+ *
1104
+ * Exported so the caller can LOG the drop. A button silently missing from a
1105
+ * notification is the exact shape of failure this repo keeps paying for — the
1106
+ * operator authored it, it never appeared, and nothing said why.
1107
+ */
1108
+ function unresolvableButtons(actions) {
1109
+ if (actions === void 0) return [];
1110
+ const buttons = actions.buttons ?? [];
1111
+ if (buttons.length === 0) return [];
1112
+ const known = declaredSequences(actions);
1113
+ return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
1298
1114
  }
1299
1115
  //#endregion
1300
- //#region src/notification-center/fmp4-clip/fmp4-clip-service.ts
1301
- /**
1302
- * Cut a notification clip from the LIVE fMP4 fragment seam — roadmap 4b, the
1303
- * second consumer [D80](../../../../../../docs/decisions/adr-0080.md) named and
1304
- * deliberately did not build.
1305
- *
1306
- * ## What changes for the operator
1307
- *
1308
- * The clip ring path (`streamBroker.renderPreBufferClip`, D32) re-encodes to
1309
- * 480 px wide, 5 fps, 2× speed, `-an`. That is a thumbnail with motion. This
1310
- * one is `-c:v copy`: the camera's own H.264 at its own resolution and frame
1311
- * rate, real time, with in-band AAC — a clip you can actually watch.
1312
- *
1313
- * ## Where the consumer lives, and why it is HERE
1314
- *
1315
- * [D84](../../../../../../docs/decisions/adr-0084.md) established the answer
1316
- * the hard way: a `Fmp4FragmentPlane` is a live object, so it cannot cross a
1317
- * process boundary and no capability can carry one. The first consumer — the
1318
- * HKSV delegate — is `hub-only` in its own runner and therefore spawns its OWN
1319
- * child against the broker's restream, taking the transport from
1320
- * `@camstack/types/node`. The notification centre is in yet another runner
1321
- * (`addon-post-analysis`), so it does exactly the same thing. The alternative,
1322
- * a `streamBroker.renderFragmentClip` cap method, is a `*.cap.ts` change plus
1323
- * codegen plus a `@camstack/system` train to move bytes that are already
1324
- * reachable over RTSP.
1325
- *
1326
- * ## ON-DEMAND, and what that costs — read this before "fixing" the pre-roll
1327
- *
1328
- * HKSV's prebuffer exists because its ffmpeg never stops. That is affordable
1329
- * (measured: 0.7 % of a core, 30 MB RSS on the copy branch) but it is still a
1330
- * PERMANENT child per camera, and a notification clip does not justify one on
1331
- * every camera that has a rule. So this child is spawned at the event and
1332
- * killed when the clip is assembled.
1333
- *
1334
- * **This path has NO pre-roll, and the guess that it might have had one was
1335
- * wrong.** `RtspRestreamer` bursts the current GOP to a joining session, so it
1336
- * looked as though the first fragment would open before we asked for it.
1337
- * Measured against 615's 720p slot from outside the hub — the exact argv, the
1338
- * real child, the real plane:
1339
- *
1340
- * | dial → init | fragments | media duration | wall clock | bytes |
1341
- * | --- | --- | --- | --- | --- |
1342
- * | 4479 ms | 3 × ~141 KB | 12.03 s | 12.42 s | 425 KB |
1343
- *
1344
- * Media duration and wall clock differ by 387 ms — the dial. Nothing from
1345
- * before the spawn survives into the file: ffmpeg discards up to the first key
1346
- * frame it can open on, which is also why the first fragment costs one full
1347
- * key-frame interval. So `preRollSec` is NOT honoured here; the fallback's is.
1348
- *
1349
- * A real pre-roll needs a plane that was already running. The seam is ready for
1350
- * one — the assembler takes any `Fmp4Subscription`, including a prebuffered one
1351
- * — and it is queued rather than built, because a warm child per
1352
- * camera-with-a-clip-rule is a new permanent cost and that is an operator
1353
- * decision, not an implementation one.
1354
- *
1355
- * ## Fail-closed, and always with the reason
1356
- *
1357
- * Every refusal returns a code AND a sentence, and the caller falls back to the
1358
- * clip ring. An H.265 camera, a broker on another node, an ffmpeg that will not
1359
- * start: each is a `debug`/`warn` line carrying `tags: { deviceId }`, because a
1360
- * clip that silently changed shape per camera is exactly the thing nobody can
1361
- * diagnose from an attachment.
1362
- */
1363
- /**
1364
- * The fragment length asked of the muxer.
1365
- *
1366
- * Deliberately SHORT. `-min_frag_duration` can only ever DELAY a cut past a key
1367
- * frame (D80), so asking for 1 s against a 4 s key-frame grid yields exactly the
1368
- * source cadence — the finest granularity the stream can give. Asking for the
1369
- * grid's own length is the D84 bug: the deadline lands on the same instant as
1370
- * the key frame, loses, and DOUBLES every fragment.
1371
- */
1372
- var CLIP_FRAGMENT_MS = 1e3;
1373
- /**
1374
- * How long the child may produce nothing before it is declared dead.
1375
- *
1376
- * Shorter than the transport's 12 s default on purpose: this runs inside the
1377
- * notification drain, and the commonest cause of a stall here is a restream url
1378
- * that is not reachable from THIS runner (the broker for that camera lives on
1379
- * another node and its url is loopback). Paying 12 s per notification for that
1380
- * is worse than falling back sooner.
1381
- *
1382
- * NOT shorter than 9 s, and that bound is measured rather than chosen: the init
1383
- * segment cannot be written before ffmpeg has a key frame to open on, and 615
1384
- * took **4479 ms** over the LAN on a 4.0 s grid. A camera on an 8 s grid needs
1385
- * roughly twice that, so anything under ~9 s would declare healthy cameras dead
1386
- * and cool them down for ten minutes.
1387
- */
1388
- var CLIP_FIRST_UNIT_TIMEOUT_MS = 9e3;
1389
- /** Slack past the requested window for one more key-frame interval to land. */
1390
- var CLIP_CADENCE_SLACK_MS = 1e4;
1391
- /** The whole attempt, spawn included. A notification is not worth more. */
1392
- var CLIP_TOTAL_BUDGET_MS = 25e3;
1393
- /**
1394
- * Ceiling on the assembled file.
1395
- *
1396
- * The degrade engine DROPS an over-`maxBytes` attachment rather than degrading
1397
- * it to a link, and the SMALLEST cap declared today is pushover's 5 MiB. So the
1398
- * cap is that, not something roomier: a clip truncated to four seconds and
1399
- * delivered beats a whole one the engine throws away, and the truncation is
1400
- * marked either way. A 1080p copy clip of a few seconds is 1-2 MB, so this only
1401
- * ever bites a 4K-only camera.
1402
- */
1403
- var CLIP_MAX_BYTES = 5 * 1024 * 1024;
1404
- /**
1405
- * How long a camera is left alone after the seam failed to start on it.
1406
- *
1407
- * Without this, a camera whose broker is on another node pays
1408
- * {@link CLIP_FIRST_UNIT_TIMEOUT_MS} on EVERY notification, forever. The
1409
- * cooldown is announced once and the suppressed attempts are counted into the
1410
- * next line, the same shape the NC's own rate-limited lines use.
1411
- */
1412
- var CLIP_FAILURE_COOLDOWN_MS = 10 * 6e4;
1413
- /** ffmpeg on `PATH`, as every other spawn in this repo resolves it. */
1414
- var FFMPEG_BINARY = "ffmpeg";
1415
- var Fmp4ClipService = class {
1416
- deps;
1417
- cooldowns = /* @__PURE__ */ new Map();
1418
- inFlight = /* @__PURE__ */ new Set();
1419
- transport = null;
1420
- transportProbed = false;
1116
+ //#region src/notification-center/device-mute-store.ts
1117
+ var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
1118
+ var NC_DEVICE_MUTES_COLUMNS = [
1119
+ (
1120
+ /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
1121
+ {
1122
+ name: "id",
1123
+ type: "TEXT",
1124
+ primaryKey: true,
1125
+ notNull: true
1126
+ }),
1127
+ {
1128
+ name: "deviceId",
1129
+ type: "INTEGER",
1130
+ notNull: true
1131
+ },
1132
+ (
1133
+ /** When an admin muted it — the only thing an audit ever wants to know. */
1134
+ {
1135
+ name: "mutedAt",
1136
+ type: "INTEGER",
1137
+ notNull: true
1138
+ })
1139
+ ];
1140
+ /**
1141
+ * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
1142
+ * the table proportional to what the operator actually silenced, and makes the
1143
+ * hot-path read a `Set.has`.
1144
+ */
1145
+ var NcDeviceMuteStore = class {
1146
+ muted = /* @__PURE__ */ new Set();
1147
+ store;
1148
+ logger;
1149
+ now;
1421
1150
  constructor(deps) {
1422
- this.deps = deps;
1151
+ this.store = deps.store;
1152
+ this.logger = deps.logger;
1153
+ this.now = deps.now ?? (() => Date.now());
1423
1154
  }
1424
- /**
1425
- * Cut one clip. Never throws — every failure is a `{ ok: false }` with a
1426
- * code and a sentence, because the caller's only correct response is to fall
1427
- * back to the clip ring.
1428
- *
1429
- * The DECLINE IS LOGGED HERE and not at the call site, so there is exactly one
1430
- * place that can forget to. Two cameras silently attaching two different kinds
1431
- * of clip is precisely what an operator cannot diagnose from a phone, and the
1432
- * question is always per-camera — hence `tags: { deviceId }` on every line.
1433
- */
1434
- async renderClip(request) {
1435
- const result = await this.attempt(request);
1436
- if (!result.ok) this.deps.logger.debug("nc clip: the fMP4 seam declined — the clip ring serves this one", {
1437
- tags: { deviceId: request.deviceId },
1438
- meta: {
1439
- code: result.code,
1440
- reason: result.reason
1441
- }
1155
+ static async declare(store) {
1156
+ await store.declareCollection.mutate({
1157
+ collection: NC_DEVICE_MUTES_COLLECTION,
1158
+ columns: [...NC_DEVICE_MUTES_COLUMNS]
1442
1159
  });
1443
- return result;
1444
1160
  }
1445
- async attempt(request) {
1446
- const now = this.deps.now ?? Date.now;
1447
- const log = this.deps.logger;
1448
- const deviceId = request.deviceId;
1449
- const cooling = this.cooldowns.get(deviceId);
1450
- if (cooling !== void 0 && cooling.until > now()) {
1451
- cooling.suppressed += 1;
1452
- return {
1453
- ok: false,
1454
- code: "cooldown",
1455
- reason: `the seam failed to start on this camera and is suppressed for another ${Math.round((cooling.until - now()) / 1e3)}s`
1456
- };
1457
- }
1458
- if (this.inFlight.has(deviceId)) return {
1459
- ok: false,
1460
- code: "busy",
1461
- reason: "a seam clip for this camera is already being cut"
1462
- };
1463
- const transport = await this.loadTransport();
1464
- if (transport === null) return {
1465
- ok: false,
1466
- code: "transport-missing",
1467
- reason: "this host has no fMP4 fragment transport — @camstack/types predates it"
1468
- };
1469
- const entries = await this.readEntries(deviceId);
1470
- if (entries === null || entries.length === 0) return {
1471
- ok: false,
1472
- code: "no-entries",
1473
- reason: "the camera reported no profile RTSP entries"
1474
- };
1475
- const choice = pickClipSource(entries);
1476
- if (!choice.ok) return {
1477
- ok: false,
1478
- code: choice.refusal,
1479
- reason: clipRefusalReason(choice.refusal)
1480
- };
1481
- const source = choice.source;
1482
- this.inFlight.add(deviceId);
1483
- const plane = new transport.Fmp4FragmentPlane(log.child("nc-clip-plane"));
1484
- const child = new transport.Fmp4FragmentChild({
1485
- logger: log.child("nc-clip-fmp4"),
1486
- ffmpegBinaryPath: this.deps.ffmpegBinaryPath ?? FFMPEG_BINARY,
1487
- spawnFn: this.deps.spawnFn ?? spawn,
1488
- firstUnitTimeoutMs: CLIP_FIRST_UNIT_TIMEOUT_MS
1489
- }, {
1490
- sourceId: `nc-clip/${deviceId}`,
1491
- deviceId,
1492
- fragmentMs: CLIP_FRAGMENT_MS,
1493
- invocation: buildClipInvocation(source.url),
1494
- plane
1495
- });
1496
- const startedAt = now();
1161
+ /**
1162
+ * (Re)hydrate. A failed load KEEPS what is already in memory rather than
1163
+ * clearing it: dropping every mute on a transient store error would start
1164
+ * notifying for a camera the operator silenced, without them asking — the
1165
+ * same reasoning as `NcSnoozeStore.load`.
1166
+ */
1167
+ async load() {
1497
1168
  try {
1498
- await child.start();
1169
+ const rows = await this.store.query.query({
1170
+ collection: NC_DEVICE_MUTES_COLLECTION,
1171
+ filter: { limit: 1e4 }
1172
+ });
1173
+ const next = /* @__PURE__ */ new Set();
1174
+ let skipped = 0;
1175
+ for (const row of rows) {
1176
+ const raw = row.data["deviceId"];
1177
+ if (typeof raw !== "number" || !Number.isInteger(raw)) {
1178
+ skipped += 1;
1179
+ continue;
1180
+ }
1181
+ next.add(raw);
1182
+ }
1183
+ this.muted.clear();
1184
+ for (const id of next) this.muted.add(id);
1185
+ if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
1499
1186
  } catch (err) {
1500
- this.noteStartFailure(deviceId, err);
1501
- await child.stop().catch(() => void 0);
1502
- plane.dispose();
1503
- this.inFlight.delete(deviceId);
1504
- return {
1505
- ok: false,
1506
- code: "child-failed",
1507
- reason: `ffmpeg produced no fMP4 initialisation segment: ${errText(err)}`
1508
- };
1187
+ this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
1188
+ error: String(err),
1189
+ muted: this.muted.size
1190
+ } });
1509
1191
  }
1510
- this.clearCooldown(deviceId);
1511
- const subscribedAt = now();
1512
- const postRollMs = Math.max(0, request.postRollSec) * 1e3;
1513
- const collectUntilMs = Math.max(request.aroundMs, subscribedAt) + postRollMs;
1514
- const budgetEnd = startedAt + CLIP_TOTAL_BUDGET_MS;
1515
- const deadlineMs = Math.min(collectUntilMs + CLIP_CADENCE_SLACK_MS, budgetEnd);
1516
- try {
1517
- const outcome = await assembleFmp4Clip({
1518
- subscription: plane.subscribe({ tag: `nc-clip/${deviceId}` }),
1519
- collectUntilMs,
1520
- deadlineMs,
1521
- maxBytes: CLIP_MAX_BYTES,
1522
- ...this.deps.now !== void 0 ? { now: this.deps.now } : {},
1523
- ...this.deps.setTimeoutFn !== void 0 ? { setTimeoutFn: this.deps.setTimeoutFn } : {}
1524
- });
1525
- if (!outcome.ok) {
1526
- log.warn("nc clip: the fragment seam produced nothing usable — falling back to the clip ring", {
1527
- tags: { deviceId },
1528
- meta: {
1529
- refusal: outcome.refusal,
1530
- reason: clipAssemblyRefusalReason(outcome.refusal),
1531
- brokerId: source.brokerId
1532
- }
1533
- });
1534
- return {
1535
- ok: false,
1536
- code: outcome.refusal,
1537
- reason: clipAssemblyRefusalReason(outcome.refusal)
1538
- };
1539
- }
1540
- const clip = outcome.clip;
1541
- log.info("nc clip: cut from the fMP4 seam", {
1542
- tags: { deviceId },
1543
- meta: {
1544
- brokerId: source.brokerId,
1545
- profile: source.profile,
1546
- resolution: `${source.width}x${source.height}`,
1547
- fragments: clip.fragments,
1548
- bytes: clip.bytes.length,
1549
- arrivalSpanMs: clip.arrivalSpanMs,
1550
- truncated: clip.truncated,
1551
- stoppedBecause: clip.stoppedBecause,
1552
- dialLatencyMs: subscribedAt - startedAt,
1553
- eventToDialMs: subscribedAt - request.aroundMs
1192
+ }
1193
+ /** Hot-path read, called once per evaluated subject. */
1194
+ isMuted(deviceId) {
1195
+ return this.muted.has(deviceId);
1196
+ }
1197
+ /** Every muted camera, ascending — a stable order so two callers agree. */
1198
+ listMuted() {
1199
+ return [...this.muted].toSorted((a, b) => a - b);
1200
+ }
1201
+ /**
1202
+ * Mute or unmute. Idempotent in both directions; the in-memory set is
1203
+ * updated only after the write lands, so a failed write cannot leave the
1204
+ * hot path believing a camera is silenced when the next boot will not.
1205
+ */
1206
+ async setMuted(deviceId, muted) {
1207
+ const id = String(deviceId);
1208
+ if (muted) {
1209
+ await this.store.set.mutate({
1210
+ collection: NC_DEVICE_MUTES_COLLECTION,
1211
+ key: id,
1212
+ value: {
1213
+ id,
1214
+ deviceId,
1215
+ mutedAt: this.now()
1554
1216
  }
1555
1217
  });
1556
- return {
1557
- ok: true,
1558
- clip,
1559
- source
1560
- };
1561
- } finally {
1562
- await child.stop().catch(() => void 0);
1563
- plane.dispose();
1564
- this.inFlight.delete(deviceId);
1218
+ this.muted.add(deviceId);
1219
+ } else {
1220
+ await this.store.delete.mutate({
1221
+ collection: NC_DEVICE_MUTES_COLLECTION,
1222
+ key: id
1223
+ });
1224
+ this.muted.delete(deviceId);
1565
1225
  }
1566
1226
  }
1567
- async loadTransport() {
1568
- if (this.transportProbed) return this.transport;
1569
- this.transportProbed = true;
1570
- const load = this.deps.loadTransport ?? defaultLoadTransport;
1571
- try {
1572
- this.transport = await load();
1573
- } catch (err) {
1574
- this.transport = null;
1575
- this.deps.logger.warn("nc clip: this host carries no fMP4 fragment transport — every clip falls back to the ring", { meta: { error: errText(err) } });
1576
- }
1577
- return this.transport;
1227
+ };
1228
+ var NcDeviceStateCache = class {
1229
+ deps;
1230
+ states = /* @__PURE__ */ new Map();
1231
+ lastOkAt = 0;
1232
+ ids = [];
1233
+ now;
1234
+ constructor(deps) {
1235
+ this.deps = deps;
1236
+ this.now = deps.now ?? (() => Date.now());
1578
1237
  }
1579
- async readEntries(deviceId) {
1238
+ /**
1239
+ * The device ids worth reading — recomputed from the rule set, so a cluster
1240
+ * with no gated rule does no work at all.
1241
+ */
1242
+ setWatched(ids) {
1243
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
1244
+ }
1245
+ watched() {
1246
+ return this.ids;
1247
+ }
1248
+ /** Hot-path read. No I/O, no await — this is called per event. */
1249
+ get(deviceId) {
1250
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
1251
+ return this.states.get(deviceId);
1252
+ }
1253
+ /**
1254
+ * Re-read every watched state. Never throws: a gate that could fail the
1255
+ * evaluation tick would take the notification pipeline with it.
1256
+ */
1257
+ async refresh() {
1258
+ if (this.ids.length === 0) {
1259
+ this.states = /* @__PURE__ */ new Map();
1260
+ return;
1261
+ }
1580
1262
  try {
1581
- return await this.deps.listProfileEntries(deviceId);
1263
+ const next = await this.deps.readStates(this.ids);
1264
+ this.states = new Map(next);
1265
+ this.lastOkAt = this.now();
1266
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
1582
1267
  } catch (err) {
1583
- this.deps.logger.debug("nc clip: could not read the camera profile entries", {
1584
- tags: { deviceId },
1585
- meta: { error: errText(err) }
1586
- });
1587
- return null;
1268
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
1269
+ watched: this.ids.length,
1270
+ error: String(err)
1271
+ } });
1588
1272
  }
1589
1273
  }
1590
- noteStartFailure(deviceId, err) {
1591
- const now = this.deps.now ?? Date.now;
1592
- this.cooldowns.set(deviceId, {
1593
- until: now() + CLIP_FAILURE_COOLDOWN_MS,
1594
- suppressed: 0
1595
- });
1596
- this.deps.logger.warn("nc clip: the fMP4 child would not start — this camera falls back to the clip ring for now", {
1597
- tags: { deviceId },
1598
- meta: {
1599
- error: errText(err),
1600
- cooldownMs: CLIP_FAILURE_COOLDOWN_MS
1601
- }
1602
- });
1603
- }
1604
- clearCooldown(deviceId) {
1605
- const previous = this.cooldowns.get(deviceId);
1606
- if (previous === void 0) return;
1607
- this.cooldowns.delete(deviceId);
1608
- this.deps.logger.info("nc clip: the fMP4 seam recovered on this camera", {
1609
- tags: { deviceId },
1610
- meta: { suppressedAttempts: previous.suppressed }
1611
- });
1612
- }
1613
1274
  };
1614
- /**
1615
- * The invocation, minus the sink the child owns.
1616
- *
1617
- * `copy` is the entire point (see the class docblock) and the audio IS encoded,
1618
- * because camera microphones are G.711/PCM and an mp4 attachment has to carry
1619
- * AAC to play anywhere. `-c:a aac` with no audio stream in the source is a
1620
- * no-op in ffmpeg's default stream selection, so a silent camera needs no
1621
- * second branch.
1622
- */
1623
- function buildClipInvocation(url) {
1624
- return {
1625
- logLevel: "error",
1626
- decodeHwAccel: null,
1627
- input: {
1628
- url,
1629
- rtspTransport: "tcp",
1630
- analyzeDurationUs: 1e6,
1631
- probeSizeBytes: 1e6
1632
- },
1633
- video: { kind: "copy" },
1634
- audio: {
1635
- kind: "encode",
1636
- codec: "aac",
1637
- bitrateKbps: 64,
1638
- sampleRateHz: 24e3,
1639
- channels: 1
1640
- },
1641
- threadCount: 0,
1642
- outputArgs: []
1643
- };
1644
- }
1645
- async function defaultLoadTransport() {
1646
- const mod = await import("../node-pIpcQ_aN.mjs");
1647
- if (typeof mod.Fmp4FragmentChild !== "function" || typeof mod.Fmp4FragmentPlane !== "function") return null;
1648
- return {
1649
- Fmp4FragmentChild: mod.Fmp4FragmentChild,
1650
- Fmp4FragmentPlane: mod.Fmp4FragmentPlane
1651
- };
1652
- }
1653
- function errText(err) {
1654
- return err instanceof Error ? err.message : String(err);
1655
- }
1656
1275
  //#endregion
1657
1276
  //#region src/pipeline-analytics/pipeline/label-utils.ts
1658
1277
  /**
@@ -2066,12 +1685,13 @@ function readSensorEventType(value) {
2066
1685
  * what the operator named in the rule. Carrying only the camera is what made a
2067
1686
  * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
2068
1687
  */
2069
- function subjectFromSensorEvent(ev) {
1688
+ function subjectFromSensorEvent(ev, markerTrackId) {
2070
1689
  const eventType = readSensorEventType(ev.value);
2071
1690
  return {
2072
1691
  kind: "device-event",
2073
1692
  recordId: ev.id,
2074
1693
  deviceId: ev.deviceId,
1694
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
2075
1695
  ...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
2076
1696
  timestamp: ev.timestamp,
2077
1697
  classNames: [],
@@ -2556,20 +2176,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
2556
2176
  //#endregion
2557
2177
  //#region src/notification-center/dispatcher.ts
2558
2178
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
2559
- /** GIF and MP4 are the same cut in two containers — one render request each. */
2560
- var FOOTAGE_FORMATS = [{
2561
- flag: "mediaGif",
2562
- format: "gif",
2563
- mediaType: "gif",
2564
- mime: "image/gif",
2565
- name: "event.gif"
2566
- }, {
2567
- flag: "mediaClip",
2568
- format: "mp4",
2569
- mediaType: "video",
2570
- mime: "video/mp4",
2571
- name: "event.mp4"
2572
- }];
2573
2179
  var NcDispatcher = class {
2574
2180
  deps;
2575
2181
  targetCache = null;
@@ -2952,32 +2558,43 @@ var NcDispatcher = class {
2952
2558
  name: "zone.jpg"
2953
2559
  } : still);
2954
2560
  } else out.push(still);
2955
- }
2956
- for (const want of FOOTAGE_FORMATS) {
2957
- if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
2958
- try {
2959
- const rendered = await this.deps.renderFootage({
2960
- deviceId: entry.payload.subject.deviceId,
2961
- aroundMs: entry.payload.subject.timestamp,
2962
- format: want.format,
2963
- ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
2964
- ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
2965
- ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
2966
- ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
2967
- });
2968
- if (rendered !== null && rendered.byteLength > 0) {
2969
- const bytes = new Uint8Array(rendered.byteLength);
2970
- bytes.set(rendered);
2971
- out.push({
2972
- mediaType: want.mediaType,
2973
- bytes,
2974
- mime: want.mime,
2975
- name: want.name
2976
- });
2977
- }
2978
- } catch (err) {
2979
- this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
2980
- }
2561
+ } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
2562
+ tags: { deviceId: entry.payload.subject.deviceId },
2563
+ meta: {
2564
+ recordKind: entry.recordKind,
2565
+ recordId: entry.recordId,
2566
+ policy: entry.payload.media,
2567
+ ...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
2568
+ owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
2569
+ }
2570
+ });
2571
+ const wantGif = entry.payload.mediaGif === true;
2572
+ const wantMp4 = entry.payload.mediaClip === true;
2573
+ if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
2574
+ const media = await this.deps.renderEventMedia({
2575
+ deviceId: entry.payload.subject.deviceId,
2576
+ aroundMs: entry.payload.subject.timestamp,
2577
+ wantGif,
2578
+ wantMp4,
2579
+ preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
2580
+ postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
2581
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
2582
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
2583
+ });
2584
+ if (media.gif !== null && media.gif.byteLength > 0) out.push({
2585
+ mediaType: "gif",
2586
+ bytes: copyBytes(media.gif),
2587
+ mime: "image/gif",
2588
+ name: "event.gif"
2589
+ });
2590
+ if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
2591
+ mediaType: "video",
2592
+ bytes: copyBytes(media.mp4),
2593
+ mime: "video/mp4",
2594
+ name: "event.mp4"
2595
+ });
2596
+ } catch (err) {
2597
+ this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
2981
2598
  }
2982
2599
  return out;
2983
2600
  }
@@ -3146,6 +2763,135 @@ function paramNumber(params, key) {
3146
2763
  function clampPriority(priority) {
3147
2764
  return Math.max(1, Math.min(5, Math.round(priority)));
3148
2765
  }
2766
+ /**
2767
+ * A copy backed by its own `ArrayBuffer`.
2768
+ *
2769
+ * The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
2770
+ * by an assembler may sit on a shared buffer. Copying is cheap next to the
2771
+ * encode that produced it and removes a whole class of aliasing question.
2772
+ */
2773
+ function copyBytes(bytes) {
2774
+ const out = new Uint8Array(bytes.byteLength);
2775
+ out.set(bytes);
2776
+ return out;
2777
+ }
2778
+ //#endregion
2779
+ //#region src/notification-center/event-intake.ts
2780
+ /** Object-event insert (movement-state transitions + zone crossings). */
2781
+ function incomingFromObjectEvent(event) {
2782
+ return {
2783
+ subject: subjectFromObjectEvent(event),
2784
+ kind: "object-event",
2785
+ origin: "pipeline",
2786
+ log: () => ({
2787
+ tags: { deviceId: event.deviceId },
2788
+ meta: { eventId: event.id }
2789
+ })
2790
+ };
2791
+ }
2792
+ /** `TrackCloser.closeExpired` — the ONE close point. */
2793
+ function incomingFromTrack(track, info) {
2794
+ return {
2795
+ subject: subjectFromTrack(track, info),
2796
+ kind: "track-end",
2797
+ origin: "pipeline",
2798
+ log: () => ({
2799
+ tags: { deviceId: track.deviceId },
2800
+ meta: { trackId: track.trackId }
2801
+ })
2802
+ };
2803
+ }
2804
+ /**
2805
+ * Sensor-event persist (one row per linked camera).
2806
+ *
2807
+ * `markerTrackId` is the synthetic marker track the same state change
2808
+ * projected onto this camera — the notification's media owner, and the only
2809
+ * frame a device event has. Absent when no marker landed.
2810
+ */
2811
+ function incomingFromSensorEvent(event, markerTrackId) {
2812
+ return {
2813
+ subject: subjectFromSensorEvent(event, markerTrackId),
2814
+ kind: "device-event",
2815
+ origin: "sensor",
2816
+ log: () => ({
2817
+ tags: { deviceId: event.deviceId },
2818
+ meta: {
2819
+ sensorEventId: event.id,
2820
+ kind: event.kind,
2821
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
2822
+ }
2823
+ })
2824
+ };
2825
+ }
2826
+ /** Audio-event persist (classified episode). */
2827
+ function incomingFromAudioEvent(event) {
2828
+ return {
2829
+ subject: subjectFromAudioEvent(event),
2830
+ kind: "audio-event",
2831
+ origin: "pipeline",
2832
+ log: () => ({
2833
+ tags: { deviceId: event.deviceId },
2834
+ meta: {
2835
+ audioEventId: event.id,
2836
+ class: event.classification?.className
2837
+ }
2838
+ })
2839
+ };
2840
+ }
2841
+ /** Package object-event persist (delivered / picked-up). */
2842
+ function incomingFromPackageEvent(event, phase) {
2843
+ return {
2844
+ subject: subjectFromPackageEvent(event, phase),
2845
+ kind: "package-event",
2846
+ origin: "pipeline",
2847
+ log: () => ({
2848
+ tags: { deviceId: event.deviceId },
2849
+ meta: {
2850
+ eventId: event.id,
2851
+ phase
2852
+ }
2853
+ })
2854
+ };
2855
+ }
2856
+ /** A committed ZoneAnalytics occupancy edge. */
2857
+ function incomingFromOccupancyEdge(edge) {
2858
+ return {
2859
+ subject: subjectFromOccupancyEvent(edge),
2860
+ kind: "occupancy-event",
2861
+ origin: "pipeline",
2862
+ log: () => ({
2863
+ tags: { deviceId: edge.deviceId },
2864
+ meta: {
2865
+ zoneId: edge.zoneId ?? "@frame",
2866
+ className: edge.className ?? "@all",
2867
+ count: edge.count,
2868
+ threshold: edge.threshold,
2869
+ occupied: edge.occupied
2870
+ }
2871
+ })
2872
+ };
2873
+ }
2874
+ /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
2875
+ * record — same subject, honest origin in the log. */
2876
+ function asReconcile(event) {
2877
+ return {
2878
+ ...event,
2879
+ origin: "reconcile"
2880
+ };
2881
+ }
2882
+ /**
2883
+ * The delivery an intake kind rides. `object-event` and `audio-event` both ride
2884
+ * `immediate` (classified audio has no delivery of its own — it is gated by the
2885
+ * audio-class opt-in instead); `occupancy-event` rides `device-event`.
2886
+ *
2887
+ * Extracted from the centre so the tester can tell an operator which rules a
2888
+ * given simulated trigger will even be considered against, without evaluating.
2889
+ */
2890
+ function deliveryForKind(kind) {
2891
+ if (kind === "object-event" || kind === "audio-event") return "immediate";
2892
+ if (kind === "occupancy-event") return "device-event";
2893
+ return kind;
2894
+ }
3149
2895
  //#endregion
3150
2896
  //#region src/notification-center/occupancy-watcher.ts
3151
2897
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
@@ -4002,60 +3748,6 @@ function rowToEntry$1(id, data) {
4002
3748
  };
4003
3749
  }
4004
3750
  //#endregion
4005
- //#region src/notification-center/action-buttons.ts
4006
- /** Sequence names the rule actually declares. A button may only name one. */
4007
- function declaredSequences(actions) {
4008
- const names = /* @__PURE__ */ new Set();
4009
- for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
4010
- return names;
4011
- }
4012
- /**
4013
- * Build the notification's buttons.
4014
- *
4015
- * Returns an empty array rather than `undefined` — the caller decides whether
4016
- * to set the field, and an empty array is the honest answer to "which buttons
4017
- * survived", which `undefined` would conflate with "the rule declared none".
4018
- */
4019
- function buildActionButtons(input) {
4020
- const actions = input.actions;
4021
- if (actions === void 0) return [];
4022
- const buttons = actions.buttons ?? [];
4023
- if (buttons.length === 0) return [];
4024
- const known = declaredSequences(actions);
4025
- const seen = /* @__PURE__ */ new Set();
4026
- const out = [];
4027
- for (const button of buttons) {
4028
- if (!known.has(button.sequence)) continue;
4029
- if (seen.has(button.id)) continue;
4030
- seen.add(button.id);
4031
- out.push({
4032
- id: button.id,
4033
- label: button.label,
4034
- url: input.mintUrl({
4035
- sequence: button.sequence,
4036
- actionId: button.id
4037
- }),
4038
- ...button.icon !== void 0 ? { icon: button.icon } : {},
4039
- ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
4040
- });
4041
- }
4042
- return out;
4043
- }
4044
- /**
4045
- * Which of a rule's buttons name a sequence it does not have.
4046
- *
4047
- * Exported so the caller can LOG the drop. A button silently missing from a
4048
- * notification is the exact shape of failure this repo keeps paying for — the
4049
- * operator authored it, it never appeared, and nothing said why.
4050
- */
4051
- function unresolvableButtons(actions) {
4052
- if (actions === void 0) return [];
4053
- const buttons = actions.buttons ?? [];
4054
- if (buttons.length === 0) return [];
4055
- const known = declaredSequences(actions);
4056
- return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
4057
- }
4058
- //#endregion
4059
3751
  //#region src/notification-center/rule-actions.ts
4060
3752
  var NcRuleActionRunner = class {
4061
3753
  deps;
@@ -4368,53 +4060,6 @@ var NcRuleStore = class {
4368
4060
  });
4369
4061
  }
4370
4062
  };
4371
- var NcDeviceStateCache = class {
4372
- deps;
4373
- states = /* @__PURE__ */ new Map();
4374
- lastOkAt = 0;
4375
- ids = [];
4376
- now;
4377
- constructor(deps) {
4378
- this.deps = deps;
4379
- this.now = deps.now ?? (() => Date.now());
4380
- }
4381
- /**
4382
- * The device ids worth reading — recomputed from the rule set, so a cluster
4383
- * with no gated rule does no work at all.
4384
- */
4385
- setWatched(ids) {
4386
- this.ids = [...new Set(ids)].sort((a, b) => a - b);
4387
- }
4388
- watched() {
4389
- return this.ids;
4390
- }
4391
- /** Hot-path read. No I/O, no await — this is called per event. */
4392
- get(deviceId) {
4393
- if (this.now() - this.lastOkAt > 3e5) return void 0;
4394
- return this.states.get(deviceId);
4395
- }
4396
- /**
4397
- * Re-read every watched state. Never throws: a gate that could fail the
4398
- * evaluation tick would take the notification pipeline with it.
4399
- */
4400
- async refresh() {
4401
- if (this.ids.length === 0) {
4402
- this.states = /* @__PURE__ */ new Map();
4403
- return;
4404
- }
4405
- try {
4406
- const next = await this.deps.readStates(this.ids);
4407
- this.states = new Map(next);
4408
- this.lastOkAt = this.now();
4409
- for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
4410
- } catch (err) {
4411
- this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
4412
- watched: this.ids.length,
4413
- error: String(err)
4414
- } });
4415
- }
4416
- }
4417
- };
4418
4063
  //#endregion
4419
4064
  //#region src/notification-center/snooze-digest.ts
4420
4065
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -4531,119 +4176,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
4531
4176
  return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
4532
4177
  }
4533
4178
  //#endregion
4534
- //#region src/notification-center/device-mute-store.ts
4535
- var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
4536
- var NC_DEVICE_MUTES_COLUMNS = [
4537
- (
4538
- /** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
4539
- {
4540
- name: "id",
4541
- type: "TEXT",
4542
- primaryKey: true,
4543
- notNull: true
4544
- }),
4545
- {
4546
- name: "deviceId",
4547
- type: "INTEGER",
4548
- notNull: true
4549
- },
4550
- (
4551
- /** When an admin muted it — the only thing an audit ever wants to know. */
4552
- {
4553
- name: "mutedAt",
4554
- type: "INTEGER",
4555
- notNull: true
4556
- })
4557
- ];
4558
- /**
4559
- * Absence of a row is "not muted"; there is no `muted: false` row. That keeps
4560
- * the table proportional to what the operator actually silenced, and makes the
4561
- * hot-path read a `Set.has`.
4562
- */
4563
- var NcDeviceMuteStore = class {
4564
- muted = /* @__PURE__ */ new Set();
4565
- store;
4566
- logger;
4567
- now;
4568
- constructor(deps) {
4569
- this.store = deps.store;
4570
- this.logger = deps.logger;
4571
- this.now = deps.now ?? (() => Date.now());
4572
- }
4573
- static async declare(store) {
4574
- await store.declareCollection.mutate({
4575
- collection: NC_DEVICE_MUTES_COLLECTION,
4576
- columns: [...NC_DEVICE_MUTES_COLUMNS]
4577
- });
4578
- }
4579
- /**
4580
- * (Re)hydrate. A failed load KEEPS what is already in memory rather than
4581
- * clearing it: dropping every mute on a transient store error would start
4582
- * notifying for a camera the operator silenced, without them asking — the
4583
- * same reasoning as `NcSnoozeStore.load`.
4584
- */
4585
- async load() {
4586
- try {
4587
- const rows = await this.store.query.query({
4588
- collection: NC_DEVICE_MUTES_COLLECTION,
4589
- filter: { limit: 1e4 }
4590
- });
4591
- const next = /* @__PURE__ */ new Set();
4592
- let skipped = 0;
4593
- for (const row of rows) {
4594
- const raw = row.data["deviceId"];
4595
- if (typeof raw !== "number" || !Number.isInteger(raw)) {
4596
- skipped += 1;
4597
- continue;
4598
- }
4599
- next.add(raw);
4600
- }
4601
- this.muted.clear();
4602
- for (const id of next) this.muted.add(id);
4603
- if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
4604
- } catch (err) {
4605
- this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
4606
- error: String(err),
4607
- muted: this.muted.size
4608
- } });
4609
- }
4610
- }
4611
- /** Hot-path read, called once per evaluated subject. */
4612
- isMuted(deviceId) {
4613
- return this.muted.has(deviceId);
4614
- }
4615
- /** Every muted camera, ascending — a stable order so two callers agree. */
4616
- listMuted() {
4617
- return [...this.muted].toSorted((a, b) => a - b);
4618
- }
4619
- /**
4620
- * Mute or unmute. Idempotent in both directions; the in-memory set is
4621
- * updated only after the write lands, so a failed write cannot leave the
4622
- * hot path believing a camera is silenced when the next boot will not.
4623
- */
4624
- async setMuted(deviceId, muted) {
4625
- const id = String(deviceId);
4626
- if (muted) {
4627
- await this.store.set.mutate({
4628
- collection: NC_DEVICE_MUTES_COLLECTION,
4629
- key: id,
4630
- value: {
4631
- id,
4632
- deviceId,
4633
- mutedAt: this.now()
4634
- }
4635
- });
4636
- this.muted.add(deviceId);
4637
- } else {
4638
- await this.store.delete.mutate({
4639
- collection: NC_DEVICE_MUTES_COLLECTION,
4640
- key: id
4641
- });
4642
- this.muted.delete(deviceId);
4643
- }
4644
- }
4645
- };
4646
- //#endregion
4647
4179
  //#region src/notification-center/snooze-store.ts
4648
4180
  /**
4649
4181
  * NcSnoozeStore — the durable half of snooze.
@@ -4997,6 +4529,306 @@ var NcSnoozeStore = class {
4997
4529
  }
4998
4530
  };
4999
4531
  //#endregion
4532
+ //#region src/notification-center/test-event.ts
4533
+ /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
4534
+ var BboxSchema = object({
4535
+ x: number().min(0).max(1),
4536
+ y: number().min(0).max(1),
4537
+ w: number().min(0).max(1),
4538
+ h: number().min(0).max(1)
4539
+ });
4540
+ /**
4541
+ * What the operator typed. Every field maps onto exactly one field of
4542
+ * {@link NcRuleSubject} — there is no test-only condition input, because a
4543
+ * condition the tester could set and a real record could not is a condition the
4544
+ * test result would lie about.
4545
+ */
4546
+ var NcTestEventInputSchema = object({
4547
+ trigger: _enum([
4548
+ "object-event",
4549
+ "track-end",
4550
+ "device-event",
4551
+ "package-event",
4552
+ "audio-event",
4553
+ "occupancy-event"
4554
+ ]),
4555
+ /** The CAMERA the event is attributed to (what a notification shows). */
4556
+ deviceId: number().int(),
4557
+ /** DEVICE-EVENT: the linked device whose state changed — the contact, the
4558
+ * button, the doorbell — when that is not the camera. */
4559
+ sourceDeviceId: number().int().optional(),
4560
+ /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
4561
+ classNames: array(string()).default([]),
4562
+ /** Collapsed display label — an identity name, a plate, a subclass. */
4563
+ label: string().optional(),
4564
+ /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
4565
+ * condition FAILS on an absent confidence, and the tester must be able to
4566
+ * reproduce that. */
4567
+ confidence: number().min(0).max(1).optional(),
4568
+ /** Identity/plate MATCH confidence — distinct from `confidence`. */
4569
+ labelConfidence: number().min(0).max(1).optional(),
4570
+ /** Key-event importance [0,1] (track-end only, in practice). */
4571
+ importance: number().min(0).max(1).optional(),
4572
+ /** Track lifespan in seconds (track-end only). */
4573
+ dwellSeconds: number().min(0).optional(),
4574
+ /** Admin zone ids the subject is in (object) / visited (track). */
4575
+ zones: array(string()).default([]),
4576
+ /** OBJECT-EVENT: simulate a confirmed zone crossing. */
4577
+ crossing: object({
4578
+ zoneId: string(),
4579
+ direction: _enum(["enter", "exit"])
4580
+ }).optional(),
4581
+ /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
4582
+ sensorKind: string().optional(),
4583
+ /** DEVICE-EVENT: the raw device event-type token, when the source is an
4584
+ * event-emitter slice. */
4585
+ eventType: string().optional(),
4586
+ /** PACKAGE-EVENT: which lifecycle phase. */
4587
+ packagePhase: _enum(["delivered", "picked-up"]).optional(),
4588
+ /** Detection provenance. Absent = `pipeline` (what a record with no stamped
4589
+ * source reads as). */
4590
+ source: _enum([
4591
+ "pipeline",
4592
+ "onboard",
4593
+ "sensor",
4594
+ "audio"
4595
+ ]).optional(),
4596
+ /** Normalized bbox — feeds `customZones`, AND is what lets the still be
4597
+ * cropped / boxed the way the rule's media policy asks. */
4598
+ bbox: BboxSchema.optional(),
4599
+ /** OCCUPANCY-EVENT: the committed count edge. */
4600
+ occupancy: object({
4601
+ zoneId: string().optional(),
4602
+ zoneName: string().optional(),
4603
+ className: string().optional(),
4604
+ count: number().int().min(0),
4605
+ previousCount: number().int().min(0).default(0),
4606
+ occupied: boolean(),
4607
+ threshold: number().int().min(1)
4608
+ }).optional(),
4609
+ /** The instant the event happened. Absent = now. Set it to test a schedule
4610
+ * condition without waiting for the hour. */
4611
+ timestamp: number().int().optional(),
4612
+ /** Evaluate only this rule (what the per-rule Test button sends). */
4613
+ onlyRuleId: string().optional(),
4614
+ bypassCooldown: boolean().default(false),
4615
+ bypassMute: boolean().default(false),
4616
+ /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
4617
+ * not open a gate the operator did not ask to open. */
4618
+ runActions: boolean().default(false),
4619
+ /** Where the still comes from. `live` takes a snapshot of the camera NOW;
4620
+ * `none` sends the notification without one. */
4621
+ still: _enum(["live", "none"]).default("live")
4622
+ });
4623
+ /** Per-rule verdict, as the tester reports it. */
4624
+ var NcTestEventRuleOutcomeSchema = object({
4625
+ ruleId: string(),
4626
+ ruleName: string(),
4627
+ matched: boolean(),
4628
+ failedCondition: string().optional(),
4629
+ matchedOn: array(string()).optional(),
4630
+ cooldown: _enum([
4631
+ "n/a",
4632
+ "clear",
4633
+ "suppressed",
4634
+ "bypassed"
4635
+ ]),
4636
+ enqueued: number().int(),
4637
+ targetIds: array(string())
4638
+ });
4639
+ var NcTestEventResultSchema = object({
4640
+ /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
4641
+ * for in the logs, in the outbox and in the delivery history. */
4642
+ recordId: string(),
4643
+ /** Always `synthetic` — the marker the whole path carried. */
4644
+ origin: literal("synthetic"),
4645
+ /** The delivery the chosen trigger rides. A rule declaring anything else was
4646
+ * never a candidate, which is the answer to most "it did not fire". */
4647
+ delivery: string(),
4648
+ candidates: number().int(),
4649
+ outcomes: array(NcTestEventRuleOutcomeSchema),
4650
+ /** What the still ended up being. `unattachable` means the chosen TRIGGER
4651
+ * cannot carry one at all — an audio episode and an occupancy edge freeze no
4652
+ * media owner, so those notifications ship text-only in production too, and
4653
+ * that is a property of production, not of the test. A DEVICE event is no
4654
+ * longer one of them: its press projects a marker track that owns the frame. */
4655
+ still: _enum([
4656
+ "live",
4657
+ "none",
4658
+ "unavailable",
4659
+ "unattachable"
4660
+ ]),
4661
+ /** Everything the operator has to know to read the result honestly. */
4662
+ notes: array(string()),
4663
+ droppedBy: string().optional()
4664
+ });
4665
+ /** The id shape every synthetic record carries. One prefix, everywhere. */
4666
+ var SYNTHETIC_ID_PREFIX = "synthetic:";
4667
+ /** True for a record id this module minted. Used by the media shim. */
4668
+ function isSyntheticId(id) {
4669
+ return id.startsWith(SYNTHETIC_ID_PREFIX);
4670
+ }
4671
+ /**
4672
+ * Whether a subject of this kind can carry a still AT ALL, in production.
4673
+ *
4674
+ * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
4675
+ * owner onto the payload only for the kinds that have one:
4676
+ *
4677
+ * - an `eventId` for object and package events;
4678
+ * - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
4679
+ * DEVICE event too: the same state change projects a synthetic marker track
4680
+ * carrying the camera's frame (`sensor-marker-projector.ts`), and the
4681
+ * notification names it as its owner.
4682
+ *
4683
+ * An audio episode and an occupancy edge still freeze neither. Audio HAS a
4684
+ * marker projection, but its id does not reach the notification subject yet;
4685
+ * occupancy has no projection at all. Those two therefore remain honestly
4686
+ * unattachable — a tester that invented a picture for them would be advertising
4687
+ * a feature that does not exist.
4688
+ */
4689
+ function triggerCanCarryStill(kind) {
4690
+ return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
4691
+ }
4692
+ /**
4693
+ * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
4694
+ * event, so the red-green test can assert that this subject and a
4695
+ * producer-built one are indistinguishable.
4696
+ */
4697
+ function buildSyntheticEvent(input, recordId, now) {
4698
+ const kind = input.trigger;
4699
+ const timestamp = input.timestamp ?? now;
4700
+ const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
4701
+ return {
4702
+ subject: {
4703
+ kind,
4704
+ recordId,
4705
+ deviceId: input.deviceId,
4706
+ ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
4707
+ timestamp,
4708
+ classNames: input.classNames,
4709
+ ...input.label !== void 0 ? { label: input.label } : {},
4710
+ ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
4711
+ zones: input.zones,
4712
+ ...carriesTrackId ? { trackId: recordId } : {},
4713
+ source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
4714
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
4715
+ ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
4716
+ ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
4717
+ ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
4718
+ ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
4719
+ ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
4720
+ ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
4721
+ ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
4722
+ ...input.occupancy !== void 0 ? { occupancy: {
4723
+ ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
4724
+ ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
4725
+ ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
4726
+ count: input.occupancy.count,
4727
+ previousCount: input.occupancy.previousCount,
4728
+ occupied: input.occupancy.occupied,
4729
+ threshold: input.occupancy.threshold
4730
+ } } : {}
4731
+ },
4732
+ kind,
4733
+ origin: "synthetic",
4734
+ controls: {
4735
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
4736
+ bypassCooldown: input.bypassCooldown,
4737
+ bypassMute: input.bypassMute,
4738
+ runActions: input.runActions
4739
+ },
4740
+ log: () => ({
4741
+ tags: { deviceId: input.deviceId },
4742
+ meta: {
4743
+ eventId: recordId,
4744
+ synthetic: true,
4745
+ trigger: input.trigger,
4746
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
4747
+ }
4748
+ })
4749
+ };
4750
+ }
4751
+ /**
4752
+ * Turn one live JPEG into the media set the dispatcher's ladders look for.
4753
+ *
4754
+ * Every entry is the SAME photograph, differing only in how it was cut — which
4755
+ * is exactly what the ladders mean by their kind names, so nothing here claims
4756
+ * a frame it does not have:
4757
+ *
4758
+ * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
4759
+ * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
4760
+ * - `crop` / `thumbnail` — that bbox, cut out.
4761
+ *
4762
+ * With NO bbox only the clean-scene kinds are produced, so a rule asking for
4763
+ * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
4764
+ * scene it did not ask for. sharp failures degrade the same way: the clean
4765
+ * scene always survives.
4766
+ */
4767
+ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
4768
+ const base64 = Buffer.from(jpeg).toString("base64");
4769
+ const clean = (kind) => ({
4770
+ key: `${SYNTHETIC_ID_PREFIX}${kind}`,
4771
+ kind,
4772
+ base64,
4773
+ sizeBytes: jpeg.byteLength,
4774
+ timestamp
4775
+ });
4776
+ const files = [
4777
+ clean("fullFrame"),
4778
+ clean("keyFrame"),
4779
+ clean("firstFrame")
4780
+ ];
4781
+ if (bbox === void 0) return files;
4782
+ try {
4783
+ const { default: sharp } = await import("sharp");
4784
+ const meta = await sharp(Buffer.from(jpeg)).metadata();
4785
+ const W = meta.width ?? 0;
4786
+ const H = meta.height ?? 0;
4787
+ if (W === 0 || H === 0) return files;
4788
+ const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
4789
+ const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
4790
+ const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
4791
+ const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
4792
+ const cropBuf = await sharp(Buffer.from(jpeg)).extract({
4793
+ left,
4794
+ top,
4795
+ width,
4796
+ height
4797
+ }).jpeg({ quality: 85 }).toBuffer();
4798
+ const cropB64 = cropBuf.toString("base64");
4799
+ files.push({
4800
+ key: `${SYNTHETIC_ID_PREFIX}crop`,
4801
+ kind: "crop",
4802
+ base64: cropB64,
4803
+ sizeBytes: cropBuf.byteLength,
4804
+ timestamp
4805
+ }, {
4806
+ key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
4807
+ kind: "thumbnail",
4808
+ base64: cropB64,
4809
+ sizeBytes: cropBuf.byteLength,
4810
+ timestamp
4811
+ });
4812
+ const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
4813
+ const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
4814
+ const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
4815
+ input: overlay,
4816
+ top: 0,
4817
+ left: 0
4818
+ }]).jpeg({ quality: 85 }).toBuffer();
4819
+ files.push({
4820
+ key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
4821
+ kind: "fullFrameBoxed",
4822
+ base64: boxedBuf.toString("base64"),
4823
+ sizeBytes: boxedBuf.byteLength,
4824
+ timestamp
4825
+ });
4826
+ } catch {
4827
+ return files;
4828
+ }
4829
+ return files;
4830
+ }
4831
+ //#endregion
5000
4832
  //#region src/notification-center/timelapse/timelapse-store.ts
5001
4833
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
5002
4834
  var NC_TIMELAPSE_RULES_COLUMNS = [
@@ -5303,6 +5135,16 @@ var WATERMARK_EVERY_TICKS = 15;
5303
5135
  var OUTBOX_RETENTION_MS = 168 * 36e5;
5304
5136
  var TEST_RULE_MAX_RESULTS = 200;
5305
5137
  /**
5138
+ * How long a synthetic injection's live snapshot stays in RAM.
5139
+ *
5140
+ * Long enough to cover the drain tick plus the first handful of outbox
5141
+ * backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
5142
+ * cannot hold a JPEG for the life of the process. A row still retrying after
5143
+ * this loses its picture and ships text-only — the same degradation a real
5144
+ * event gets when its media never landed.
5145
+ */
5146
+ var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
5147
+ /**
5306
5148
  * The className every durable package event carries (mirrors
5307
5149
  * `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
5308
5150
  * stays free of a cross-module import into the pipeline). Delivery events use
@@ -5416,6 +5258,13 @@ var NotificationCenter = class NotificationCenter {
5416
5258
  alarmPanel = null;
5417
5259
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
5418
5260
  lastFiredAt = /* @__PURE__ */ new Map();
5261
+ /**
5262
+ * The live snapshot a synthetic injection took, held for as long as the
5263
+ * outbox may still be retrying its rows. In RAM and only in RAM: a test that
5264
+ * left a media blob behind would be a test that persisted something, which
5265
+ * is the one thing the synthetic producer promises not to do.
5266
+ */
5267
+ syntheticMedia = /* @__PURE__ */ new Map();
5419
5268
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
5420
5269
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
5421
5270
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -5573,6 +5422,11 @@ var NotificationCenter = class NotificationCenter {
5573
5422
  this.dispatcher = new NcDispatcher({
5574
5423
  ...deps.dispatcher,
5575
5424
  logger: this.logger.child("dispatch"),
5425
+ getMediaForOwner: async (ownerKind, ownerId) => {
5426
+ const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
5427
+ if (held !== void 0) return held.files;
5428
+ return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
5429
+ },
5576
5430
  ...deps.now !== void 0 ? { now: deps.now } : {},
5577
5431
  isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
5578
5432
  snoozeGate: (input) => this.snoozeGate(input)
@@ -5651,19 +5505,157 @@ var NotificationCenter = class NotificationCenter {
5651
5505
  this.evaluationActive = false;
5652
5506
  }
5653
5507
  /**
5508
+ * Consume ONE event. THE entry point of the notification path — every
5509
+ * producer arrives here and nothing else does.
5510
+ *
5511
+ * Fire-and-forget: the frame path must never await a notification. The
5512
+ * evaluation is appended to the serialized chain, so consumption order is
5513
+ * persist order (see {@link evalChain}).
5514
+ *
5515
+ * It persists nothing about the event itself. Whether a durable row exists is
5516
+ * the PRODUCER's business — the tracker writes its tracks, the sensor ingest
5517
+ * its rows, the synthetic tester nothing at all — and the only thing written
5518
+ * from here on is the outbox row, which is the notification's own delivery
5519
+ * queue and not a record of the event.
5520
+ */
5521
+ consumeEvent(event) {
5522
+ if (!this.evaluationActive) return;
5523
+ this.scheduleEvaluation(event);
5524
+ }
5525
+ /**
5526
+ * The same consumption, awaited, with the verdict returned. Identical code
5527
+ * path — {@link consumeEvent} is this function with the report discarded — so
5528
+ * a producer that reads the report is testing what the live producers do, not
5529
+ * a parallel implementation of it.
5530
+ */
5531
+ async consumeEventAndReport(event) {
5532
+ if (!this.evaluationActive) return {
5533
+ recordId: event.subject.recordId,
5534
+ kind: event.kind,
5535
+ delivery: deliveryForKind(event.kind),
5536
+ origin: event.origin,
5537
+ candidates: 0,
5538
+ outcomes: [],
5539
+ droppedBy: "evaluation-inactive"
5540
+ };
5541
+ const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
5542
+ this.evalChain = result.then(() => void 0, () => void 0);
5543
+ return result;
5544
+ }
5545
+ /**
5546
+ * The SYNTHETIC producer — an operator-driven event, through the whole path.
5547
+ *
5548
+ * It builds an envelope and calls {@link consumeEventAndReport}. That is the
5549
+ * entire integration: no bypass, no second matcher, no shortcut into the
5550
+ * dispatcher. What the operator receives on their phone was matched by
5551
+ * `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
5552
+ * tick, given media by the ordinary ladders, degraded by the ordinary engine
5553
+ * and delivered by the ordinary notifier.
5554
+ *
5555
+ * What it does NOT do is persist the event. No track row, no object-event
5556
+ * row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
5557
+ * expiry. The outbox row is the exception that proves the rule: it is the
5558
+ * notification's delivery queue, not a record of the event, and it carries
5559
+ * `synthetic: true` so it can never be mistaken for one.
5560
+ */
5561
+ async injectTestEvent(input, deps = {}) {
5562
+ const now = this.now();
5563
+ this.pruneSyntheticMedia(now);
5564
+ const recordId = `${SYNTHETIC_ID_PREFIX}${randomUUID()}`;
5565
+ const event = buildSyntheticEvent(input, recordId, now);
5566
+ const notes = [];
5567
+ let still = "none";
5568
+ if (!triggerCanCarryStill(event.kind)) {
5569
+ still = "unattachable";
5570
+ notes.push(`a ${event.kind} notification carries no still in production either — the payload freezes no media owner for this trigger, so nothing to attach is the honest result, not a limitation of the test`);
5571
+ } else if (input.still === "live") {
5572
+ const jpeg = await this.captureLiveStill(input.deviceId, deps);
5573
+ if (jpeg === null) {
5574
+ still = "unavailable";
5575
+ notes.push("the camera returned no snapshot — the notification ships without a still");
5576
+ } else {
5577
+ const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
5578
+ this.syntheticMedia.set(recordId, {
5579
+ files,
5580
+ expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
5581
+ });
5582
+ still = "live";
5583
+ if (input.bbox === void 0) notes.push("no bbox was given, so only the clean scene exists — a rule asking for the subject crop finds nothing, exactly as it would for a real event with no crop stored");
5584
+ }
5585
+ }
5586
+ if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
5587
+ if (input.bypassMute) notes.push("per-camera notification mute bypassed");
5588
+ if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
5589
+ this.logger.info("synthetic event injected", {
5590
+ tags: { deviceId: input.deviceId },
5591
+ meta: {
5592
+ eventId: recordId,
5593
+ synthetic: true,
5594
+ trigger: input.trigger,
5595
+ classes: input.classNames,
5596
+ zones: input.zones,
5597
+ still,
5598
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
5599
+ }
5600
+ });
5601
+ const report = await this.consumeEventAndReport(event);
5602
+ if (report.droppedBy === "no-candidate-rule") notes.push(input.onlyRuleId !== void 0 ? "that rule is not enabled, or its delivery does not match this trigger" : `no enabled rule declares delivery "${report.delivery}"`);
5603
+ if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
5604
+ if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
5605
+ if (report.outcomes.every((o) => o.enqueued === 0) && report.outcomes.some((o) => o.matched)) notes.push("the rule matched but no row was enqueued — every target is opted out of this rule, or the rule addresses none");
5606
+ return {
5607
+ recordId,
5608
+ origin: "synthetic",
5609
+ delivery: report.delivery,
5610
+ candidates: report.candidates,
5611
+ outcomes: report.outcomes.map((o) => ({
5612
+ ruleId: o.ruleId,
5613
+ ruleName: o.ruleName,
5614
+ matched: o.matched,
5615
+ ...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
5616
+ ...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
5617
+ cooldown: o.cooldown,
5618
+ enqueued: o.enqueued,
5619
+ targetIds: [...o.targetIds]
5620
+ })),
5621
+ still,
5622
+ notes,
5623
+ ...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
5624
+ };
5625
+ }
5626
+ /** One live frame, or null. Never throws into an injection. */
5627
+ async captureLiveStill(deviceId, deps) {
5628
+ const capture = deps.getSnapshot;
5629
+ if (capture === void 0) return null;
5630
+ try {
5631
+ const shot = await capture(deviceId);
5632
+ if (shot === null || shot.base64.length === 0) return null;
5633
+ const buf = Buffer.from(shot.base64, "base64");
5634
+ const bytes = new Uint8Array(buf.byteLength);
5635
+ bytes.set(buf);
5636
+ return bytes;
5637
+ } catch (err) {
5638
+ this.logger.warn("synthetic injection could not photograph the camera", {
5639
+ tags: { deviceId },
5640
+ meta: { error: String(err) }
5641
+ });
5642
+ return null;
5643
+ }
5644
+ }
5645
+ /** Drop expired synthetic stills. Called on every injection — there is no
5646
+ * timer, because the map is empty except while a test is in flight. */
5647
+ pruneSyntheticMedia(now) {
5648
+ for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
5649
+ }
5650
+ /**
5654
5651
  * Called at the object-event persist site, in the SAME moment as the
5655
5652
  * durable insert. Fire-and-forget from the frame path (never blocks the
5656
5653
  * pipeline); the boot reconcile covers a crash between persist and
5657
5654
  * outbox insert.
5658
5655
  */
5659
5656
  onObjectEventPersisted(event, _track) {
5660
- if (!this.evaluationActive) return;
5661
5657
  if (packagePhaseOf(event) !== null) return;
5662
- const subject = subjectFromObjectEvent(event);
5663
- this.scheduleEvaluation(subject, "object-event", () => ({
5664
- tags: { deviceId: event.deviceId },
5665
- meta: { eventId: event.id }
5666
- }));
5658
+ this.consumeEvent(incomingFromObjectEvent(event));
5667
5659
  }
5668
5660
  /**
5669
5661
  * Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
@@ -5672,18 +5664,19 @@ var NotificationCenter = class NotificationCenter {
5672
5664
  * post-close promotions are picked up).
5673
5665
  */
5674
5666
  onTrackClosed(track, _ownedMedia, info) {
5675
- if (!this.evaluationActive) return;
5676
- const subject = subjectFromTrack(track, info);
5677
- this.scheduleEvaluation(subject, "track-end", () => ({
5678
- tags: { deviceId: track.deviceId },
5679
- meta: { trackId: track.trackId }
5680
- }));
5667
+ this.consumeEvent(incomingFromTrack(track, info));
5681
5668
  }
5682
5669
  /**
5683
- * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
5684
- * per linked camera), in the SAME moment as the durable insert. Feeds the
5685
- * `device-event` trigger (doorbell press / sensor state change). Fire-and-
5686
- * forget from the ingest loop; the outbox owns delivery from here.
5670
+ * Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
5671
+ * camera), once the same state change has had its chance to project a marker
5672
+ * track. Feeds the `device-event` trigger (doorbell press / sensor state
5673
+ * change). Fire-and-forget from the ingest loop; the outbox owns delivery.
5674
+ *
5675
+ * `markerTrackId` is that marker — the synthetic track carrying the camera's
5676
+ * frame — and it is what gives this notification a picture: the payload
5677
+ * freezes it as the media owner and the dispatcher resolves it through the
5678
+ * ladder every other attachment uses. Absent means no marker landed, and the
5679
+ * notification is text-only; that is logged, per camera, at the enqueue.
5687
5680
  *
5688
5681
  * Delivery-grade boundary (honest): the SensorEventStore is itself fed from
5689
5682
  * the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
@@ -5692,16 +5685,8 @@ var NotificationCenter = class NotificationCenter {
5692
5685
  * device-event notification — the durable guarantee begins at this hook, not
5693
5686
  * before it.
5694
5687
  */
5695
- onSensorEventPersisted(event) {
5696
- if (!this.evaluationActive) return;
5697
- const subject = subjectFromSensorEvent(event);
5698
- this.scheduleEvaluation(subject, "device-event", () => ({
5699
- tags: { deviceId: event.deviceId },
5700
- meta: {
5701
- sensorEventId: event.id,
5702
- kind: event.kind
5703
- }
5704
- }));
5688
+ onSensorEventPersisted(event, markerTrackId) {
5689
+ this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
5705
5690
  }
5706
5691
  /**
5707
5692
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -5716,15 +5701,7 @@ var NotificationCenter = class NotificationCenter {
5716
5701
  * durable guarantee begins at this hook, matching the device-event boundary.
5717
5702
  */
5718
5703
  onAudioEventPersisted(event) {
5719
- if (!this.evaluationActive) return;
5720
- const subject = subjectFromAudioEvent(event);
5721
- this.scheduleEvaluation(subject, "audio-event", () => ({
5722
- tags: { deviceId: event.deviceId },
5723
- meta: {
5724
- audioEventId: event.id,
5725
- class: event.classification?.className
5726
- }
5727
- }));
5704
+ this.consumeEvent(incomingFromAudioEvent(event));
5728
5705
  }
5729
5706
  /**
5730
5707
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -5733,15 +5710,7 @@ var NotificationCenter = class NotificationCenter {
5733
5710
  * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
5734
5711
  */
5735
5712
  onPackageEventPersisted(event, phase) {
5736
- if (!this.evaluationActive) return;
5737
- const subject = subjectFromPackageEvent(event, phase);
5738
- this.scheduleEvaluation(subject, "package-event", () => ({
5739
- tags: { deviceId: event.deviceId },
5740
- meta: {
5741
- eventId: event.id,
5742
- phase
5743
- }
5744
- }));
5713
+ this.consumeEvent(incomingFromPackageEvent(event, phase));
5745
5714
  }
5746
5715
  /**
5747
5716
  * Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
@@ -5777,18 +5746,7 @@ var NotificationCenter = class NotificationCenter {
5777
5746
  * Public so a test / a future out-of-band edge source can drive it directly.
5778
5747
  */
5779
5748
  onOccupancyEdge(edge) {
5780
- if (!this.evaluationActive) return;
5781
- const subject = subjectFromOccupancyEvent(edge);
5782
- this.scheduleEvaluation(subject, "occupancy-event", () => ({
5783
- tags: { deviceId: edge.deviceId },
5784
- meta: {
5785
- zoneId: edge.zoneId ?? "@frame",
5786
- className: edge.className ?? "@all",
5787
- count: edge.count,
5788
- threshold: edge.threshold,
5789
- occupied: edge.occupied
5790
- }
5791
- }));
5749
+ this.consumeEvent(incomingFromOccupancyEdge(edge));
5792
5750
  }
5793
5751
  buildProvider() {
5794
5752
  return {
@@ -5885,7 +5843,7 @@ var NotificationCenter = class NotificationCenter {
5885
5843
  }
5886
5844
  /**
5887
5845
  * Adopt the panel this node owns. Called once by the addon after
5888
- * `ensureAlarmPanel`; a node without one never calls it and the cap then
5846
+ * `declareAlarmPanel`; a node without one never calls it and the cap then
5889
5847
  * answers `deviceId: null`.
5890
5848
  */
5891
5849
  setAlarmPanel(panel) {
@@ -5963,48 +5921,74 @@ var NotificationCenter = class NotificationCenter {
5963
5921
  });
5964
5922
  }
5965
5923
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
5966
- scheduleEvaluation(subject, kind, logContext) {
5924
+ scheduleEvaluation(event) {
5967
5925
  this.evalChain = this.evalChain.then(async () => {
5968
5926
  try {
5969
- await this.evaluateAndEnqueue(subject, kind);
5927
+ await this.evaluateAndEnqueue(event);
5970
5928
  } catch (err) {
5971
- const ctx = logContext();
5929
+ const ctx = event.log();
5972
5930
  this.logger.warn("rule evaluation failed", {
5973
5931
  tags: ctx.tags,
5974
5932
  meta: {
5975
5933
  ...ctx.meta,
5976
- kind,
5934
+ kind: event.kind,
5935
+ origin: event.origin,
5977
5936
  error: String(err)
5978
5937
  }
5979
5938
  });
5980
5939
  }
5981
5940
  });
5982
5941
  }
5983
- async evaluateAndEnqueue(subject, kind) {
5984
- if (this.deviceMutes.isMuted(subject.deviceId)) {
5942
+ async evaluateAndEnqueue(event) {
5943
+ const { subject, kind, origin } = event;
5944
+ const controls = event.controls ?? {};
5945
+ const outcomes = [];
5946
+ const report = (extra = {}) => ({
5947
+ recordId: subject.recordId,
5948
+ kind,
5949
+ delivery: deliveryForKind(kind),
5950
+ origin,
5951
+ candidates: 0,
5952
+ outcomes,
5953
+ ...extra
5954
+ });
5955
+ if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
5985
5956
  this.reportMutedDrop(subject, kind, this.now());
5986
- return;
5957
+ return report({ droppedBy: "muted" });
5987
5958
  }
5988
- const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5989
- const candidates = this.rules.listEnabled(delivery);
5959
+ const delivery = deliveryForKind(kind);
5960
+ const enabled = this.rules.listEnabled(delivery);
5961
+ const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
5990
5962
  if (candidates.length === 0) {
5991
5963
  this.logger.debug("no enabled rule for this trigger", {
5992
5964
  tags: { deviceId: subject.deviceId },
5993
5965
  meta: {
5994
5966
  delivery,
5995
5967
  kind,
5968
+ origin,
5996
5969
  rulesLoaded: this.rules.list().length
5997
5970
  }
5998
5971
  });
5999
- return;
5972
+ return report({ droppedBy: "no-candidate-rule" });
6000
5973
  }
6001
5974
  const now = this.now();
6002
5975
  let anyMatched = false;
5976
+ /** One "no media owner" line per EVENT, not per matching rule. */
5977
+ let noOwnerReported = false;
6003
5978
  const rejections = [];
6004
5979
  for (const rule of candidates) {
6005
5980
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
6006
5981
  if (!evaluation.matched) {
6007
5982
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
5983
+ outcomes.push({
5984
+ ruleId: rule.id,
5985
+ ruleName: rule.name,
5986
+ matched: false,
5987
+ ...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
5988
+ cooldown: "n/a",
5989
+ enqueued: 0,
5990
+ targetIds: []
5991
+ });
6008
5992
  this.logger.debug("rule did not match", {
6009
5993
  tags: { deviceId: subject.deviceId },
6010
5994
  meta: {
@@ -6020,7 +6004,8 @@ var NotificationCenter = class NotificationCenter {
6020
6004
  continue;
6021
6005
  }
6022
6006
  const key = cooldownKey(rule, subject);
6023
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6007
+ const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
6008
+ if (cooling && controls.bypassCooldown !== true) {
6024
6009
  this.logger.debug("rule matched but is cooling down", {
6025
6010
  tags: { deviceId: subject.deviceId },
6026
6011
  meta: {
@@ -6031,6 +6016,15 @@ var NotificationCenter = class NotificationCenter {
6031
6016
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6032
6017
  }
6033
6018
  });
6019
+ outcomes.push({
6020
+ ruleId: rule.id,
6021
+ ruleName: rule.name,
6022
+ matched: true,
6023
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6024
+ cooldown: "suppressed",
6025
+ enqueued: 0,
6026
+ targetIds: []
6027
+ });
6034
6028
  continue;
6035
6029
  }
6036
6030
  this.logger.info("rule matched — enqueueing", {
@@ -6039,6 +6033,7 @@ var NotificationCenter = class NotificationCenter {
6039
6033
  ruleId: rule.id,
6040
6034
  rule: rule.name,
6041
6035
  kind,
6036
+ origin,
6042
6037
  targets: rule.targets.length,
6043
6038
  eventId: subject.recordId,
6044
6039
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
@@ -6047,7 +6042,7 @@ var NotificationCenter = class NotificationCenter {
6047
6042
  }
6048
6043
  });
6049
6044
  const sequences = rule.actions?.onTrigger;
6050
- if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
6045
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
6051
6046
  ruleId: rule.id,
6052
6047
  ruleName: rule.name,
6053
6048
  deviceId: subject.deviceId,
@@ -6062,11 +6057,35 @@ var NotificationCenter = class NotificationCenter {
6062
6057
  buttons: unresolvable.join(", ")
6063
6058
  }
6064
6059
  });
6060
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
6061
+ noOwnerReported = true;
6062
+ this.logger.warn("device event has no marker track — this notification ships text-only", {
6063
+ tags: { deviceId: subject.deviceId },
6064
+ meta: {
6065
+ recordId: subject.recordId,
6066
+ ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
6067
+ ruleId: rule.id,
6068
+ rule: rule.name
6069
+ }
6070
+ });
6071
+ }
6065
6072
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6066
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
6073
+ const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
6074
+ const inserted = await this.outbox.enqueue(entries);
6075
+ if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
6076
+ outcomes.push({
6077
+ ruleId: rule.id,
6078
+ ruleName: rule.name,
6079
+ matched: true,
6080
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6081
+ cooldown: cooling ? "bypassed" : "clear",
6082
+ enqueued: inserted,
6083
+ targetIds: entries.map((e) => e.targetId)
6084
+ });
6067
6085
  anyMatched = true;
6068
6086
  }
6069
6087
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
6088
+ return report({ candidates: candidates.length });
6070
6089
  }
6071
6090
  /**
6072
6091
  * One INFO line when a MUTED camera's event is dropped, bounded to one per
@@ -6118,7 +6137,7 @@ var NotificationCenter = class NotificationCenter {
6118
6137
  }
6119
6138
  });
6120
6139
  }
6121
- buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6140
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
6122
6141
  const hasEventMedia = kind === "object-event" || kind === "package-event";
6123
6142
  const isTrackScoped = kind === "object-event" || kind === "track-end";
6124
6143
  const direct = new Set(rule.targets.map((t) => t.targetId));
@@ -6126,6 +6145,7 @@ var NotificationCenter = class NotificationCenter {
6126
6145
  const payload = {
6127
6146
  ruleName: rule.name,
6128
6147
  delivery: rule.delivery,
6148
+ ...origin === "synthetic" ? { synthetic: true } : {},
6129
6149
  priority: rule.priority,
6130
6150
  ...rule.template !== void 0 ? { template: rule.template } : {},
6131
6151
  media: rule.media.attach,
@@ -6309,21 +6329,7 @@ var NotificationCenter = class NotificationCenter {
6309
6329
  const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
6310
6330
  for (const event of ordered) {
6311
6331
  const phase = packagePhaseOf(event);
6312
- if (phase !== null) this.scheduleEvaluation(subjectFromPackageEvent(event, phase), "package-event", () => ({
6313
- tags: { deviceId: event.deviceId },
6314
- meta: {
6315
- eventId: event.id,
6316
- phase,
6317
- reconcile: true
6318
- }
6319
- }));
6320
- else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
6321
- tags: { deviceId: event.deviceId },
6322
- meta: {
6323
- eventId: event.id,
6324
- reconcile: true
6325
- }
6326
- }));
6332
+ this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
6327
6333
  }
6328
6334
  await this.evalChain;
6329
6335
  if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
@@ -6491,7 +6497,7 @@ var NotificationCenter = class NotificationCenter {
6491
6497
  }
6492
6498
  } else if (rule.delivery === "track-end") {
6493
6499
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
6494
- for (const track of tracks) subjects.push(subjectFromTrack(track));
6500
+ for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
6495
6501
  }
6496
6502
  let lookup = (id) => this.deviceStates.get(id);
6497
6503
  const gated = rule.conditions.deviceState;
@@ -6594,6 +6600,24 @@ var ncActions = defineCustomActions({
6594
6600
  "nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
6595
6601
  kind: "mutation",
6596
6602
  caller: "required"
6603
+ }),
6604
+ /**
6605
+ * Inject a SYNTHETIC event through the whole notification path — match,
6606
+ * media, delivery — without producing a track, an event row or anything else
6607
+ * durable. See `test-event.ts` for what it does and does not do.
6608
+ *
6609
+ * A bridge action rather than a cap method, deliberately: it needs no
6610
+ * codegen, no `@camstack/system` republish and no place on a release train,
6611
+ * so the tester can ship and change at the speed of the addon it tests.
6612
+ *
6613
+ * `auth: 'admin'` because an injection DELIVERS: it reaches every target the
6614
+ * matched rule addresses, on other people's phones. `caller: 'required'` so
6615
+ * the log line names who pressed it.
6616
+ */
6617
+ "nc.injectTestEvent": customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
6618
+ kind: "mutation",
6619
+ auth: "admin",
6620
+ caller: "required"
6597
6621
  })
6598
6622
  });
6599
6623
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -6708,6 +6732,22 @@ function makeNcActionHandlers(deps) {
6708
6732
  await deps.snooze.cancel(input.snoozeId, c);
6709
6733
  return { success: true };
6710
6734
  },
6735
+ "nc.injectTestEvent": async (input, caller) => {
6736
+ const c = requireCaller(caller);
6737
+ if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
6738
+ const inject = deps.injectTestEvent;
6739
+ if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
6740
+ const parsed = NcTestEventInputSchema.parse(input);
6741
+ deps.logger.info("synthetic notification test requested", {
6742
+ tags: { deviceId: parsed.deviceId },
6743
+ meta: {
6744
+ by: c.userId,
6745
+ trigger: parsed.trigger,
6746
+ ...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
6747
+ }
6748
+ });
6749
+ return inject(parsed);
6750
+ },
6711
6751
  "nc.setRuleTargetEnabled": async (input, caller) => {
6712
6752
  const c = requireCaller(caller);
6713
6753
  assertRuleVisible(input.ruleId, c);
@@ -6717,6 +6757,844 @@ function makeNcActionHandlers(deps) {
6717
6757
  }
6718
6758
  };
6719
6759
  }
6760
+ var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
6761
+ var DebugFindingSchema = object({
6762
+ code: string(),
6763
+ severity: _enum([
6764
+ "error",
6765
+ "warn",
6766
+ "info"
6767
+ ]),
6768
+ detail: string()
6769
+ });
6770
+ var DebugTrackListItemSchema = object({
6771
+ trackId: string(),
6772
+ deviceId: number(),
6773
+ className: string(),
6774
+ label: string().optional(),
6775
+ firstSeen: number(),
6776
+ lastSeen: number(),
6777
+ durationMs: number(),
6778
+ active: boolean()
6779
+ });
6780
+ var DebugTrackReportSchema = object({
6781
+ trackId: string(),
6782
+ deviceId: number(),
6783
+ className: string(),
6784
+ label: string().optional(),
6785
+ firstSeen: number(),
6786
+ lastSeen: number(),
6787
+ durationMs: number(),
6788
+ active: boolean(),
6789
+ mediaCount: number(),
6790
+ mediaKinds: array(string()).readonly(),
6791
+ eventCount: number(),
6792
+ positionCount: number(),
6793
+ findings: array(DebugFindingSchema).readonly()
6794
+ });
6795
+ var ListTracksInputSchema = object({
6796
+ /** Empty or absent means every camera that has tracks. */
6797
+ deviceIds: array(number()).optional(),
6798
+ limit: number().int().positive().max(200).optional()
6799
+ });
6800
+ var ListTracksOutputSchema = object({
6801
+ tracks: array(DebugTrackListItemSchema).readonly(),
6802
+ /**
6803
+ * True when a camera hit `limit`. Said rather than implied: a silently capped
6804
+ * list is how an operator concludes a mark did not take.
6805
+ */
6806
+ truncated: boolean()
6807
+ });
6808
+ var AnalyzeInputSchema = object({ trackIds: array(string()).min(1).max(200) });
6809
+ var AnalyzeOutputSchema = object({
6810
+ reports: array(DebugTrackReportSchema).readonly(),
6811
+ /**
6812
+ * Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
6813
+ * pin retention, so a marked track can be evicted between the list and the
6814
+ * analysis — reported as a number the tab can show rather than as silence.
6815
+ */
6816
+ vanished: array(string()).readonly()
6817
+ });
6818
+ var debugActions = defineCustomActions({
6819
+ "debug.listTracks": customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
6820
+ "debug.analyze": customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
6821
+ });
6822
+ //#endregion
6823
+ //#region src/pipeline-analytics/debug/zip-stream.ts
6824
+ /**
6825
+ * A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
6826
+ * entry, without ever holding the archive in memory.
6827
+ *
6828
+ * ## Why hand-rolled, and why ZIP rather than the tar next door
6829
+ *
6830
+ * The archive the operator asked for is a ZIP, because it is the format that
6831
+ * opens with a double click on every desktop they use; the training exports use
6832
+ * ustar and that stays as it is. Both are hand-written here for the same two
6833
+ * reasons the {@link ../training-export/tar-stream.js tar writer} records:
6834
+ *
6835
+ * 1. **A dependency would not arrive.** A hub addon is deployed as its built
6836
+ * `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
6837
+ * `archiver` to `package.json` gets a module that is missing at runtime on
6838
+ * the node that actually runs the code. The only libraries an addon can rely
6839
+ * on are Node's builtins and what its bundler inlines.
6840
+ * 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
6841
+ * STORE (method 0) spends no CPU on the hub's one thread for a percent or
6842
+ * two, and — unlike DEFLATE — the entry's size and CRC are known before the
6843
+ * header is written, so no data descriptors and no seeking back: the writer
6844
+ * is append-only and therefore streamable.
6845
+ *
6846
+ * So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
6847
+ * plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
6848
+ * asserted against the system `unzip` binary in the spec next door rather than
6849
+ * against my reading of the format.
6850
+ *
6851
+ * ## What this deliberately does NOT do
6852
+ *
6853
+ * No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
6854
+ * archive is a hand-marked debug set — tens of tracks, not a dataset.
6855
+ * {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
6856
+ * so the caller can skip it and SAY it skipped it, rather than emitting an
6857
+ * archive with a wrapped offset that reads as corrupt.
6858
+ *
6859
+ * ## Entry names are ASCII, by construction
6860
+ *
6861
+ * The writer sets the UTF-8 name flag (bit 11) because that is what the format
6862
+ * says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
6863
+ * 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
6864
+ * comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
6865
+ * valid for the filesystem it refuses to extract the entry at all. Measured, not
6866
+ * assumed; the spec next door pins it.
6867
+ *
6868
+ * So {@link toAsciiZipName} is applied to every path, and — more importantly —
6869
+ * no free text goes into a path in the first place: an entry is addressed by
6870
+ * `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
6871
+ * its encoding is JSON's problem and not the archive's.
6872
+ */
6873
+ var LOCAL_SIG = 67324752;
6874
+ var CENTRAL_SIG = 33639248;
6875
+ var EOCD_SIG = 101010256;
6876
+ /** Bit 11 — the entry name is UTF-8. */
6877
+ var FLAG_UTF8 = 2048;
6878
+ /** STORE. */
6879
+ var METHOD_STORE = 0;
6880
+ /** PKZIP 2.0, the floor for STORE + UTF-8 names. */
6881
+ var VERSION = 20;
6882
+ /** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
6883
+ var ZIP_MAX_BYTES = 4294967295;
6884
+ var CRC_TABLE = (() => {
6885
+ const table = new Array(256);
6886
+ for (let i = 0; i < 256; i++) {
6887
+ let c = i;
6888
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
6889
+ table[i] = c >>> 0;
6890
+ }
6891
+ return table;
6892
+ })();
6893
+ /**
6894
+ * Reduce a path to the ASCII set every ZIP reader agrees on.
6895
+ *
6896
+ * Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
6897
+ * transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
6898
+ * only name this archive can promise, and a lossy-but-stable mapping is better
6899
+ * than a pretty one that a reader mangles differently from the manifest.
6900
+ */
6901
+ function toAsciiZipName(name) {
6902
+ return name.replace(/[^A-Za-z0-9._/-]/g, "_");
6903
+ }
6904
+ /** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
6905
+ function crc32(buf) {
6906
+ let c = 4294967295;
6907
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
6908
+ return (c ^ 4294967295) >>> 0;
6909
+ }
6910
+ /**
6911
+ * MS-DOS date/time, the only timestamp a classic ZIP record carries.
6912
+ *
6913
+ * Two-second resolution and an epoch of 1980 — a date before that is clamped
6914
+ * rather than allowed to write a negative year field, which some readers show as
6915
+ * 2107 and others reject outright.
6916
+ */
6917
+ function dosDateTime(mtimeMs) {
6918
+ const d = new Date(mtimeMs);
6919
+ const year = Math.max(1980, d.getFullYear());
6920
+ return {
6921
+ time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
6922
+ date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
6923
+ };
6924
+ }
6925
+ /**
6926
+ * The 30-byte local header + name for one stored file.
6927
+ *
6928
+ * Sizes and CRC are written into the header itself (never a trailing data
6929
+ * descriptor) — STORE knows both before the body moves, and a header that tells
6930
+ * the truth is what lets a reader index the archive without scanning it.
6931
+ */
6932
+ function zipLocalHeader(entry, crc, nameBytes) {
6933
+ const head = Buffer.alloc(30);
6934
+ const { time, date } = dosDateTime(entry.mtimeMs);
6935
+ head.writeUInt32LE(LOCAL_SIG, 0);
6936
+ head.writeUInt16LE(VERSION, 4);
6937
+ head.writeUInt16LE(FLAG_UTF8, 6);
6938
+ head.writeUInt16LE(METHOD_STORE, 8);
6939
+ head.writeUInt16LE(time, 10);
6940
+ head.writeUInt16LE(date, 12);
6941
+ head.writeUInt32LE(crc, 14);
6942
+ head.writeUInt32LE(entry.body.length, 18);
6943
+ head.writeUInt32LE(entry.body.length, 22);
6944
+ head.writeUInt16LE(nameBytes.length, 26);
6945
+ head.writeUInt16LE(0, 28);
6946
+ return Buffer.concat([head, nameBytes]);
6947
+ }
6948
+ function centralHeader(entry) {
6949
+ const head = Buffer.alloc(46);
6950
+ head.writeUInt32LE(CENTRAL_SIG, 0);
6951
+ head.writeUInt16LE(VERSION, 4);
6952
+ head.writeUInt16LE(VERSION, 6);
6953
+ head.writeUInt16LE(FLAG_UTF8, 8);
6954
+ head.writeUInt16LE(METHOD_STORE, 10);
6955
+ head.writeUInt16LE(entry.dosTime, 12);
6956
+ head.writeUInt16LE(entry.dosDate, 14);
6957
+ head.writeUInt32LE(entry.crc, 16);
6958
+ head.writeUInt32LE(entry.size, 20);
6959
+ head.writeUInt32LE(entry.size, 24);
6960
+ head.writeUInt16LE(entry.nameBytes.length, 28);
6961
+ head.writeUInt16LE(0, 30);
6962
+ head.writeUInt16LE(0, 32);
6963
+ head.writeUInt16LE(0, 34);
6964
+ head.writeUInt16LE(0, 36);
6965
+ head.writeUInt32LE(0, 38);
6966
+ head.writeUInt32LE(entry.offset, 42);
6967
+ return Buffer.concat([head, entry.nameBytes]);
6968
+ }
6969
+ function endOfCentralDirectory(count, size, offset) {
6970
+ const eocd = Buffer.alloc(22);
6971
+ eocd.writeUInt32LE(EOCD_SIG, 0);
6972
+ eocd.writeUInt16LE(0, 4);
6973
+ eocd.writeUInt16LE(0, 6);
6974
+ eocd.writeUInt16LE(count, 8);
6975
+ eocd.writeUInt16LE(count, 10);
6976
+ eocd.writeUInt32LE(size, 12);
6977
+ eocd.writeUInt32LE(offset, 16);
6978
+ eocd.writeUInt16LE(0, 20);
6979
+ return eocd;
6980
+ }
6981
+ /**
6982
+ * True when one more entry still fits inside the classic (non-ZIP64) ceilings.
6983
+ *
6984
+ * Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
6985
+ * be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
6986
+ * 32-bit offset, producing an archive that looks complete and unpacks as
6987
+ * garbage) is precisely the one a test must be able to reach.
6988
+ */
6989
+ function zipEntryFits(query) {
6990
+ if (query.entryIndex >= 65535) return false;
6991
+ const nameLen = toAsciiZipName(query.name).length;
6992
+ return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
6993
+ }
6994
+ /**
6995
+ * An append-only ZIP writer.
6996
+ *
6997
+ * The caller supplies `write` — the real `res.write`, awaited, so backpressure
6998
+ * is the socket's. The writer holds only the central directory (one small record
6999
+ * per entry), never a body: {@link add} takes one file at a time and forgets it.
7000
+ */
7001
+ var ZipStreamWriter = class {
7002
+ write;
7003
+ entries = [];
7004
+ offset = 0;
7005
+ constructor(write) {
7006
+ this.write = write;
7007
+ }
7008
+ /** Entries accepted so far — what the manifest should claim, not what was asked. */
7009
+ get entryCount() {
7010
+ return this.entries.length;
7011
+ }
7012
+ /** Bytes emitted so far, central directory excluded. */
7013
+ get bytesWritten() {
7014
+ return this.offset;
7015
+ }
7016
+ /**
7017
+ * True when this entry still fits inside the classic ZIP ceilings.
7018
+ *
7019
+ * Checked BEFORE the header goes out, because a 32-bit offset that wraps
7020
+ * produces an archive that looks complete and unpacks as garbage — the failure
7021
+ * this writer most needs to make impossible.
7022
+ */
7023
+ fits(entry) {
7024
+ return zipEntryFits({
7025
+ offset: this.offset,
7026
+ entryIndex: this.entries.length,
7027
+ name: entry.name,
7028
+ bodyLength: entry.body.length
7029
+ });
7030
+ }
7031
+ /** Append one stored file. Returns false (and writes nothing) when it does not fit. */
7032
+ async add(entry) {
7033
+ if (!this.fits(entry)) return false;
7034
+ const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
7035
+ const crc = crc32(entry.body);
7036
+ const { time, date } = dosDateTime(entry.mtimeMs);
7037
+ const header = zipLocalHeader(entry, crc, nameBytes);
7038
+ this.entries.push({
7039
+ nameBytes,
7040
+ crc,
7041
+ size: entry.body.length,
7042
+ offset: this.offset,
7043
+ dosTime: time,
7044
+ dosDate: date
7045
+ });
7046
+ await this.write(header);
7047
+ await this.write(entry.body);
7048
+ this.offset += header.length + entry.body.length;
7049
+ return true;
7050
+ }
7051
+ /** Write the central directory + EOCD. The archive is unreadable without it. */
7052
+ async finish() {
7053
+ const start = this.offset;
7054
+ let size = 0;
7055
+ for (const entry of this.entries) {
7056
+ const block = centralHeader(entry);
7057
+ await this.write(block);
7058
+ size += block.length;
7059
+ }
7060
+ await this.write(endOfCentralDirectory(this.entries.length, size, start));
7061
+ this.offset = start + size + 22;
7062
+ }
7063
+ };
7064
+ var DEBUG_MANIFEST_ENTRY = "manifest.json";
7065
+ var DEBUG_TRACK_ENTRY = "track.json";
7066
+ var DEBUG_ANALYSIS_ENTRY = "analysis.json";
7067
+ var DEBUG_EVENTS_ENTRY = "events.json";
7068
+ /** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
7069
+ function debugTrackDir(deviceId, trackId) {
7070
+ return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
7071
+ }
7072
+ /**
7073
+ * Resolve every media row of one track to an archive-relative name.
7074
+ *
7075
+ * Deterministic: the input order (the store returns `timestamp asc`) fully
7076
+ * determines the output, so re-exporting an unchanged track produces the same
7077
+ * names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
7078
+ * carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
7079
+ */
7080
+ function planTrackMediaNames(media) {
7081
+ const occurrences = /* @__PURE__ */ new Map();
7082
+ for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
7083
+ const used = /* @__PURE__ */ new Set();
7084
+ const out = [];
7085
+ for (const m of media) {
7086
+ const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
7087
+ let name = `media/${base}.jpg`;
7088
+ for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
7089
+ used.add(name);
7090
+ out.push({
7091
+ key: m.key,
7092
+ name: toAsciiZipName(name),
7093
+ kind: m.kind,
7094
+ timestamp: m.timestamp,
7095
+ sizeBytes: m.sizeBytes
7096
+ });
7097
+ }
7098
+ return out;
7099
+ }
7100
+ var DEBUG_RETENTION_NOTE = "A `debug` mark does not pin retention (D81). A track with no media here may have been evicted rather than never captured.";
7101
+ /** The archive filename, stamped so two downloads never collide in ~/Downloads. */
7102
+ function debugArchiveFilename(nowMs) {
7103
+ return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
7104
+ }
7105
+ /** Parse `?deviceIds=615,617` — empty means every camera. */
7106
+ function parseDebugDeviceIds(url) {
7107
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
7108
+ const raw = new URLSearchParams(query).get("deviceIds");
7109
+ if (raw === null || raw.trim() === "") return [];
7110
+ const out = [];
7111
+ for (const part of raw.split(",")) {
7112
+ const n = Number.parseInt(part.trim(), 10);
7113
+ if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
7114
+ }
7115
+ return out;
7116
+ }
7117
+ //#endregion
7118
+ //#region src/pipeline-analytics/data-plane-access.ts
7119
+ /**
7120
+ * Prefix literals for the export planes, mirrored here so the policy reads as
7121
+ * a table without importing three handler modules into the addon's boot path.
7122
+ * `data-plane-access.spec.ts` asserts they still match the constants their
7123
+ * handlers register with — a mirror nothing checks is a lie waiting to happen.
7124
+ */
7125
+ var TRAINING_EXPORT_PREFIX_REF = "training-export";
7126
+ var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
7127
+ var DEBUG_EXPORT_PREFIX_REF = "debug-export";
7128
+ /**
7129
+ * Every data plane this addon serves, and the level the HUB enforces before it
7130
+ * proxies. Exhaustive by test: a new plane that forgets to appear here fails
7131
+ * `data-plane-access.spec.ts`.
7132
+ */
7133
+ var PIPELINE_ANALYTICS_PLANE_ACCESS = {
7134
+ /** One stored crop/thumbnail by an unguessable id the caller already holds. */
7135
+ "event-media": "authenticated",
7136
+ /** Self-authenticating single-use action token (D47). */
7137
+ "nc-action": "public",
7138
+ /** Self-authenticating single-use artifact token (D47). */
7139
+ "nc-artifact": "public",
7140
+ /** Every frame behind a marked selection — a corpus, not an item. */
7141
+ [TRAINING_EXPORT_PREFIX_REF]: "admin",
7142
+ [RETRAIN_EXPORT_PREFIX_REF]: "admin",
7143
+ /** Diagnostic bundle: logs, config and frames. */
7144
+ [DEBUG_EXPORT_PREFIX_REF]: "admin"
7145
+ };
7146
+ /** The declared level for a plane prefix. Typed, so a prefix with no declared
7147
+ * policy is a compile error rather than a silent default. */
7148
+ function planeAccess(prefix) {
7149
+ return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
7150
+ }
7151
+ //#endregion
7152
+ //#region src/pipeline-analytics/debug/debug-export-handler.ts
7153
+ /**
7154
+ * Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
7155
+ * it yields twice: between tracks, and between individual media files.
7156
+ *
7157
+ * The per-file yield is the one that matters. A track can own twenty blobs, and
7158
+ * reading them back to back — each a base64 decode of a few hundred KB —
7159
+ * occupies the loop for long enough that snapshot resizes start timing out. One
7160
+ * file at a time, with a gap, keeps the export off the critical path; it is slow
7161
+ * on purpose.
7162
+ */
7163
+ var TRACK_PACING_MS = 25;
7164
+ var MEDIA_PACING_MS = 15;
7165
+ function json(value) {
7166
+ return Buffer.from(JSON.stringify(value, null, 2), "utf8");
7167
+ }
7168
+ /** Total media rows the plan hopes to ship — the number HEAD reports. */
7169
+ function plannedMediaCount(plan) {
7170
+ let total = 0;
7171
+ for (const t of plan.tracks) total += t.media.length;
7172
+ return total;
7173
+ }
7174
+ function createDebugExportHandler(deps) {
7175
+ return async (req, res) => {
7176
+ const method = req.method ?? "GET";
7177
+ if (method !== "GET" && method !== "HEAD") {
7178
+ res.writeHead(405, { allow: "GET, HEAD" });
7179
+ res.end();
7180
+ return;
7181
+ }
7182
+ const deviceIds = parseDebugDeviceIds(req.url ?? "");
7183
+ let plan;
7184
+ try {
7185
+ plan = await deps.buildPlan(deviceIds);
7186
+ } catch (err) {
7187
+ deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
7188
+ res.writeHead(500, { "content-type": "text/plain" });
7189
+ res.end("debug export plan failed");
7190
+ return;
7191
+ }
7192
+ res.writeHead(200, {
7193
+ "content-type": "application/zip",
7194
+ "content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
7195
+ "cache-control": "no-store",
7196
+ "x-camstack-debug-tracks": String(plan.tracks.length),
7197
+ "x-camstack-debug-media": String(plannedMediaCount(plan))
7198
+ });
7199
+ if (method === "HEAD") {
7200
+ res.end();
7201
+ return;
7202
+ }
7203
+ const write = (chunk) => new Promise((resolve, reject) => {
7204
+ if (!res.write(chunk, (err) => {
7205
+ if (err) reject(err);
7206
+ else resolve();
7207
+ })) res.once("drain", resolve);
7208
+ });
7209
+ const zip = new ZipStreamWriter(write);
7210
+ const mtimeMs = deps.now();
7211
+ const startedAt = deps.now();
7212
+ const manifestTracks = [];
7213
+ let included = 0;
7214
+ let missing = 0;
7215
+ let stoppedBecause = plan.capped ? "track-cap" : null;
7216
+ for (const entry of plan.tracks) {
7217
+ if (zip.bytesWritten >= 268435456) {
7218
+ stoppedBecause ??= "byte-budget";
7219
+ deps.logger.warn("debug export stopped — byte budget reached", { meta: {
7220
+ budget: MAX_EXPORT_BYTES,
7221
+ written: zip.bytesWritten
7222
+ } });
7223
+ break;
7224
+ }
7225
+ const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
7226
+ const deviceId = entry.report.deviceId;
7227
+ for (const [name, body] of [
7228
+ [DEBUG_TRACK_ENTRY, json(entry.track)],
7229
+ [DEBUG_ANALYSIS_ENTRY, json(entry.report)],
7230
+ [DEBUG_EVENTS_ENTRY, json(entry.events)]
7231
+ ]) if (!await zip.add({
7232
+ name: `${dir}/${name}`,
7233
+ body,
7234
+ mtimeMs
7235
+ })) stoppedBecause ??= "zip-ceiling";
7236
+ const mediaIncluded = [];
7237
+ const mediaMissing = [];
7238
+ for (const media of entry.media) {
7239
+ const bytes = await deps.readMedia(media.key);
7240
+ if (bytes === null) {
7241
+ missing += 1;
7242
+ mediaMissing.push(media.key);
7243
+ deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
7244
+ tags: { deviceId },
7245
+ meta: {
7246
+ trackId: entry.report.trackId,
7247
+ mediaKey: media.key,
7248
+ kind: media.kind
7249
+ }
7250
+ });
7251
+ continue;
7252
+ }
7253
+ if (!await zip.add({
7254
+ name: `${dir}/${media.name}`,
7255
+ body: bytes,
7256
+ mtimeMs
7257
+ })) {
7258
+ stoppedBecause ??= "zip-ceiling";
7259
+ missing += 1;
7260
+ mediaMissing.push(media.key);
7261
+ deps.logger.warn("debug export skipped a media file — the archive is full", {
7262
+ tags: { deviceId },
7263
+ meta: {
7264
+ trackId: entry.report.trackId,
7265
+ mediaKey: media.key,
7266
+ kind: media.kind
7267
+ }
7268
+ });
7269
+ continue;
7270
+ }
7271
+ included += 1;
7272
+ mediaIncluded.push(media.name);
7273
+ await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
7274
+ }
7275
+ manifestTracks.push({
7276
+ trackId: entry.report.trackId,
7277
+ deviceId,
7278
+ dir,
7279
+ className: entry.report.className,
7280
+ ...entry.report.label === void 0 ? {} : { label: entry.report.label },
7281
+ firstSeen: entry.report.firstSeen,
7282
+ lastSeen: entry.report.lastSeen,
7283
+ durationMs: entry.report.durationMs,
7284
+ eventCount: entry.report.eventCount,
7285
+ findings: entry.report.findings.map((f) => f.code),
7286
+ mediaIncluded,
7287
+ mediaMissing
7288
+ });
7289
+ await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
7290
+ }
7291
+ const manifest = {
7292
+ generatedAt: plan.generatedAt,
7293
+ deviceIds: plan.deviceIds,
7294
+ trackCount: manifestTracks.length,
7295
+ mediaIncluded: included,
7296
+ mediaMissing: missing,
7297
+ truncated: stoppedBecause !== null,
7298
+ stoppedBecause,
7299
+ retentionNote: DEBUG_RETENTION_NOTE,
7300
+ tracks: manifestTracks
7301
+ };
7302
+ await zip.add({
7303
+ name: DEBUG_MANIFEST_ENTRY,
7304
+ body: json(manifest),
7305
+ mtimeMs
7306
+ });
7307
+ await zip.finish();
7308
+ await new Promise((resolve) => {
7309
+ res.end(resolve);
7310
+ });
7311
+ deps.logger.info("debug export complete", { meta: {
7312
+ tracks: manifestTracks.length,
7313
+ mediaIncluded: included,
7314
+ mediaMissing: missing,
7315
+ stoppedBecause,
7316
+ bytes: zip.bytesWritten,
7317
+ elapsedMs: deps.now() - startedAt
7318
+ } });
7319
+ };
7320
+ }
7321
+ //#endregion
7322
+ //#region src/pipeline-analytics/debug/debug-track-analysis.ts
7323
+ /** A track is "short" below this — the pipeline rarely produces a full media set. */
7324
+ var SHORT_TRACK_MS = 2e3;
7325
+ /** Below this, the label the track settled on is weak enough to be worth saying. */
7326
+ var LOW_BEST_SCORE = .5;
7327
+ /** Duration in ms. Never negative — a clock that went backwards reads as 0. */
7328
+ function trackDurationMs(facts) {
7329
+ return Math.max(0, facts.lastSeen - facts.firstSeen);
7330
+ }
7331
+ function hasKind(media, kind) {
7332
+ return media.some((m) => m.kind === kind);
7333
+ }
7334
+ function latestMediaAt(media) {
7335
+ let latest = null;
7336
+ for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
7337
+ return latest;
7338
+ }
7339
+ function finding(code, severity, detail) {
7340
+ return {
7341
+ code,
7342
+ severity,
7343
+ detail
7344
+ };
7345
+ }
7346
+ /**
7347
+ * Analyse one track. Pure — same inventory in, same findings out.
7348
+ *
7349
+ * Order is stable and roughly "worst first": the media the whole rest of the
7350
+ * system reads, then the track's own substance, then the softer signals.
7351
+ */
7352
+ function analyseDebugTrack(inventory) {
7353
+ const { facts, media, eventCount } = inventory;
7354
+ const durationMs = trackDurationMs(facts);
7355
+ const closed = !facts.active;
7356
+ const out = [];
7357
+ if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
7358
+ else if (closed) {
7359
+ if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
7360
+ if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
7361
+ if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
7362
+ if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
7363
+ if (!hasKind(media, "crop") && !hasKind(media, "faceCrop") && !hasKind(media, "plateCrop")) out.push(finding("MISSING_BEST_CROP", "warn", "No crop of any kind — nothing was cut for a subject."));
7364
+ }
7365
+ if (durationMs < 2e3) out.push(finding("SHORT_TRACK", "warn", `Track lasted ${String(durationMs)} ms, under the ${String(SHORT_TRACK_MS)} ms floor — likely a fragment.`));
7366
+ if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
7367
+ if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
7368
+ if (durationMs >= 1e4 && facts.snapshotCount === 0 && !hasKind(media, "snapshot")) out.push(finding("NO_PERIODIC_SNAPSHOTS", "warn", `No periodic snapshot over ${String(durationMs)} ms — the movement gate never opened.`));
7369
+ if (closed && hasKind(media, "lastFrame")) {
7370
+ const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
7371
+ const lag = facts.lastSeen - lastFrameAt;
7372
+ if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
7373
+ }
7374
+ if (closed && durationMs >= 1e4 && media.length > 0) {
7375
+ const latest = latestMediaAt(media);
7376
+ if (latest !== null) {
7377
+ const covered = (latest - facts.firstSeen) / durationMs;
7378
+ if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
7379
+ }
7380
+ }
7381
+ if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
7382
+ if (facts.labelScore !== void 0 && facts.labelScore < .5) out.push(finding("LOW_BEST_SCORE", "info", `Best label score ${facts.labelScore.toFixed(2)} is under ${String(LOW_BEST_SCORE)}.`));
7383
+ return out;
7384
+ }
7385
+ /** Fold an inventory into the report shape, findings included. */
7386
+ function buildDebugTrackReport(inventory) {
7387
+ const { facts, media, eventCount } = inventory;
7388
+ const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
7389
+ return {
7390
+ trackId: facts.trackId,
7391
+ deviceId: facts.deviceId,
7392
+ className: facts.className,
7393
+ ...facts.label === void 0 ? {} : { label: facts.label },
7394
+ firstSeen: facts.firstSeen,
7395
+ lastSeen: facts.lastSeen,
7396
+ durationMs: trackDurationMs(facts),
7397
+ active: facts.active,
7398
+ mediaCount: media.length,
7399
+ mediaKinds: kinds,
7400
+ eventCount,
7401
+ positionCount: facts.positionCount,
7402
+ findings: analyseDebugTrack(inventory)
7403
+ };
7404
+ }
7405
+ //#endregion
7406
+ //#region src/pipeline-analytics/debug/debug-service.ts
7407
+ /**
7408
+ * The three media owners one track's files hang off.
7409
+ *
7410
+ * A track's face and plate crops are NOT owned by `track:<trackId>` — they live
7411
+ * under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
7412
+ * first owner reports MISSING_BEST_CROP on every track that produced a face,
7413
+ * which is the opposite of the truth.
7414
+ */
7415
+ var MEDIA_OWNERS = [
7416
+ {
7417
+ kind: "track",
7418
+ prefix: ""
7419
+ },
7420
+ {
7421
+ kind: "face",
7422
+ prefix: "face-"
7423
+ },
7424
+ {
7425
+ kind: "plate",
7426
+ prefix: "plate-"
7427
+ }
7428
+ ];
7429
+ function toFacts(track) {
7430
+ return {
7431
+ trackId: track.trackId,
7432
+ deviceId: track.deviceId,
7433
+ className: track.className,
7434
+ ...track.label === void 0 ? {} : { label: track.label },
7435
+ ...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
7436
+ firstSeen: track.firstSeen,
7437
+ lastSeen: track.lastSeen,
7438
+ active: track.active,
7439
+ positionCount: track.positions.length,
7440
+ snapshotCount: track.snapshots.length,
7441
+ ...track.importance === void 0 ? {} : { importance: track.importance },
7442
+ ...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
7443
+ };
7444
+ }
7445
+ function toListItem(track) {
7446
+ return {
7447
+ trackId: track.trackId,
7448
+ deviceId: track.deviceId,
7449
+ className: track.className,
7450
+ ...track.label === void 0 ? {} : { label: track.label },
7451
+ firstSeen: track.firstSeen,
7452
+ lastSeen: track.lastSeen,
7453
+ durationMs: trackDurationMs(toFacts(track)),
7454
+ active: track.active
7455
+ };
7456
+ }
7457
+ /** Which cameras to sweep — the caller's list, else every camera with tracks. */
7458
+ async function resolveDeviceIds(deps, requested) {
7459
+ if (requested !== void 0 && requested.length > 0) return requested;
7460
+ return deps.listDeviceIds();
7461
+ }
7462
+ /**
7463
+ * The marked set, newest first per camera.
7464
+ *
7465
+ * One indexed query per camera and nothing else — no media, no events, and no
7466
+ * trajectory: `listDebug` reads `slim` rows, so nothing here parses the
7467
+ * `positions` JSON. The counts an operator wants (media, events, positions) come
7468
+ * from `debug.analyze`, per track, by id.
7469
+ *
7470
+ * A camera that hits `limit` sets `truncated`, because a list that silently
7471
+ * stops at 200 is how an operator concludes a mark did not take.
7472
+ */
7473
+ async function listDebugTracks(deps, input) {
7474
+ const limit = input.limit ?? 200;
7475
+ const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
7476
+ const tracks = [];
7477
+ let truncated = false;
7478
+ for (const deviceId of deviceIds) {
7479
+ const rows = await deps.listDebugTracks(deviceId, limit);
7480
+ if (rows.length >= limit) {
7481
+ truncated = true;
7482
+ deps.logger.warn("debug listTracks hit the per-camera cap", {
7483
+ tags: { deviceId },
7484
+ meta: {
7485
+ deviceId,
7486
+ limit
7487
+ }
7488
+ });
7489
+ }
7490
+ for (const row of rows) tracks.push(toListItem(row));
7491
+ }
7492
+ return {
7493
+ tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
7494
+ truncated
7495
+ };
7496
+ }
7497
+ /**
7498
+ * Gather one track's whole inventory: three media queries plus its events.
7499
+ *
7500
+ * The events are RETURNED as well as counted — the bundle writes them to
7501
+ * `events.json`, and fetching them a second time there would double the query
7502
+ * count of an export for a number the caller already holds.
7503
+ */
7504
+ async function gatherTrack(deps, track) {
7505
+ const media = [];
7506
+ for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
7507
+ const events = await deps.listEvents(track.trackId);
7508
+ return {
7509
+ inventory: {
7510
+ facts: toFacts(track),
7511
+ media,
7512
+ eventCount: events.length
7513
+ },
7514
+ events
7515
+ };
7516
+ }
7517
+ /**
7518
+ * Analyse the named tracks.
7519
+ *
7520
+ * An id that no longer resolves is reported in `vanished` rather than dropped: a
7521
+ * `debug` mark does not pin retention (D81), so a track CAN disappear between
7522
+ * the list and the analysis, and the tab has to be able to say so instead of
7523
+ * quietly showing fewer rows than it listed.
7524
+ */
7525
+ async function analyzeDebugTracks(deps, input) {
7526
+ const reports = [];
7527
+ const vanished = [];
7528
+ for (const trackId of input.trackIds) {
7529
+ const track = await deps.getTrack(trackId);
7530
+ if (track === null) {
7531
+ vanished.push(trackId);
7532
+ deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
7533
+ continue;
7534
+ }
7535
+ const gathered = await gatherTrack(deps, track);
7536
+ reports.push(buildDebugTrackReport(gathered.inventory));
7537
+ }
7538
+ return {
7539
+ reports,
7540
+ vanished
7541
+ };
7542
+ }
7543
+ /**
7544
+ * Plan the bundle — INDEX READS ONLY, no blob touched.
7545
+ *
7546
+ * The plan is what the manifest is built from and what the handler streams
7547
+ * against, so it must be produced in one pass: a plan that re-queried while
7548
+ * streaming could describe a set the archive does not contain.
7549
+ *
7550
+ * **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
7551
+ * index rows; exporting is every blob those rows point at, base64-decoded and
7552
+ * pushed through the hub's one thread. An unbounded "export all" is a
7553
+ * self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
7554
+ * rather than quietly shipping less than the button offered.
7555
+ *
7556
+ * Each row is re-read at full projection: `listDebugTracks` deliberately returns
7557
+ * `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
7558
+ * every track and write an empty `positions` array into `track.json`.
7559
+ */
7560
+ async function buildDebugBundlePlan(deps, requestedDeviceIds) {
7561
+ const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
7562
+ const tracks = [];
7563
+ const covered = [];
7564
+ let capped = false;
7565
+ for (const deviceId of deviceIds) {
7566
+ if (tracks.length >= 25) {
7567
+ capped = true;
7568
+ break;
7569
+ }
7570
+ const rows = await deps.listDebugTracks(deviceId, 200);
7571
+ if (rows.length > 0) covered.push(deviceId);
7572
+ for (const row of rows) {
7573
+ if (tracks.length >= 25) {
7574
+ capped = true;
7575
+ break;
7576
+ }
7577
+ const full = await deps.getTrack(row.trackId) ?? row;
7578
+ const { inventory, events } = await gatherTrack(deps, full);
7579
+ tracks.push({
7580
+ report: buildDebugTrackReport(inventory),
7581
+ track: full,
7582
+ events,
7583
+ media: planTrackMediaNames(inventory.media)
7584
+ });
7585
+ }
7586
+ }
7587
+ if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
7588
+ limit: 25,
7589
+ deviceIds: covered
7590
+ } });
7591
+ return {
7592
+ generatedAt: deps.now(),
7593
+ deviceIds: covered,
7594
+ tracks,
7595
+ capped
7596
+ };
7597
+ }
6720
7598
  //#endregion
6721
7599
  //#region src/pipeline-analytics/embedding-actions.ts
6722
7600
  /**
@@ -10963,6 +11841,13 @@ function eventsToClips(events, pad) {
10963
11841
  ...events.audio.map((e) => make(e, "audio"))
10964
11842
  ].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
10965
11843
  }
11844
+ /** Keep only clips whose window overlaps at least one recorded range — a clip
11845
+ * is a window over footage, so a window with nothing behind it is unplayable
11846
+ * and must not be listed (a camera that never recorded showed 15 dead audio
11847
+ * "clips" in the viewer ribbon, every tap a 500). Pure. */
11848
+ function filterClipsByFootage(clips, ranges) {
11849
+ return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
11850
+ }
10966
11851
  function createVideoclipsProvider(deps) {
10967
11852
  return {
10968
11853
  listClips: async ({ deviceId, since, until, limit }) => {
@@ -10972,7 +11857,20 @@ function createVideoclipsProvider(deps) {
10972
11857
  until,
10973
11858
  limit
10974
11859
  }), deps.defaultPad);
10975
- return limit != null ? clips.slice(0, limit) : clips;
11860
+ let filtered = clips;
11861
+ if (deps.fetchAvailability) try {
11862
+ filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
11863
+ deviceId,
11864
+ since,
11865
+ until
11866
+ }));
11867
+ } catch (err) {
11868
+ deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
11869
+ deviceId,
11870
+ error: err instanceof Error ? err.message : String(err)
11871
+ });
11872
+ }
11873
+ return limit != null ? filtered.slice(0, limit) : filtered;
10976
11874
  },
10977
11875
  getClipPlayback: async ({ deviceId, clipId }) => {
10978
11876
  const parsed = parseClipId(clipId);
@@ -10985,6 +11883,230 @@ function createVideoclipsProvider(deps) {
10985
11883
  }
10986
11884
  };
10987
11885
  }
11886
+ /**
11887
+ * The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
11888
+ *
11889
+ * This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
11890
+ * fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
11891
+ * copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
11892
+ * one encode per event over a ~12 s window, not a permanent transcode child
11893
+ * ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
11894
+ * it was measured on a real 615 720p cut before being chosen: **0.23 s of
11895
+ * encode, 254 KB out**, against the 922 KB the copy of the same window carried.
11896
+ * The re-encode is smaller than what it replaces.
11897
+ *
11898
+ * So both attachments now agree on the timeline as well as on the window: one
11899
+ * clip, one rate, two containers.
11900
+ */
11901
+ var DEFAULT_SPEED = 4;
11902
+ /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
11903
+ * at or below 1080p is an identity scale and the broker copies it. */
11904
+ var RING_MP4_MAX_WIDTH = 1920;
11905
+ /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
11906
+ var RING_MP4_FPS = 15;
11907
+ var NO_MEDIA = {
11908
+ mp4: null,
11909
+ gif: null,
11910
+ source: "none",
11911
+ startOffsetMs: null,
11912
+ endOffsetMs: null,
11913
+ profile: null,
11914
+ video: null
11915
+ };
11916
+ var EventMediaService = class {
11917
+ deps;
11918
+ constructor(deps) {
11919
+ this.deps = deps;
11920
+ }
11921
+ /**
11922
+ * Cut this event. Never throws — a notification that lost its media is still
11923
+ * a notification, and every branch that drops it logs why.
11924
+ */
11925
+ async cut(request) {
11926
+ if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
11927
+ const produced = await this.produce(request);
11928
+ if (produced !== null) return produced;
11929
+ return this.fallbackToRing(request);
11930
+ }
11931
+ async produce(request) {
11932
+ const deviceId = request.deviceId;
11933
+ const log = this.deps.logger;
11934
+ const kinds = [];
11935
+ if (request.wantMp4) kinds.push("mp4");
11936
+ if (request.wantGif) kinds.push("gif");
11937
+ let production;
11938
+ try {
11939
+ production = await this.deps.produce({
11940
+ deviceId,
11941
+ aroundMs: request.aroundMs,
11942
+ preSeconds: request.preRollSec,
11943
+ postSeconds: request.postRollSec,
11944
+ kinds,
11945
+ gifMaxWidth: 640,
11946
+ gifFps: 12,
11947
+ gifSpeed: 4,
11948
+ speed: request.speed ?? DEFAULT_SPEED,
11949
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
11950
+ });
11951
+ } catch (err) {
11952
+ log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
11953
+ tags: { deviceId },
11954
+ meta: { error: err instanceof Error ? err.message : String(err) }
11955
+ });
11956
+ return null;
11957
+ }
11958
+ const mp4 = await this.redeem(deviceId, production.media, "mp4");
11959
+ const gif = await this.redeem(deviceId, production.media, "gif");
11960
+ if (mp4 === null && gif === null) {
11961
+ log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
11962
+ tags: { deviceId },
11963
+ meta: {
11964
+ kinds: kinds.join(","),
11965
+ profile: production.profile
11966
+ }
11967
+ });
11968
+ return null;
11969
+ }
11970
+ const startOffsetMs = production.coverage.fromTs - request.aroundMs;
11971
+ const endOffsetMs = production.coverage.toTs - request.aroundMs;
11972
+ log.info("nc media: one production, every attachment", {
11973
+ tags: { deviceId },
11974
+ meta: {
11975
+ profile: production.profile,
11976
+ video: production.video,
11977
+ mp4Bytes: mp4?.byteLength ?? null,
11978
+ gifBytes: gif?.byteLength ?? null,
11979
+ startOffsetMs,
11980
+ endOffsetMs,
11981
+ sharedWindow: true
11982
+ }
11983
+ });
11984
+ return {
11985
+ mp4,
11986
+ gif,
11987
+ source: "produced",
11988
+ startOffsetMs,
11989
+ endOffsetMs,
11990
+ profile: production.profile,
11991
+ video: production.video
11992
+ };
11993
+ }
11994
+ /**
11995
+ * Redeem one artifact handle at the node that produced it.
11996
+ *
11997
+ * `null` is not an error here — a production simply may not carry the kind
11998
+ * (a gif whose derive failed says so on the broker's own log line). A handle
11999
+ * that FAILS to redeem is different and is logged, because it means the bytes
12000
+ * existed and did not arrive.
12001
+ */
12002
+ async redeem(deviceId, media, kind) {
12003
+ const artifact = media.find((m) => m.kind === kind);
12004
+ if (artifact === void 0) return null;
12005
+ try {
12006
+ const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
12007
+ if (res === null) {
12008
+ this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
12009
+ tags: { deviceId },
12010
+ meta: {
12011
+ kind,
12012
+ handle: artifact.handle,
12013
+ nodeId: artifact.nodeId
12014
+ }
12015
+ });
12016
+ return null;
12017
+ }
12018
+ const buf = Buffer.from(res.base64, "base64");
12019
+ if (buf.byteLength === 0) return null;
12020
+ const bytes = new Uint8Array(buf.byteLength);
12021
+ bytes.set(buf);
12022
+ return bytes;
12023
+ } catch (err) {
12024
+ this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
12025
+ tags: { deviceId },
12026
+ meta: {
12027
+ kind,
12028
+ handle: artifact.handle,
12029
+ nodeId: artifact.nodeId,
12030
+ error: err instanceof Error ? err.message : String(err)
12031
+ }
12032
+ });
12033
+ return null;
12034
+ }
12035
+ }
12036
+ /**
12037
+ * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
12038
+ *
12039
+ * Weaker than a production and deliberately so — the coherence here rests on
12040
+ * the two calls being given IDENTICAL parameters rather than on there being
12041
+ * one render. That is defensible because the ring only ever grows forward and
12042
+ * `windowAround` selects by wall clock around the same instant, so two calls
12043
+ * seconds apart choose the same packets; the one thing that could differ is
12044
+ * the rendition, so the profile is pinned to whatever the FIRST call actually
12045
+ * used and handed to the second.
12046
+ *
12047
+ * It exists for one reason: a hub whose `@camstack/server` predates the
12048
+ * production method must not stop attaching footage, and three of the four
12049
+ * live rules on this install ask for a gif and nothing else.
12050
+ */
12051
+ async fallbackToRing(request) {
12052
+ const deviceId = request.deviceId;
12053
+ const speed = request.speed ?? DEFAULT_SPEED;
12054
+ const window = {
12055
+ deviceId,
12056
+ aroundMs: request.aroundMs,
12057
+ preRollSec: request.preRollSec,
12058
+ postRollSec: request.postRollSec,
12059
+ speed,
12060
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
12061
+ };
12062
+ try {
12063
+ const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
12064
+ ...window,
12065
+ format: "mp4",
12066
+ maxWidth: RING_MP4_MAX_WIDTH,
12067
+ fps: RING_MP4_FPS
12068
+ }) : null;
12069
+ const gif = request.wantGif ? await this.deps.renderRingClip({
12070
+ ...window,
12071
+ format: "gif",
12072
+ maxWidth: 640,
12073
+ fps: 12 / 4,
12074
+ speed: 4
12075
+ }) : null;
12076
+ if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
12077
+ this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
12078
+ tags: { deviceId },
12079
+ meta: { aroundMs: request.aroundMs }
12080
+ });
12081
+ return NO_MEDIA;
12082
+ }
12083
+ this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
12084
+ tags: { deviceId },
12085
+ meta: {
12086
+ mp4Bytes: mp4?.byteLength ?? null,
12087
+ gifBytes: gif?.byteLength ?? null,
12088
+ profile: request.profile ?? null,
12089
+ sharedWindow: "by-parameters"
12090
+ }
12091
+ });
12092
+ return {
12093
+ mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
12094
+ gif: gif !== null && gif.byteLength > 0 ? gif : null,
12095
+ source: "ring-fallback",
12096
+ startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
12097
+ endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
12098
+ profile: request.profile ?? null,
12099
+ video: null
12100
+ };
12101
+ } catch (err) {
12102
+ this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
12103
+ tags: { deviceId },
12104
+ meta: { error: err instanceof Error ? err.message : String(err) }
12105
+ });
12106
+ return NO_MEDIA;
12107
+ }
12108
+ }
12109
+ };
10988
12110
  //#endregion
10989
12111
  //#region src/shared/frame/crop-extractor.ts
10990
12112
  /**
@@ -21873,6 +22995,10 @@ var TRACKS_COLUMNS = [
21873
22995
  name: "debug",
21874
22996
  type: "BOOLEAN"
21875
22997
  },
22998
+ {
22999
+ name: "hasFace",
23000
+ type: "BOOLEAN"
23001
+ },
21876
23002
  {
21877
23003
  name: "envMinX",
21878
23004
  type: "REAL"
@@ -21953,7 +23079,8 @@ function cloneTrack(t) {
21953
23079
  retrainStatus: t.retrainStatus,
21954
23080
  markForTrain: t.retrainStatus === "staging"
21955
23081
  } : {},
21956
- ...t.debug !== void 0 ? { debug: t.debug } : {}
23082
+ ...t.debug !== void 0 ? { debug: t.debug } : {},
23083
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
21957
23084
  };
21958
23085
  }
21959
23086
  var TrackStore = class {
@@ -22316,6 +23443,49 @@ var TrackStore = class {
22316
23443
  }
22317
23444
  }
22318
23445
  /**
23446
+ * Record that a face DETECTOR found a face on this track. Set-once, never
23447
+ * cleared, and deliberately NOT part of {@link setFlags}: that method is the
23448
+ * operator's write surface and this is the pipeline's.
23449
+ *
23450
+ * **The signal is detection, not recognition.** The caller is the detail-route
23451
+ * fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
23452
+ * `minFacePx` gate, before the recognizer is consulted, and regardless of
23453
+ * whether face recognition is enabled at all. That is the whole point: the
23454
+ * viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
23455
+ * and a flag that only fired when the recognizer answered would be true
23456
+ * exactly when the badge must not show.
23457
+ *
23458
+ * **Idempotent, and it does not write per detail.** A face detail lands many
23459
+ * times over a track's life. While the track is LIVE the flag is set in RAM
23460
+ * only and rides `persistCompleted` at expiry — one row write, not one per
23461
+ * frame. Once the flag is set, every later call returns immediately.
23462
+ *
23463
+ * The persisted patch is for the LATE detail: enrichment answers routinely
23464
+ * arrive after the track has expired, when there is no RAM entry left and the
23465
+ * row is the only thing to correct. Best-effort — a failed patch costs a badge,
23466
+ * never a track.
23467
+ */
23468
+ async markFaceDetected(trackId) {
23469
+ const active = this.active.get(trackId);
23470
+ if (active) {
23471
+ if (active.hasFace === true) return;
23472
+ active.hasFace = true;
23473
+ return;
23474
+ }
23475
+ try {
23476
+ await this.store.update.mutate({
23477
+ collection: TRACKS_COLLECTION,
23478
+ id: trackId,
23479
+ data: { hasFace: true }
23480
+ });
23481
+ } catch (err) {
23482
+ this.logger.warn("markFaceDetected persist failed", { meta: {
23483
+ trackId,
23484
+ error: String(err)
23485
+ } });
23486
+ }
23487
+ }
23488
+ /**
22319
23489
  * Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
22320
23490
  * express.
22321
23491
  *
@@ -22529,6 +23699,71 @@ var TrackStore = class {
22529
23699
  }
22530
23700
  }
22531
23701
  /**
23702
+ * The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
23703
+ *
23704
+ * Two things about this query are load-bearing.
23705
+ *
23706
+ * **`where: { debug: true }` matches only the explicit `true`.** The column is
23707
+ * nullable with no default (unlike `retrainStatus`), so a row that was never
23708
+ * marked holds NULL and positive equality skips it — which is exactly the set
23709
+ * wanted here, and the reason the column was left nullable in the first place.
23710
+ *
23711
+ * **The result is re-checked in memory.** `compileFilter` DROPS a predicate
23712
+ * whose column does not exist in SQLite — an older hub whose `declareCollection`
23713
+ * fingerprint never re-ran would answer this query with EVERY track on the
23714
+ * camera, silently, and the tab would present the whole history as
23715
+ * operator-marked. A skipped predicate is indistinguishable from a matching one
23716
+ * at the wire, so the guard is the only thing that can tell them apart.
23717
+ *
23718
+ * **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
23719
+ * JSON column averaging ~10 KB a row, and `'full'` parses it for every row
23720
+ * returned. That is affordable for a handful of marked tracks and ruinous in
23721
+ * the case this method already defends against: if the predicate is dropped,
23722
+ * the store hands back `limit` rows PER CAMERA and the trajectory of the
23723
+ * camera's whole recent history is parsed before the guard below throws it
23724
+ * away. The cheap question comes first (D56) — the list carries no trajectory,
23725
+ * and the per-track analysis reads the one row it needs through
23726
+ * `getPersistedByTrackId`.
23727
+ */
23728
+ async listDebug(deviceId, limit) {
23729
+ try {
23730
+ const tracks = (await this.store.query.query({
23731
+ collection: TRACKS_COLLECTION,
23732
+ filter: {
23733
+ where: {
23734
+ deviceId,
23735
+ debug: true
23736
+ },
23737
+ orderBy: {
23738
+ field: "lastSeen",
23739
+ direction: "desc"
23740
+ },
23741
+ limit
23742
+ }
23743
+ })).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
23744
+ const marked = tracks.filter((t) => t.debug === true);
23745
+ if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
23746
+ tags: { deviceId },
23747
+ meta: {
23748
+ deviceId,
23749
+ returned: tracks.length,
23750
+ marked: marked.length
23751
+ }
23752
+ });
23753
+ return marked;
23754
+ } catch (err) {
23755
+ this.logger.warn("TrackStore.listDebug failed", {
23756
+ tags: { deviceId },
23757
+ meta: {
23758
+ deviceId,
23759
+ limit,
23760
+ error: String(err)
23761
+ }
23762
+ });
23763
+ return [];
23764
+ }
23765
+ }
23766
+ /**
22532
23767
  * How many of this device's tracks are STAGING — optionally only those older
22533
23768
  * than a cutoff, i.e. exactly the rows a sweep just declined to touch.
22534
23769
  *
@@ -22935,6 +24170,7 @@ var TrackStore = class {
22935
24170
  ...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
22936
24171
  ...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
22937
24172
  ...t.debug !== void 0 ? { debug: t.debug } : {},
24173
+ ...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
22938
24174
  ...envelope !== null && dims !== void 0 ? {
22939
24175
  envMinX: envelope.minX,
22940
24176
  envMinY: envelope.minY,
@@ -22969,6 +24205,7 @@ var TrackStore = class {
22969
24205
  const audioLabels = data["audioLabels"];
22970
24206
  const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
22971
24207
  const debug = data["debug"];
24208
+ const hasFace = data["hasFace"];
22972
24209
  const envMinX = data["envMinX"];
22973
24210
  const envMinY = data["envMinY"];
22974
24211
  const envMaxX = data["envMaxX"];
@@ -23004,6 +24241,7 @@ var TrackStore = class {
23004
24241
  markForTrain: retrainParsed.data === "staging"
23005
24242
  } : {},
23006
24243
  ...typeof debug === "boolean" ? { debug } : {},
24244
+ ...typeof hasFace === "boolean" ? { hasFace } : {},
23007
24245
  ...envelope !== null ? { envelope } : {}
23008
24246
  };
23009
24247
  }
@@ -25374,6 +26612,31 @@ var LinkedCamerasCache = class {
25374
26612
  }
25375
26613
  };
25376
26614
  /**
26615
+ * THE camera-set derivation for a sensor-cap state change — one function, so
26616
+ * every consumer of a device-event attributes it to the same cameras.
26617
+ *
26618
+ * It is shared on purpose. The self-camera half (a doorbell wrapper bound on
26619
+ * camera 615 makes 615 both source and subject) was added to the history-row
26620
+ * ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
26621
+ * which kept resolving `camerasFor` alone. The result: camera 615's real
26622
+ * doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
26623
+ * timeline marker, for weeks, with the divergence invisible in both codepaths.
26624
+ * Two derivations of "which cameras" is one too many.
26625
+ */
26626
+ async function resolveSensorEventCameras(cache, data) {
26627
+ const linkedCameraIds = await cache.camerasFor(data.deviceId);
26628
+ const isCamera = await cache.isCamera(data.deviceId);
26629
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
26630
+ if (cameraIds.length === 0) return {
26631
+ cameraIds,
26632
+ dropReason: isCamera ? "camera-native-cap" : "no-camera"
26633
+ };
26634
+ return {
26635
+ cameraIds,
26636
+ dropReason: null
26637
+ };
26638
+ }
26639
+ /**
25377
26640
  * One `DeviceStateChanged` → N history rows, one per camera the event belongs
25378
26641
  * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
25379
26642
  * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
@@ -25386,11 +26649,9 @@ var LinkedCamerasCache = class {
25386
26649
  async function ingestSensorStateChange(deps, data, timestamp) {
25387
26650
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
25388
26651
  if (descriptor === void 0) return 0;
25389
- const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
25390
- const isCamera = await deps.cache.isCamera(data.deviceId);
25391
- const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
25392
- if (cameraIds.length === 0) {
25393
- deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
26652
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
26653
+ if (dropReason !== null) {
26654
+ deps.onDropped?.(dropReason, descriptor.kind);
25394
26655
  return 0;
25395
26656
  }
25396
26657
  const slice = data.slice;
@@ -26129,6 +27390,70 @@ var EventMediaDispatcher = class {
26129
27390
  }
26130
27391
  }
26131
27392
  };
27393
+ /**
27394
+ * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
27395
+ * synthetic track per camera the event belongs to. Returns the number of
27396
+ * markers that landed. Best-effort (telemetry, D8) — a materializer that
27397
+ * throws propagates to the caller, which logs it.
27398
+ */
27399
+ async function projectSensorMarkers(deps, data, timestamp) {
27400
+ const descriptor = EVENT_KIND_BY_CAP[data.capName];
27401
+ if (descriptor === void 0) {
27402
+ deps.onSkipped({
27403
+ cameraId: null,
27404
+ sourceDeviceId: data.deviceId,
27405
+ capName: data.capName,
27406
+ kind: null,
27407
+ reason: "unmapped-cap"
27408
+ });
27409
+ return 0;
27410
+ }
27411
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
27412
+ if (dropReason !== null) {
27413
+ deps.onSkipped({
27414
+ cameraId: null,
27415
+ sourceDeviceId: data.deviceId,
27416
+ capName: data.capName,
27417
+ kind: descriptor.kind,
27418
+ reason: dropReason
27419
+ });
27420
+ return 0;
27421
+ }
27422
+ const producingDeviceName = await deps.producingDeviceName(data.deviceId);
27423
+ let landed = 0;
27424
+ for (const cameraId of cameraIds) {
27425
+ const track = await deps.markers.materialize({
27426
+ cameraId,
27427
+ sourceDeviceId: data.deviceId,
27428
+ source: "sensor",
27429
+ kind: descriptor.kind,
27430
+ timestamp,
27431
+ snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
27432
+ ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
27433
+ });
27434
+ if (track === null) {
27435
+ deps.onSkipped({
27436
+ cameraId,
27437
+ sourceDeviceId: data.deviceId,
27438
+ capName: data.capName,
27439
+ kind: descriptor.kind,
27440
+ reason: "not-materialized"
27441
+ });
27442
+ continue;
27443
+ }
27444
+ landed++;
27445
+ deps.onProjected({
27446
+ cameraId,
27447
+ sourceDeviceId: data.deviceId,
27448
+ capName: data.capName,
27449
+ kind: descriptor.kind,
27450
+ trackId: track.trackId,
27451
+ selfCamera: cameraId === data.deviceId,
27452
+ hasMedia: track.snapshots.length > 0
27453
+ });
27454
+ }
27455
+ return landed;
27456
+ }
26132
27457
  /** JPEG quality for the downscaled full frame — matches the crop path. */
26133
27458
  var FULL_FRAME_QUALITY = 80;
26134
27459
  /**
@@ -26239,13 +27564,13 @@ var SyntheticTrackMaterializer = class {
26239
27564
  * answers: ≤10 s old on a mains camera, and on a battery camera the
26240
27565
  * intentionally stale-but-honest frame that costs no wake.
26241
27566
  */
26242
- async fetchSnapshot(cameraId) {
27567
+ async fetchSnapshot(cameraId, timeoutMs) {
26243
27568
  try {
26244
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
26245
- if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
27569
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
27570
+ if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
26246
27571
  return snap;
26247
27572
  } catch (err) {
26248
- this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
27573
+ this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
26249
27574
  return null;
26250
27575
  }
26251
27576
  }
@@ -26262,14 +27587,14 @@ var SyntheticTrackMaterializer = class {
26262
27587
  const trackId = this.makeId();
26263
27588
  const ts = input.timestamp;
26264
27589
  let mediaKey = null;
26265
- const snap = await this.fetchSnapshot(input.cameraId);
27590
+ const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
26266
27591
  if (snap !== null) try {
26267
27592
  const raw = Buffer.from(snap.base64, "base64");
26268
27593
  let data = raw;
26269
27594
  try {
26270
27595
  data = await downscaleFullFrameJpeg(raw, 960, 540);
26271
27596
  } catch (err) {
26272
- this.deps.onError?.("downscaleSnapshot", err);
27597
+ this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
26273
27598
  }
26274
27599
  mediaKey = await this.deps.media.put({
26275
27600
  deviceId: input.cameraId,
@@ -26280,7 +27605,7 @@ var SyntheticTrackMaterializer = class {
26280
27605
  data
26281
27606
  });
26282
27607
  } catch (err) {
26283
- this.deps.onError?.("snapshotMedia", err);
27608
+ this.deps.onError?.("snapshotMedia", err, input.cameraId);
26284
27609
  }
26285
27610
  const track = {
26286
27611
  trackId,
@@ -26305,7 +27630,7 @@ var SyntheticTrackMaterializer = class {
26305
27630
  try {
26306
27631
  await this.deps.tracks.persistSyntheticTrack(track);
26307
27632
  } catch (err) {
26308
- this.deps.onError?.("persistSyntheticTrack", err);
27633
+ this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
26309
27634
  return null;
26310
27635
  }
26311
27636
  return track;
@@ -29005,8 +30330,6 @@ var REBUILD_PAGE_SIZE = 200;
29005
30330
  */
29006
30331
  var REBUILD_FALLBACK_NODE_ID = "hub";
29007
30332
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
29008
- /** Operator-visible name of the fixed integration. */
29009
- var NC_INTEGRATION_NAME = "Notification Center";
29010
30333
  /**
29011
30334
  * The FULL action catalog for this addon.
29012
30335
  *
@@ -29017,7 +30340,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
29017
30340
  */
29018
30341
  var customActions = {
29019
30342
  ...ncActions,
29020
- ...embeddingActions
30343
+ ...embeddingActions,
30344
+ ...debugActions
29021
30345
  };
29022
30346
  /**
29023
30347
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -29100,15 +30424,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
29100
30424
  * Absent / empty / non-string all fall back to the hub default — the exact
29101
30425
  * narrowing the old raw read applied inline. */
29102
30426
  var PostProcessingNodeIdSchema = string().min(1);
29103
- /**
29104
- * Footage-attachment window + geometry, used when the rule states none. The
29105
- * window is CENTRED on the event, so the recipient sees the approach and what
29106
- * followed rather than one side of it.
29107
- */
29108
- var NC_FOOTAGE_PRE_ROLL_SEC = 3;
29109
- var NC_FOOTAGE_POST_ROLL_SEC = 5;
29110
- var NC_FOOTAGE_MAX_WIDTH = 480;
29111
- var NC_FOOTAGE_FPS = 5;
29112
30427
  /** Per-install HMAC secret behind the signed artifact links (minted once). */
29113
30428
  var NcArtifactSecretSchema = string();
29114
30429
  var EmbeddingEnabledSchema = boolean();
@@ -29273,6 +30588,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29273
30588
  trainingExportDataPlane = null;
29274
30589
  /** Handle for the retrain-export data-plane listener (dispose on shutdown). */
29275
30590
  retrainExportDataPlane = null;
30591
+ /** Handle for the debug-export data-plane listener (dispose on shutdown). */
30592
+ debugExportDataPlane = null;
29276
30593
  /** The retrain curation surface — see `retrain/retrain-service.ts`. Null
29277
30594
  * until the stores are built. */
29278
30595
  retrain = null;
@@ -29282,38 +30599,83 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29282
30599
  * data-plane handle. Null until served / when the facility is absent. */
29283
30600
  ncArtifactPlane = null;
29284
30601
  ncArtifactDataPlane = null;
29285
- /** Roadmap 4b: notification clips cut from the live fMP4 fragment seam.
29286
- * Built on first use — a hub whose rules never ask for an mp4 never
29287
- * constructs it, and constructing it costs nothing until `renderClip`. */
29288
- ncSeamClips = null;
29289
30602
  /**
29290
- * One attempt at the fMP4 seam. `null` means "the ring serves this one", and
29291
- * the REASON is always logged with `tags: { deviceId }`.
30603
+ * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
29292
30604
  *
29293
- * That log line is the whole point of the fallback being explicit rather than
29294
- * a try/catch: two cameras silently attaching two different kinds of clip is
29295
- * precisely the thing an operator cannot diagnose from a phone.
30605
+ * Built on first use — a hub whose rules never attach footage never
30606
+ * constructs it, and constructing it costs nothing until `cut`.
29296
30607
  */
29297
- async renderSeamClip(req) {
29298
- const log = this.ctx.logger;
30608
+ ncEventMedia = null;
30609
+ /**
30610
+ * The dispatcher's single media dep.
30611
+ *
30612
+ * ONE call for the gif and the mp4, deliberately: rendering them separately
30613
+ * is what produced a gif around the event and a video seven seconds after it
30614
+ * (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
30615
+ * slot, asks the broker's own prebuffer ring for the window at that slot's
30616
+ * own width in real time — which is what makes the broker COPY rather than
30617
+ * re-encode — and derives the gif from those same bytes.
30618
+ */
30619
+ async renderEventMedia(req) {
29299
30620
  const api = this.ctx.api;
29300
- this.ncSeamClips ??= new Fmp4ClipService({
29301
- logger: log.child("nc-seam-clip"),
29302
- listProfileEntries: (deviceId) => api.cameraStreams.getProfileRtspEntries.query({ deviceId })
30621
+ this.ncEventMedia ??= new EventMediaService({
30622
+ logger: this.ctx.logger.child("nc-event-media"),
30623
+ produce: (input) => api.streamBroker.produceEventMedia.mutate({
30624
+ deviceId: input.deviceId,
30625
+ aroundMs: input.aroundMs,
30626
+ preSeconds: input.preSeconds,
30627
+ postSeconds: input.postSeconds,
30628
+ kinds: [...input.kinds],
30629
+ gifMaxWidth: input.gifMaxWidth,
30630
+ gifFps: input.gifFps,
30631
+ gifSpeed: input.gifSpeed,
30632
+ speed: input.speed,
30633
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
30634
+ }),
30635
+ fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? nodePin(nodeId) : void 0),
30636
+ renderRingClip: async (input) => {
30637
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
30638
+ deviceId: input.deviceId,
30639
+ aroundMs: input.aroundMs,
30640
+ format: input.format,
30641
+ preRollSec: input.preRollSec,
30642
+ postRollSec: input.postRollSec,
30643
+ maxWidth: input.maxWidth,
30644
+ fps: input.fps,
30645
+ speed: input.speed,
30646
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
30647
+ });
30648
+ const buf = Buffer.from(res.base64, "base64");
30649
+ if (buf.byteLength === 0) return null;
30650
+ const bytes = new Uint8Array(buf.byteLength);
30651
+ bytes.set(buf);
30652
+ return bytes;
30653
+ }
29303
30654
  });
29304
30655
  try {
29305
- const result = await this.ncSeamClips.renderClip({
30656
+ const media = await this.ncEventMedia.cut({
29306
30657
  deviceId: req.deviceId,
29307
30658
  aroundMs: req.aroundMs,
29308
- postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC
30659
+ wantMp4: req.wantMp4,
30660
+ wantGif: req.wantGif,
30661
+ preRollSec: req.preRollSec,
30662
+ postRollSec: req.postRollSec,
30663
+ ...req.profile !== void 0 ? { profile: req.profile } : {},
30664
+ ...req.speed !== void 0 ? { speed: req.speed } : {}
29309
30665
  });
29310
- return result.ok ? result.clip.bytes : null;
30666
+ return {
30667
+ mp4: media.mp4,
30668
+ gif: media.gif
30669
+ };
29311
30670
  } catch (err) {
29312
- log.warn("nc clip: the fMP4 seam threw — falling back to the clip ring", {
30671
+ this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
29313
30672
  tags: { deviceId: req.deviceId },
29314
30673
  meta: { error: err instanceof Error ? err.message : String(err) }
29315
30674
  });
29316
- return null;
30675
+ return {
30676
+ mp4: null,
30677
+ gif: null
30678
+ };
29317
30679
  }
29318
30680
  }
29319
30681
  /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
@@ -29391,7 +30753,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29391
30753
  });
29392
30754
  const served = await this.ctx.dataPlane?.serve({
29393
30755
  prefix: "nc-action",
29394
- access: "public",
30756
+ access: planeAccess("nc-action"),
29395
30757
  handler: plane.handler
29396
30758
  }) ?? null;
29397
30759
  this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
@@ -29445,7 +30807,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29445
30807
  });
29446
30808
  this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
29447
30809
  prefix: "nc-artifact",
29448
- access: "public",
30810
+ access: planeAccess("nc-artifact"),
29449
30811
  handler: plane.handler
29450
30812
  }) ?? null;
29451
30813
  this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
@@ -29728,7 +31090,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29728
31090
  const api = this.ctx.api;
29729
31091
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
29730
31092
  await this.declareCollections(api);
29731
- await this.ensureAlarmPanel(api);
31093
+ await this.declareAlarmPanel(api);
29732
31094
  const logger = this.ctx.logger;
29733
31095
  const storage = await this.resolveMediaStorage(logger);
29734
31096
  const stores = await this.buildStores(api, logger, storage);
@@ -29790,11 +31152,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29790
31152
  ...isHub && ncHandlers !== void 0 ? {
29791
31153
  customActions: {
29792
31154
  ...ncActions,
29793
- ...embeddingActions
31155
+ ...embeddingActions,
31156
+ ...debugActions
29794
31157
  },
29795
31158
  actionHandlers: {
29796
31159
  ...ncHandlers,
29797
- ...this.buildEmbeddingActionHandlers()
31160
+ ...this.buildEmbeddingActionHandlers(),
31161
+ ...this.buildDebugActionHandlers()
29798
31162
  }
29799
31163
  } : {}
29800
31164
  };
@@ -29818,70 +31182,45 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29818
31182
  },
29819
31183
  listCallerTargetIds: async (userId) => {
29820
31184
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
29821
- }
31185
+ },
31186
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
29822
31187
  });
29823
31188
  }
29824
31189
  /** Declare typed collections up-front so the first insert doesn't race with
29825
31190
  * a CREATE TABLE. Idempotent. */
29826
31191
  /**
29827
- * Bring the Notification Center's alarm panel into existence, once.
31192
+ * DECLARE the Notification Center's alarm panel.
29828
31193
  *
29829
- * Both steps are get-or-create, because this runs on every boot and an
29830
- * operator must never end up with a second panel after a restart. The
29831
- * integration is FIXED: it exists because this addon says so, not because
29832
- * anybody added it, and there is no add/delete flow for it.
29833
- */
29834
- async ensureAlarmPanel(api) {
29835
- const devices = this.ctx.kernel.devices;
29836
- const rawNodeId = this.ctx.kernel.localNodeId ?? "hub";
29837
- const nodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] ?? "hub" : rawNodeId;
29838
- if (nodeId !== "hub") {
29839
- this.ctx.logger.info("alarm panel is hub-owned — skipping on this node", { meta: {
29840
- nodeId,
29841
- rawNodeId
29842
- } });
29843
- return;
29844
- }
31194
+ * The procedure is not here any more — `DeclaredDevices` owns the
31195
+ * get-or-create, the FIXED integration and its every-pass `fixed`
31196
+ * re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
31197
+ * second read that stops a transiently empty index re-stamping the declared
31198
+ * name over the operator's rename. What is left is the declaration and the
31199
+ * ports.
31200
+ */
31201
+ async declareAlarmPanel(api) {
31202
+ const declared = new DeclaredDevices({
31203
+ logger: this.ctx.logger.child("alarm"),
31204
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
31205
+ devices: this.ctx.kernel.devices,
31206
+ localNodeId: this.ctx.kernel.localNodeId,
31207
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
31208
+ createIntegration: async (input) => api.integrations.create.mutate(input),
31209
+ updateIntegration: async ({ id, info }) => {
31210
+ await api.integrations.update.mutate({
31211
+ id,
31212
+ info,
31213
+ skipRestart: true
31214
+ });
31215
+ },
31216
+ listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
31217
+ });
29845
31218
  try {
29846
- const result = await ensureAlarmPanel({
29847
- logger: this.ctx.logger.child("alarm"),
29848
- addonId: PIPELINE_ANALYTICS_ADDON_ID,
31219
+ const result = await declared.reconcile({
29849
31220
  integrationName: NC_INTEGRATION_NAME,
29850
- getIntegration: async (addonId) => {
29851
- const found = await api.integrations.getByAddonId.query({ addonId });
29852
- return found === null ? null : {
29853
- id: found.id,
29854
- fixed: found.info?.["fixed"] === true
29855
- };
29856
- },
29857
- markFixed: async (integrationId) => {
29858
- await api.integrations.update.mutate({
29859
- id: integrationId,
29860
- info: { fixed: true },
29861
- skipRestart: true
29862
- });
29863
- },
29864
- createIntegration: async (input) => api.integrations.create.mutate(input),
29865
- findDeviceByStableId: async (stableId) => {
29866
- const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
29867
- return found === void 0 ? null : { id: found.id };
29868
- },
29869
- createDevice: async ({ stableId, integrationId, name }) => {
29870
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
29871
- type: NC_ALARM_DEVICE_TYPE,
29872
- name,
29873
- integrationId
29874
- });
29875
- this.holdAlarmPanel(device);
29876
- return { id: device.id };
29877
- },
29878
- adoptDevice: async ({ stableId }) => {
29879
- const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
29880
- this.holdAlarmPanel(device);
29881
- return { id: device.id };
29882
- }
31221
+ devices: [NC_ALARM_DECLARATION]
29883
31222
  });
29884
- if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
31223
+ for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
29885
31224
  } catch (err) {
29886
31225
  this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
29887
31226
  }
@@ -29904,7 +31243,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29904
31243
  /**
29905
31244
  * Join the panel and the Notification Center, once both exist.
29906
31245
  *
29907
- * They are built in that order (`ensureAlarmPanel` runs before the centre is
31246
+ * They are built in that order (`declareAlarmPanel` runs before the centre is
29908
31247
  * constructed) and neither can reach the other on its own, so the join is
29909
31248
  * here. A node with no panel simply never calls it, and the cap answers
29910
31249
  * `deviceId: null` — which is what an agent node should say.
@@ -29952,7 +31291,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29952
31291
  let storage = this.ctx.kernel.storage;
29953
31292
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
29954
31293
  if (mediaRoot) {
29955
- const { FilesystemStorageProvider } = await import("../node-pIpcQ_aN.mjs");
31294
+ const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
29956
31295
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
29957
31296
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
29958
31297
  }
@@ -30410,28 +31749,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30410
31749
  y: pt.y
30411
31750
  })));
30412
31751
  },
30413
- renderFootage: async (req) => {
30414
- if (req.format === "mp4") {
30415
- const seam = await this.renderSeamClip(req);
30416
- if (seam !== null) return seam;
30417
- }
30418
- const res = await api.streamBroker.renderPreBufferClip.mutate({
30419
- deviceId: req.deviceId,
30420
- aroundMs: req.aroundMs,
30421
- format: req.format,
30422
- preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
30423
- postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
30424
- maxWidth: NC_FOOTAGE_MAX_WIDTH,
30425
- fps: NC_FOOTAGE_FPS,
30426
- ...req.speed !== void 0 ? { speed: req.speed } : {},
30427
- ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
30428
- });
30429
- const buf = Buffer.from(res.base64, "base64");
30430
- if (buf.byteLength === 0) return null;
30431
- const bytes = new Uint8Array(buf.byteLength);
30432
- bytes.set(buf);
30433
- return bytes;
30434
- },
31752
+ renderEventMedia: (req) => this.renderEventMedia(req),
30435
31753
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
30436
31754
  listTargets: async () => {
30437
31755
  return (await api.notificationOutput.listTargets.query({})).map((t) => {
@@ -30482,7 +31800,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30482
31800
  }
30483
31801
  /** Event-media data-plane: serve thumbnail JPEGs at
30484
31802
  * `/addon/<addonId>/event-media/<eventId>` through the hub's port.
30485
- * Mirror the recorder playback pattern: try/warn, store handle for dispose. */
31803
+ * Mirror the recorder playback pattern: try/warn, store handle for dispose.
31804
+ *
31805
+ * `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
31806
+ * the reason it changed: `getTrackMedia` / `getEventMedia` are declared
31807
+ * `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
31808
+ * admin gate protected nothing and cost every non-admin session its tiles. */
30486
31809
  async serveEventMediaDataPlane() {
30487
31810
  try {
30488
31811
  const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
@@ -30498,22 +31821,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30498
31821
  } });
30499
31822
  this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
30500
31823
  prefix: "event-media",
30501
- access: "admin",
31824
+ access: planeAccess("event-media"),
30502
31825
  handler
30503
31826
  }) ?? null;
30504
31827
  this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
30505
31828
  await this.serveNcArtifactPlane();
30506
31829
  await this.serveTrainingExportPlane();
30507
31830
  await this.serveRetrainExportPlane();
30508
- this.ctx.logger.info("event-media data-plane served", { meta: { baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)" } });
31831
+ await this.serveDebugExportPlane();
31832
+ this.ctx.logger.info("event-media data-plane served", { meta: {
31833
+ baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
31834
+ access: planeAccess("event-media")
31835
+ } });
30509
31836
  } catch (err) {
30510
31837
  this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
30511
31838
  }
30512
31839
  }
30513
31840
  /**
30514
- * The training-export data plane. Separate listener, `access: 'admin'` —
30515
- * marking a track is a viewer-level curation act (D81), but walking off with
30516
- * every stored frame behind the marked set is not.
31841
+ * The training-export data plane. Separate listener, admin-only (the level
31842
+ * is declared in `data-plane-access.ts`) — marking a track is a viewer-level
31843
+ * curation act (D81), but walking off with every stored frame behind the
31844
+ * marked set is not.
30517
31845
  */
30518
31846
  async serveTrainingExportPlane() {
30519
31847
  try {
@@ -30528,7 +31856,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30528
31856
  });
30529
31857
  this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
30530
31858
  prefix: "training-export",
30531
- access: "admin",
31859
+ access: planeAccess("training-export"),
30532
31860
  handler
30533
31861
  }) ?? null;
30534
31862
  } catch (err) {
@@ -30657,6 +31985,60 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30657
31985
  return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
30658
31986
  }
30659
31987
  /**
31988
+ * The store seam the `debug.*` actions and the debug bundle both read through.
31989
+ *
31990
+ * INDEX READS ONLY — three media-inventory queries and an event query per
31991
+ * track, no blob. The bytes are fetched one at a time, by key, by the export
31992
+ * handler, so a bundle of a hundred tracks never holds more than one file.
31993
+ */
31994
+ buildDebugServiceDeps() {
31995
+ const trackStore = this.trackStore;
31996
+ const mediaStore = this.mediaStore;
31997
+ const eventStore = this.eventStore;
31998
+ if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
31999
+ return {
32000
+ listDeviceIds: () => trackStore.listDeviceIds(),
32001
+ listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
32002
+ getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
32003
+ listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
32004
+ listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
32005
+ logger: this.ctx.logger.child("debug"),
32006
+ now: () => Date.now()
32007
+ };
32008
+ }
32009
+ /** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
32010
+ buildDebugActionHandlers() {
32011
+ return {
32012
+ "debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
32013
+ "debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
32014
+ };
32015
+ }
32016
+ /**
32017
+ * The debug-export data plane. Its own listener, for the same reason the
32018
+ * retrain export has one: a client asking for the debug bundle must never be
32019
+ * able to receive the annotated dataset by changing a query parameter.
32020
+ */
32021
+ async serveDebugExportPlane() {
32022
+ try {
32023
+ const handler = createDebugExportHandler({
32024
+ buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
32025
+ readMedia: async (key) => {
32026
+ const file = await this.mediaStore?.getByKey(key);
32027
+ return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
32028
+ },
32029
+ logger: this.ctx.logger.child("debug-export"),
32030
+ now: () => Date.now()
32031
+ });
32032
+ this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
32033
+ prefix: "debug-export",
32034
+ access: planeAccess("debug-export"),
32035
+ handler
32036
+ }) ?? null;
32037
+ } catch (err) {
32038
+ this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: errMsg(err) } });
32039
+ }
32040
+ }
32041
+ /**
30660
32042
  * The retrain-export data plane. Separate listener from the training export
30661
32043
  * next door: different archive, different contents, and a client asking for
30662
32044
  * one must never be able to receive the other by changing a query parameter.
@@ -30678,7 +32060,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30678
32060
  });
30679
32061
  this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
30680
32062
  prefix: "retrain-export",
30681
- access: "admin",
32063
+ access: planeAccess("retrain-export"),
30682
32064
  handler
30683
32065
  }) ?? null;
30684
32066
  } catch (err) {
@@ -30888,6 +32270,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30888
32270
  audio
30889
32271
  };
30890
32272
  },
32273
+ fetchAvailability: async ({ deviceId, since, until }) => {
32274
+ return (await api.recording.getAvailability.query({
32275
+ deviceId,
32276
+ fromMs: since,
32277
+ toMs: until
32278
+ })).ranges;
32279
+ },
32280
+ logWarn: (message, meta) => this.ctx.logger.warn(message, {
32281
+ tags: { deviceId: Number(meta["deviceId"]) },
32282
+ meta
32283
+ }),
30891
32284
  resolvePlayback: async ({ deviceId, startMs, endMs }) => {
30892
32285
  const manifest = await api.recording.getPlaybackManifest.query({
30893
32286
  deviceId,
@@ -31009,6 +32402,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31009
32402
  this.trainingExportDataPlane = null;
31010
32403
  await this.retrainExportDataPlane?.dispose();
31011
32404
  this.retrainExportDataPlane = null;
32405
+ await this.debugExportDataPlane?.dispose();
32406
+ this.debugExportDataPlane = null;
31012
32407
  }
31013
32408
  async handleInferenceResult(data) {
31014
32409
  if (this.shuttingDown) return;
@@ -31937,6 +33332,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
31937
33332
  async routeDetailResults(deviceId, trackId, details, frame) {
31938
33333
  for (const d of details) try {
31939
33334
  const isFaceDetail = d.className === "face";
33335
+ if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
31940
33336
  if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
31941
33337
  x: d.bbox.x,
31942
33338
  y: d.bbox.y,
@@ -33359,11 +34755,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33359
34755
  const store = this.sensorEventStore;
33360
34756
  const cache = this.linkedCamerasCache;
33361
34757
  if (store === null || cache === null) return;
34758
+ const persisted = [];
33362
34759
  try {
33363
34760
  await ingestSensorStateChange({
33364
34761
  sink: store,
33365
34762
  cache,
33366
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
34763
+ onPersisted: (ev) => persisted.push(ev),
33367
34764
  onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
33368
34765
  }, data, timestamp);
33369
34766
  } catch (err) {
@@ -33375,30 +34772,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33375
34772
  }
33376
34773
  });
33377
34774
  }
34775
+ const markerByCamera = /* @__PURE__ */ new Map();
33378
34776
  const materializer = this.syntheticTracks;
33379
- const descriptor = EVENT_KIND_BY_CAP[data.capName];
33380
- if (materializer === null || descriptor === void 0) return;
33381
- try {
33382
- const cameraIds = await cache.camerasFor(data.deviceId);
33383
- if (cameraIds.length === 0) {
33384
- this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
33385
- tags: { deviceId: data.deviceId },
34777
+ if (materializer !== null) try {
34778
+ await projectSensorMarkers({
34779
+ cache,
34780
+ markers: materializer,
34781
+ producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
34782
+ onProjected: (r) => {
34783
+ markerByCamera.set(r.cameraId, {
34784
+ trackId: r.trackId,
34785
+ hasMedia: r.hasMedia
34786
+ });
34787
+ this.ctx.logger.info("synthetic sensor-track projected", {
34788
+ tags: { deviceId: r.cameraId },
34789
+ meta: {
34790
+ capName: r.capName,
34791
+ kind: r.kind,
34792
+ trackId: r.trackId,
34793
+ sourceDeviceId: r.sourceDeviceId,
34794
+ selfCamera: r.selfCamera,
34795
+ hasMedia: r.hasMedia
34796
+ }
34797
+ });
34798
+ },
34799
+ onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
34800
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
33386
34801
  meta: {
33387
- capName: data.capName,
33388
- kind: descriptor.kind
34802
+ capName: r.capName,
34803
+ kind: r.kind,
34804
+ reason: r.reason,
34805
+ sourceDeviceId: r.sourceDeviceId
33389
34806
  }
33390
- });
33391
- return;
33392
- }
33393
- const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
33394
- for (const cameraId of cameraIds) await materializer.materialize({
33395
- cameraId,
33396
- sourceDeviceId: data.deviceId,
33397
- source: "sensor",
33398
- kind: descriptor.kind,
33399
- timestamp,
33400
- ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
33401
- });
34807
+ })
34808
+ }, data, timestamp);
33402
34809
  } catch (err) {
33403
34810
  this.ctx.logger.warn("synthetic sensor-track materialize failed", {
33404
34811
  tags: { deviceId: data.deviceId },
@@ -33408,6 +34815,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33408
34815
  }
33409
34816
  });
33410
34817
  }
34818
+ for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
33411
34819
  }
33412
34820
  /**
33413
34821
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.