@camstack/addon-post-analysis 1.2.51 → 1.2.52

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 boolean, A as encodeVectorBase64, B as vectorDimFromBase64, C as alarmPanelCapability, D as customAction, E as cosineSimilarity, F as notificationRulesCapability, G as DeviceType, H as zoneAnalyticsCapability, I as pipelineAnalyticsCapability, J as isDeviceScopedCap, K as createEvent, L as plateGalleryCapability, N as isScheduleActive, O as defineCustomActions, P as kebabToCamel, Q as array, R as readDeviceStateFrom, S as addonWidgetsSourceCapability, T as buildEventKindDescriptor, U as errMsg, V as videoclipsCapability, W as BaseAddon, X as sleep, Y as nodePin, Z as _enum, _ as OpsLogEntrySchema, a as LabelAttributionSchema, at as unknown, b as TimelapseRuleSchema, c as NC_TAXONOMY, d as NcRulePatchSchema, et as literal, f as NcRuleSchema, g as NcTaxonomySchema, h as NcSnoozeSuppressedSchema, i as EVENT_PAD_MS, it as string, j as faceGalleryCapability, l as NcConditionDescriptorSchema, m as NcSnoozeSchema, n as DEFAULT_EVENT_COLOR, nt as object, o as MACRO_LABELS, ot as EventCategory, p as NcSnoozeInputSchema, q as hydrateSchema, r as EVENT_KIND_BY_CAP, rt as record, s as NC_CONDITION_CATALOG, t as BaseDevice, tt as number, u as NcRuleInputSchema, v as RetrainStatusSchema, w as audioMetricsCapability, x as TrackSourceSchema, y as TimelapseRuleInputSchema, z as subKindsOf } from "../dist-CDLLzMJw.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
  /**
@@ -1103,557 +1102,6 @@ function mimeFromExtension(file) {
1103
1102
  return "application/octet-stream";
1104
1103
  }
1105
1104
  //#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
- }
1204
- }
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
- /**
1209
- * The next packet, `null` when the stream completed, or {@link TIMED_OUT}.
1210
- *
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.
1215
- */
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;
1234
- }
1235
- return out;
1236
- }
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, "");
1298
- }
1299
- //#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;
1421
- constructor(deps) {
1422
- this.deps = deps;
1423
- }
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
- }
1442
- });
1443
- return result;
1444
- }
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();
1497
- try {
1498
- await child.start();
1499
- } 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
- };
1509
- }
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
1554
- }
1555
- });
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);
1565
- }
1566
- }
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;
1578
- }
1579
- async readEntries(deviceId) {
1580
- try {
1581
- return await this.deps.listProfileEntries(deviceId);
1582
- } 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;
1588
- }
1589
- }
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
- };
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
- //#endregion
1657
1105
  //#region src/pipeline-analytics/pipeline/label-utils.ts
1658
1106
  /**
1659
1107
  * Every macro class the pipeline can carry on a detection. A `label` exists to
@@ -2556,20 +2004,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
2556
2004
  //#endregion
2557
2005
  //#region src/notification-center/dispatcher.ts
2558
2006
  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
2007
  var NcDispatcher = class {
2574
2008
  deps;
2575
2009
  targetCache = null;
@@ -2953,31 +2387,33 @@ var NcDispatcher = class {
2953
2387
  } : still);
2954
2388
  } else out.push(still);
2955
2389
  }
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
- }
2390
+ const wantGif = entry.payload.mediaGif === true;
2391
+ const wantMp4 = entry.payload.mediaClip === true;
2392
+ if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
2393
+ const media = await this.deps.renderEventMedia({
2394
+ deviceId: entry.payload.subject.deviceId,
2395
+ aroundMs: entry.payload.subject.timestamp,
2396
+ wantGif,
2397
+ wantMp4,
2398
+ preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
2399
+ postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
2400
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
2401
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
2402
+ });
2403
+ if (media.gif !== null && media.gif.byteLength > 0) out.push({
2404
+ mediaType: "gif",
2405
+ bytes: copyBytes(media.gif),
2406
+ mime: "image/gif",
2407
+ name: "event.gif"
2408
+ });
2409
+ if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
2410
+ mediaType: "video",
2411
+ bytes: copyBytes(media.mp4),
2412
+ mime: "video/mp4",
2413
+ name: "event.mp4"
2414
+ });
2415
+ } catch (err) {
2416
+ this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
2981
2417
  }
2982
2418
  return out;
2983
2419
  }
@@ -3146,6 +2582,18 @@ function paramNumber(params, key) {
3146
2582
  function clampPriority(priority) {
3147
2583
  return Math.max(1, Math.min(5, Math.round(priority)));
3148
2584
  }
2585
+ /**
2586
+ * A copy backed by its own `ArrayBuffer`.
2587
+ *
2588
+ * The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
2589
+ * by an assembler may sit on a shared buffer. Copying is cheap next to the
2590
+ * encode that produced it and removes a whole class of aliasing question.
2591
+ */
2592
+ function copyBytes(bytes) {
2593
+ const out = new Uint8Array(bytes.byteLength);
2594
+ out.set(bytes);
2595
+ return out;
2596
+ }
3149
2597
  //#endregion
3150
2598
  //#region src/notification-center/occupancy-watcher.ts
3151
2599
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
@@ -4002,6 +3450,406 @@ function rowToEntry$1(id, data) {
4002
3450
  };
4003
3451
  }
4004
3452
  //#endregion
3453
+ //#region src/notification-center/event-intake.ts
3454
+ /** Object-event insert (movement-state transitions + zone crossings). */
3455
+ function incomingFromObjectEvent(event) {
3456
+ return {
3457
+ subject: subjectFromObjectEvent(event),
3458
+ kind: "object-event",
3459
+ origin: "pipeline",
3460
+ log: () => ({
3461
+ tags: { deviceId: event.deviceId },
3462
+ meta: { eventId: event.id }
3463
+ })
3464
+ };
3465
+ }
3466
+ /** `TrackCloser.closeExpired` — the ONE close point. */
3467
+ function incomingFromTrack(track, info) {
3468
+ return {
3469
+ subject: subjectFromTrack(track, info),
3470
+ kind: "track-end",
3471
+ origin: "pipeline",
3472
+ log: () => ({
3473
+ tags: { deviceId: track.deviceId },
3474
+ meta: { trackId: track.trackId }
3475
+ })
3476
+ };
3477
+ }
3478
+ /** Sensor-event persist (one row per linked camera). */
3479
+ function incomingFromSensorEvent(event) {
3480
+ return {
3481
+ subject: subjectFromSensorEvent(event),
3482
+ kind: "device-event",
3483
+ origin: "sensor",
3484
+ log: () => ({
3485
+ tags: { deviceId: event.deviceId },
3486
+ meta: {
3487
+ sensorEventId: event.id,
3488
+ kind: event.kind
3489
+ }
3490
+ })
3491
+ };
3492
+ }
3493
+ /** Audio-event persist (classified episode). */
3494
+ function incomingFromAudioEvent(event) {
3495
+ return {
3496
+ subject: subjectFromAudioEvent(event),
3497
+ kind: "audio-event",
3498
+ origin: "pipeline",
3499
+ log: () => ({
3500
+ tags: { deviceId: event.deviceId },
3501
+ meta: {
3502
+ audioEventId: event.id,
3503
+ class: event.classification?.className
3504
+ }
3505
+ })
3506
+ };
3507
+ }
3508
+ /** Package object-event persist (delivered / picked-up). */
3509
+ function incomingFromPackageEvent(event, phase) {
3510
+ return {
3511
+ subject: subjectFromPackageEvent(event, phase),
3512
+ kind: "package-event",
3513
+ origin: "pipeline",
3514
+ log: () => ({
3515
+ tags: { deviceId: event.deviceId },
3516
+ meta: {
3517
+ eventId: event.id,
3518
+ phase
3519
+ }
3520
+ })
3521
+ };
3522
+ }
3523
+ /** A committed ZoneAnalytics occupancy edge. */
3524
+ function incomingFromOccupancyEdge(edge) {
3525
+ return {
3526
+ subject: subjectFromOccupancyEvent(edge),
3527
+ kind: "occupancy-event",
3528
+ origin: "pipeline",
3529
+ log: () => ({
3530
+ tags: { deviceId: edge.deviceId },
3531
+ meta: {
3532
+ zoneId: edge.zoneId ?? "@frame",
3533
+ className: edge.className ?? "@all",
3534
+ count: edge.count,
3535
+ threshold: edge.threshold,
3536
+ occupied: edge.occupied
3537
+ }
3538
+ })
3539
+ };
3540
+ }
3541
+ /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
3542
+ * record — same subject, honest origin in the log. */
3543
+ function asReconcile(event) {
3544
+ return {
3545
+ ...event,
3546
+ origin: "reconcile"
3547
+ };
3548
+ }
3549
+ /**
3550
+ * The delivery an intake kind rides. `object-event` and `audio-event` both ride
3551
+ * `immediate` (classified audio has no delivery of its own — it is gated by the
3552
+ * audio-class opt-in instead); `occupancy-event` rides `device-event`.
3553
+ *
3554
+ * Extracted from the centre so the tester can tell an operator which rules a
3555
+ * given simulated trigger will even be considered against, without evaluating.
3556
+ */
3557
+ function deliveryForKind(kind) {
3558
+ if (kind === "object-event" || kind === "audio-event") return "immediate";
3559
+ if (kind === "occupancy-event") return "device-event";
3560
+ return kind;
3561
+ }
3562
+ //#endregion
3563
+ //#region src/notification-center/test-event.ts
3564
+ /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
3565
+ var BboxSchema = object({
3566
+ x: number().min(0).max(1),
3567
+ y: number().min(0).max(1),
3568
+ w: number().min(0).max(1),
3569
+ h: number().min(0).max(1)
3570
+ });
3571
+ /**
3572
+ * What the operator typed. Every field maps onto exactly one field of
3573
+ * {@link NcRuleSubject} — there is no test-only condition input, because a
3574
+ * condition the tester could set and a real record could not is a condition the
3575
+ * test result would lie about.
3576
+ */
3577
+ var NcTestEventInputSchema = object({
3578
+ trigger: _enum([
3579
+ "object-event",
3580
+ "track-end",
3581
+ "device-event",
3582
+ "package-event",
3583
+ "audio-event",
3584
+ "occupancy-event"
3585
+ ]),
3586
+ /** The CAMERA the event is attributed to (what a notification shows). */
3587
+ deviceId: number().int(),
3588
+ /** DEVICE-EVENT: the linked device whose state changed — the contact, the
3589
+ * button, the doorbell — when that is not the camera. */
3590
+ sourceDeviceId: number().int().optional(),
3591
+ /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
3592
+ classNames: array(string()).default([]),
3593
+ /** Collapsed display label — an identity name, a plate, a subclass. */
3594
+ label: string().optional(),
3595
+ /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
3596
+ * condition FAILS on an absent confidence, and the tester must be able to
3597
+ * reproduce that. */
3598
+ confidence: number().min(0).max(1).optional(),
3599
+ /** Identity/plate MATCH confidence — distinct from `confidence`. */
3600
+ labelConfidence: number().min(0).max(1).optional(),
3601
+ /** Key-event importance [0,1] (track-end only, in practice). */
3602
+ importance: number().min(0).max(1).optional(),
3603
+ /** Track lifespan in seconds (track-end only). */
3604
+ dwellSeconds: number().min(0).optional(),
3605
+ /** Admin zone ids the subject is in (object) / visited (track). */
3606
+ zones: array(string()).default([]),
3607
+ /** OBJECT-EVENT: simulate a confirmed zone crossing. */
3608
+ crossing: object({
3609
+ zoneId: string(),
3610
+ direction: _enum(["enter", "exit"])
3611
+ }).optional(),
3612
+ /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
3613
+ sensorKind: string().optional(),
3614
+ /** DEVICE-EVENT: the raw device event-type token, when the source is an
3615
+ * event-emitter slice. */
3616
+ eventType: string().optional(),
3617
+ /** PACKAGE-EVENT: which lifecycle phase. */
3618
+ packagePhase: _enum(["delivered", "picked-up"]).optional(),
3619
+ /** Detection provenance. Absent = `pipeline` (what a record with no stamped
3620
+ * source reads as). */
3621
+ source: _enum([
3622
+ "pipeline",
3623
+ "onboard",
3624
+ "sensor",
3625
+ "audio"
3626
+ ]).optional(),
3627
+ /** Normalized bbox — feeds `customZones`, AND is what lets the still be
3628
+ * cropped / boxed the way the rule's media policy asks. */
3629
+ bbox: BboxSchema.optional(),
3630
+ /** OCCUPANCY-EVENT: the committed count edge. */
3631
+ occupancy: object({
3632
+ zoneId: string().optional(),
3633
+ zoneName: string().optional(),
3634
+ className: string().optional(),
3635
+ count: number().int().min(0),
3636
+ previousCount: number().int().min(0).default(0),
3637
+ occupied: boolean(),
3638
+ threshold: number().int().min(1)
3639
+ }).optional(),
3640
+ /** The instant the event happened. Absent = now. Set it to test a schedule
3641
+ * condition without waiting for the hour. */
3642
+ timestamp: number().int().optional(),
3643
+ /** Evaluate only this rule (what the per-rule Test button sends). */
3644
+ onlyRuleId: string().optional(),
3645
+ bypassCooldown: boolean().default(false),
3646
+ bypassMute: boolean().default(false),
3647
+ /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
3648
+ * not open a gate the operator did not ask to open. */
3649
+ runActions: boolean().default(false),
3650
+ /** Where the still comes from. `live` takes a snapshot of the camera NOW;
3651
+ * `none` sends the notification without one. */
3652
+ still: _enum(["live", "none"]).default("live")
3653
+ });
3654
+ /** Per-rule verdict, as the tester reports it. */
3655
+ var NcTestEventRuleOutcomeSchema = object({
3656
+ ruleId: string(),
3657
+ ruleName: string(),
3658
+ matched: boolean(),
3659
+ failedCondition: string().optional(),
3660
+ matchedOn: array(string()).optional(),
3661
+ cooldown: _enum([
3662
+ "n/a",
3663
+ "clear",
3664
+ "suppressed",
3665
+ "bypassed"
3666
+ ]),
3667
+ enqueued: number().int(),
3668
+ targetIds: array(string())
3669
+ });
3670
+ var NcTestEventResultSchema = object({
3671
+ /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
3672
+ * for in the logs, in the outbox and in the delivery history. */
3673
+ recordId: string(),
3674
+ /** Always `synthetic` — the marker the whole path carried. */
3675
+ origin: literal("synthetic"),
3676
+ /** The delivery the chosen trigger rides. A rule declaring anything else was
3677
+ * never a candidate, which is the answer to most "it did not fire". */
3678
+ delivery: string(),
3679
+ candidates: number().int(),
3680
+ outcomes: array(NcTestEventRuleOutcomeSchema),
3681
+ /** What the still ended up being. `unattachable` means the chosen TRIGGER
3682
+ * cannot carry one at all — a device-event notification has no media owner,
3683
+ * live or synthetic — and that is a property of production, not of the test. */
3684
+ still: _enum([
3685
+ "live",
3686
+ "none",
3687
+ "unavailable",
3688
+ "unattachable"
3689
+ ]),
3690
+ /** Everything the operator has to know to read the result honestly. */
3691
+ notes: array(string()),
3692
+ droppedBy: string().optional()
3693
+ });
3694
+ /** The id shape every synthetic record carries. One prefix, everywhere. */
3695
+ var SYNTHETIC_ID_PREFIX = "synthetic:";
3696
+ /** True for a record id this module minted. Used by the media shim. */
3697
+ function isSyntheticId(id) {
3698
+ return id.startsWith(SYNTHETIC_ID_PREFIX);
3699
+ }
3700
+ /**
3701
+ * Whether a subject of this kind can carry a still AT ALL, in production.
3702
+ *
3703
+ * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
3704
+ * owner onto the payload only for the kinds that have one: an `eventId` for
3705
+ * object/package events, a `trackId` for track-scoped ones. A device-event, an
3706
+ * audio episode and an occupancy edge freeze neither — so those notifications
3707
+ * ship text-only today, and a tester that attached an image to them would be
3708
+ * advertising a feature that does not exist.
3709
+ */
3710
+ function triggerCanCarryStill(kind) {
3711
+ return kind === "object-event" || kind === "package-event" || kind === "track-end";
3712
+ }
3713
+ /**
3714
+ * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
3715
+ * event, so the red-green test can assert that this subject and a
3716
+ * producer-built one are indistinguishable.
3717
+ */
3718
+ function buildSyntheticEvent(input, recordId, now) {
3719
+ const kind = input.trigger;
3720
+ const timestamp = input.timestamp ?? now;
3721
+ const trackScoped = kind === "object-event" || kind === "track-end";
3722
+ return {
3723
+ subject: {
3724
+ kind,
3725
+ recordId,
3726
+ deviceId: input.deviceId,
3727
+ ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
3728
+ timestamp,
3729
+ classNames: input.classNames,
3730
+ ...input.label !== void 0 ? { label: input.label } : {},
3731
+ ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
3732
+ zones: input.zones,
3733
+ ...trackScoped ? { trackId: recordId } : {},
3734
+ source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
3735
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
3736
+ ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
3737
+ ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
3738
+ ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
3739
+ ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
3740
+ ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
3741
+ ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
3742
+ ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
3743
+ ...input.occupancy !== void 0 ? { occupancy: {
3744
+ ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
3745
+ ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
3746
+ ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
3747
+ count: input.occupancy.count,
3748
+ previousCount: input.occupancy.previousCount,
3749
+ occupied: input.occupancy.occupied,
3750
+ threshold: input.occupancy.threshold
3751
+ } } : {}
3752
+ },
3753
+ kind,
3754
+ origin: "synthetic",
3755
+ controls: {
3756
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
3757
+ bypassCooldown: input.bypassCooldown,
3758
+ bypassMute: input.bypassMute,
3759
+ runActions: input.runActions
3760
+ },
3761
+ log: () => ({
3762
+ tags: { deviceId: input.deviceId },
3763
+ meta: {
3764
+ eventId: recordId,
3765
+ synthetic: true,
3766
+ trigger: input.trigger,
3767
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
3768
+ }
3769
+ })
3770
+ };
3771
+ }
3772
+ /**
3773
+ * Turn one live JPEG into the media set the dispatcher's ladders look for.
3774
+ *
3775
+ * Every entry is the SAME photograph, differing only in how it was cut — which
3776
+ * is exactly what the ladders mean by their kind names, so nothing here claims
3777
+ * a frame it does not have:
3778
+ *
3779
+ * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
3780
+ * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
3781
+ * - `crop` / `thumbnail` — that bbox, cut out.
3782
+ *
3783
+ * With NO bbox only the clean-scene kinds are produced, so a rule asking for
3784
+ * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
3785
+ * scene it did not ask for. sharp failures degrade the same way: the clean
3786
+ * scene always survives.
3787
+ */
3788
+ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
3789
+ const base64 = Buffer.from(jpeg).toString("base64");
3790
+ const clean = (kind) => ({
3791
+ key: `${SYNTHETIC_ID_PREFIX}${kind}`,
3792
+ kind,
3793
+ base64,
3794
+ sizeBytes: jpeg.byteLength,
3795
+ timestamp
3796
+ });
3797
+ const files = [
3798
+ clean("fullFrame"),
3799
+ clean("keyFrame"),
3800
+ clean("firstFrame")
3801
+ ];
3802
+ if (bbox === void 0) return files;
3803
+ try {
3804
+ const { default: sharp } = await import("sharp");
3805
+ const meta = await sharp(Buffer.from(jpeg)).metadata();
3806
+ const W = meta.width ?? 0;
3807
+ const H = meta.height ?? 0;
3808
+ if (W === 0 || H === 0) return files;
3809
+ const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
3810
+ const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
3811
+ const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
3812
+ const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
3813
+ const cropBuf = await sharp(Buffer.from(jpeg)).extract({
3814
+ left,
3815
+ top,
3816
+ width,
3817
+ height
3818
+ }).jpeg({ quality: 85 }).toBuffer();
3819
+ const cropB64 = cropBuf.toString("base64");
3820
+ files.push({
3821
+ key: `${SYNTHETIC_ID_PREFIX}crop`,
3822
+ kind: "crop",
3823
+ base64: cropB64,
3824
+ sizeBytes: cropBuf.byteLength,
3825
+ timestamp
3826
+ }, {
3827
+ key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
3828
+ kind: "thumbnail",
3829
+ base64: cropB64,
3830
+ sizeBytes: cropBuf.byteLength,
3831
+ timestamp
3832
+ });
3833
+ const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
3834
+ 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>`);
3835
+ const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
3836
+ input: overlay,
3837
+ top: 0,
3838
+ left: 0
3839
+ }]).jpeg({ quality: 85 }).toBuffer();
3840
+ files.push({
3841
+ key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
3842
+ kind: "fullFrameBoxed",
3843
+ base64: boxedBuf.toString("base64"),
3844
+ sizeBytes: boxedBuf.byteLength,
3845
+ timestamp
3846
+ });
3847
+ } catch {
3848
+ return files;
3849
+ }
3850
+ return files;
3851
+ }
3852
+ //#endregion
4005
3853
  //#region src/notification-center/action-buttons.ts
4006
3854
  /** Sequence names the rule actually declares. A button may only name one. */
4007
3855
  function declaredSequences(actions) {
@@ -5303,6 +5151,16 @@ var WATERMARK_EVERY_TICKS = 15;
5303
5151
  var OUTBOX_RETENTION_MS = 168 * 36e5;
5304
5152
  var TEST_RULE_MAX_RESULTS = 200;
5305
5153
  /**
5154
+ * How long a synthetic injection's live snapshot stays in RAM.
5155
+ *
5156
+ * Long enough to cover the drain tick plus the first handful of outbox
5157
+ * backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
5158
+ * cannot hold a JPEG for the life of the process. A row still retrying after
5159
+ * this loses its picture and ships text-only — the same degradation a real
5160
+ * event gets when its media never landed.
5161
+ */
5162
+ var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
5163
+ /**
5306
5164
  * The className every durable package event carries (mirrors
5307
5165
  * `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
5308
5166
  * stays free of a cross-module import into the pipeline). Delivery events use
@@ -5416,6 +5274,13 @@ var NotificationCenter = class NotificationCenter {
5416
5274
  alarmPanel = null;
5417
5275
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
5418
5276
  lastFiredAt = /* @__PURE__ */ new Map();
5277
+ /**
5278
+ * The live snapshot a synthetic injection took, held for as long as the
5279
+ * outbox may still be retrying its rows. In RAM and only in RAM: a test that
5280
+ * left a media blob behind would be a test that persisted something, which
5281
+ * is the one thing the synthetic producer promises not to do.
5282
+ */
5283
+ syntheticMedia = /* @__PURE__ */ new Map();
5419
5284
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
5420
5285
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
5421
5286
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -5573,6 +5438,11 @@ var NotificationCenter = class NotificationCenter {
5573
5438
  this.dispatcher = new NcDispatcher({
5574
5439
  ...deps.dispatcher,
5575
5440
  logger: this.logger.child("dispatch"),
5441
+ getMediaForOwner: async (ownerKind, ownerId) => {
5442
+ const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
5443
+ if (held !== void 0) return held.files;
5444
+ return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
5445
+ },
5576
5446
  ...deps.now !== void 0 ? { now: deps.now } : {},
5577
5447
  isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
5578
5448
  snoozeGate: (input) => this.snoozeGate(input)
@@ -5651,19 +5521,157 @@ var NotificationCenter = class NotificationCenter {
5651
5521
  this.evaluationActive = false;
5652
5522
  }
5653
5523
  /**
5524
+ * Consume ONE event. THE entry point of the notification path — every
5525
+ * producer arrives here and nothing else does.
5526
+ *
5527
+ * Fire-and-forget: the frame path must never await a notification. The
5528
+ * evaluation is appended to the serialized chain, so consumption order is
5529
+ * persist order (see {@link evalChain}).
5530
+ *
5531
+ * It persists nothing about the event itself. Whether a durable row exists is
5532
+ * the PRODUCER's business — the tracker writes its tracks, the sensor ingest
5533
+ * its rows, the synthetic tester nothing at all — and the only thing written
5534
+ * from here on is the outbox row, which is the notification's own delivery
5535
+ * queue and not a record of the event.
5536
+ */
5537
+ consumeEvent(event) {
5538
+ if (!this.evaluationActive) return;
5539
+ this.scheduleEvaluation(event);
5540
+ }
5541
+ /**
5542
+ * The same consumption, awaited, with the verdict returned. Identical code
5543
+ * path — {@link consumeEvent} is this function with the report discarded — so
5544
+ * a producer that reads the report is testing what the live producers do, not
5545
+ * a parallel implementation of it.
5546
+ */
5547
+ async consumeEventAndReport(event) {
5548
+ if (!this.evaluationActive) return {
5549
+ recordId: event.subject.recordId,
5550
+ kind: event.kind,
5551
+ delivery: deliveryForKind(event.kind),
5552
+ origin: event.origin,
5553
+ candidates: 0,
5554
+ outcomes: [],
5555
+ droppedBy: "evaluation-inactive"
5556
+ };
5557
+ const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
5558
+ this.evalChain = result.then(() => void 0, () => void 0);
5559
+ return result;
5560
+ }
5561
+ /**
5562
+ * The SYNTHETIC producer — an operator-driven event, through the whole path.
5563
+ *
5564
+ * It builds an envelope and calls {@link consumeEventAndReport}. That is the
5565
+ * entire integration: no bypass, no second matcher, no shortcut into the
5566
+ * dispatcher. What the operator receives on their phone was matched by
5567
+ * `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
5568
+ * tick, given media by the ordinary ladders, degraded by the ordinary engine
5569
+ * and delivered by the ordinary notifier.
5570
+ *
5571
+ * What it does NOT do is persist the event. No track row, no object-event
5572
+ * row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
5573
+ * expiry. The outbox row is the exception that proves the rule: it is the
5574
+ * notification's delivery queue, not a record of the event, and it carries
5575
+ * `synthetic: true` so it can never be mistaken for one.
5576
+ */
5577
+ async injectTestEvent(input, deps = {}) {
5578
+ const now = this.now();
5579
+ this.pruneSyntheticMedia(now);
5580
+ const recordId = `${SYNTHETIC_ID_PREFIX}${randomUUID()}`;
5581
+ const event = buildSyntheticEvent(input, recordId, now);
5582
+ const notes = [];
5583
+ let still = "none";
5584
+ if (!triggerCanCarryStill(event.kind)) {
5585
+ still = "unattachable";
5586
+ 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`);
5587
+ } else if (input.still === "live") {
5588
+ const jpeg = await this.captureLiveStill(input.deviceId, deps);
5589
+ if (jpeg === null) {
5590
+ still = "unavailable";
5591
+ notes.push("the camera returned no snapshot — the notification ships without a still");
5592
+ } else {
5593
+ const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
5594
+ this.syntheticMedia.set(recordId, {
5595
+ files,
5596
+ expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
5597
+ });
5598
+ still = "live";
5599
+ 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");
5600
+ }
5601
+ }
5602
+ if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
5603
+ if (input.bypassMute) notes.push("per-camera notification mute bypassed");
5604
+ if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
5605
+ this.logger.info("synthetic event injected", {
5606
+ tags: { deviceId: input.deviceId },
5607
+ meta: {
5608
+ eventId: recordId,
5609
+ synthetic: true,
5610
+ trigger: input.trigger,
5611
+ classes: input.classNames,
5612
+ zones: input.zones,
5613
+ still,
5614
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
5615
+ }
5616
+ });
5617
+ const report = await this.consumeEventAndReport(event);
5618
+ 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}"`);
5619
+ if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
5620
+ if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
5621
+ 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");
5622
+ return {
5623
+ recordId,
5624
+ origin: "synthetic",
5625
+ delivery: report.delivery,
5626
+ candidates: report.candidates,
5627
+ outcomes: report.outcomes.map((o) => ({
5628
+ ruleId: o.ruleId,
5629
+ ruleName: o.ruleName,
5630
+ matched: o.matched,
5631
+ ...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
5632
+ ...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
5633
+ cooldown: o.cooldown,
5634
+ enqueued: o.enqueued,
5635
+ targetIds: [...o.targetIds]
5636
+ })),
5637
+ still,
5638
+ notes,
5639
+ ...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
5640
+ };
5641
+ }
5642
+ /** One live frame, or null. Never throws into an injection. */
5643
+ async captureLiveStill(deviceId, deps) {
5644
+ const capture = deps.getSnapshot;
5645
+ if (capture === void 0) return null;
5646
+ try {
5647
+ const shot = await capture(deviceId);
5648
+ if (shot === null || shot.base64.length === 0) return null;
5649
+ const buf = Buffer.from(shot.base64, "base64");
5650
+ const bytes = new Uint8Array(buf.byteLength);
5651
+ bytes.set(buf);
5652
+ return bytes;
5653
+ } catch (err) {
5654
+ this.logger.warn("synthetic injection could not photograph the camera", {
5655
+ tags: { deviceId },
5656
+ meta: { error: String(err) }
5657
+ });
5658
+ return null;
5659
+ }
5660
+ }
5661
+ /** Drop expired synthetic stills. Called on every injection — there is no
5662
+ * timer, because the map is empty except while a test is in flight. */
5663
+ pruneSyntheticMedia(now) {
5664
+ for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
5665
+ }
5666
+ /**
5654
5667
  * Called at the object-event persist site, in the SAME moment as the
5655
5668
  * durable insert. Fire-and-forget from the frame path (never blocks the
5656
5669
  * pipeline); the boot reconcile covers a crash between persist and
5657
5670
  * outbox insert.
5658
5671
  */
5659
5672
  onObjectEventPersisted(event, _track) {
5660
- if (!this.evaluationActive) return;
5661
5673
  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
- }));
5674
+ this.consumeEvent(incomingFromObjectEvent(event));
5667
5675
  }
5668
5676
  /**
5669
5677
  * Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
@@ -5672,12 +5680,7 @@ var NotificationCenter = class NotificationCenter {
5672
5680
  * post-close promotions are picked up).
5673
5681
  */
5674
5682
  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
- }));
5683
+ this.consumeEvent(incomingFromTrack(track, info));
5681
5684
  }
5682
5685
  /**
5683
5686
  * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
@@ -5693,15 +5696,7 @@ var NotificationCenter = class NotificationCenter {
5693
5696
  * before it.
5694
5697
  */
5695
5698
  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
- }));
5699
+ this.consumeEvent(incomingFromSensorEvent(event));
5705
5700
  }
5706
5701
  /**
5707
5702
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -5716,15 +5711,7 @@ var NotificationCenter = class NotificationCenter {
5716
5711
  * durable guarantee begins at this hook, matching the device-event boundary.
5717
5712
  */
5718
5713
  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
- }));
5714
+ this.consumeEvent(incomingFromAudioEvent(event));
5728
5715
  }
5729
5716
  /**
5730
5717
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -5733,15 +5720,7 @@ var NotificationCenter = class NotificationCenter {
5733
5720
  * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
5734
5721
  */
5735
5722
  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
- }));
5723
+ this.consumeEvent(incomingFromPackageEvent(event, phase));
5745
5724
  }
5746
5725
  /**
5747
5726
  * Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
@@ -5777,18 +5756,7 @@ var NotificationCenter = class NotificationCenter {
5777
5756
  * Public so a test / a future out-of-band edge source can drive it directly.
5778
5757
  */
5779
5758
  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
- }));
5759
+ this.consumeEvent(incomingFromOccupancyEdge(edge));
5792
5760
  }
5793
5761
  buildProvider() {
5794
5762
  return {
@@ -5963,40 +5931,55 @@ var NotificationCenter = class NotificationCenter {
5963
5931
  });
5964
5932
  }
5965
5933
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
5966
- scheduleEvaluation(subject, kind, logContext) {
5934
+ scheduleEvaluation(event) {
5967
5935
  this.evalChain = this.evalChain.then(async () => {
5968
5936
  try {
5969
- await this.evaluateAndEnqueue(subject, kind);
5937
+ await this.evaluateAndEnqueue(event);
5970
5938
  } catch (err) {
5971
- const ctx = logContext();
5939
+ const ctx = event.log();
5972
5940
  this.logger.warn("rule evaluation failed", {
5973
5941
  tags: ctx.tags,
5974
5942
  meta: {
5975
5943
  ...ctx.meta,
5976
- kind,
5944
+ kind: event.kind,
5945
+ origin: event.origin,
5977
5946
  error: String(err)
5978
5947
  }
5979
5948
  });
5980
5949
  }
5981
5950
  });
5982
5951
  }
5983
- async evaluateAndEnqueue(subject, kind) {
5984
- if (this.deviceMutes.isMuted(subject.deviceId)) {
5952
+ async evaluateAndEnqueue(event) {
5953
+ const { subject, kind, origin } = event;
5954
+ const controls = event.controls ?? {};
5955
+ const outcomes = [];
5956
+ const report = (extra = {}) => ({
5957
+ recordId: subject.recordId,
5958
+ kind,
5959
+ delivery: deliveryForKind(kind),
5960
+ origin,
5961
+ candidates: 0,
5962
+ outcomes,
5963
+ ...extra
5964
+ });
5965
+ if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
5985
5966
  this.reportMutedDrop(subject, kind, this.now());
5986
- return;
5967
+ return report({ droppedBy: "muted" });
5987
5968
  }
5988
- const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5989
- const candidates = this.rules.listEnabled(delivery);
5969
+ const delivery = deliveryForKind(kind);
5970
+ const enabled = this.rules.listEnabled(delivery);
5971
+ const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
5990
5972
  if (candidates.length === 0) {
5991
5973
  this.logger.debug("no enabled rule for this trigger", {
5992
5974
  tags: { deviceId: subject.deviceId },
5993
5975
  meta: {
5994
5976
  delivery,
5995
5977
  kind,
5978
+ origin,
5996
5979
  rulesLoaded: this.rules.list().length
5997
5980
  }
5998
5981
  });
5999
- return;
5982
+ return report({ droppedBy: "no-candidate-rule" });
6000
5983
  }
6001
5984
  const now = this.now();
6002
5985
  let anyMatched = false;
@@ -6005,6 +5988,15 @@ var NotificationCenter = class NotificationCenter {
6005
5988
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
6006
5989
  if (!evaluation.matched) {
6007
5990
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
5991
+ outcomes.push({
5992
+ ruleId: rule.id,
5993
+ ruleName: rule.name,
5994
+ matched: false,
5995
+ ...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
5996
+ cooldown: "n/a",
5997
+ enqueued: 0,
5998
+ targetIds: []
5999
+ });
6008
6000
  this.logger.debug("rule did not match", {
6009
6001
  tags: { deviceId: subject.deviceId },
6010
6002
  meta: {
@@ -6020,7 +6012,8 @@ var NotificationCenter = class NotificationCenter {
6020
6012
  continue;
6021
6013
  }
6022
6014
  const key = cooldownKey(rule, subject);
6023
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6015
+ const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
6016
+ if (cooling && controls.bypassCooldown !== true) {
6024
6017
  this.logger.debug("rule matched but is cooling down", {
6025
6018
  tags: { deviceId: subject.deviceId },
6026
6019
  meta: {
@@ -6031,6 +6024,15 @@ var NotificationCenter = class NotificationCenter {
6031
6024
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6032
6025
  }
6033
6026
  });
6027
+ outcomes.push({
6028
+ ruleId: rule.id,
6029
+ ruleName: rule.name,
6030
+ matched: true,
6031
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6032
+ cooldown: "suppressed",
6033
+ enqueued: 0,
6034
+ targetIds: []
6035
+ });
6034
6036
  continue;
6035
6037
  }
6036
6038
  this.logger.info("rule matched — enqueueing", {
@@ -6039,6 +6041,7 @@ var NotificationCenter = class NotificationCenter {
6039
6041
  ruleId: rule.id,
6040
6042
  rule: rule.name,
6041
6043
  kind,
6044
+ origin,
6042
6045
  targets: rule.targets.length,
6043
6046
  eventId: subject.recordId,
6044
6047
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
@@ -6047,7 +6050,7 @@ var NotificationCenter = class NotificationCenter {
6047
6050
  }
6048
6051
  });
6049
6052
  const sequences = rule.actions?.onTrigger;
6050
- if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
6053
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
6051
6054
  ruleId: rule.id,
6052
6055
  ruleName: rule.name,
6053
6056
  deviceId: subject.deviceId,
@@ -6063,10 +6066,22 @@ var NotificationCenter = class NotificationCenter {
6063
6066
  }
6064
6067
  });
6065
6068
  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);
6069
+ const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
6070
+ const inserted = await this.outbox.enqueue(entries);
6071
+ if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
6072
+ outcomes.push({
6073
+ ruleId: rule.id,
6074
+ ruleName: rule.name,
6075
+ matched: true,
6076
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6077
+ cooldown: cooling ? "bypassed" : "clear",
6078
+ enqueued: inserted,
6079
+ targetIds: entries.map((e) => e.targetId)
6080
+ });
6067
6081
  anyMatched = true;
6068
6082
  }
6069
6083
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
6084
+ return report({ candidates: candidates.length });
6070
6085
  }
6071
6086
  /**
6072
6087
  * One INFO line when a MUTED camera's event is dropped, bounded to one per
@@ -6118,7 +6133,7 @@ var NotificationCenter = class NotificationCenter {
6118
6133
  }
6119
6134
  });
6120
6135
  }
6121
- buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6136
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
6122
6137
  const hasEventMedia = kind === "object-event" || kind === "package-event";
6123
6138
  const isTrackScoped = kind === "object-event" || kind === "track-end";
6124
6139
  const direct = new Set(rule.targets.map((t) => t.targetId));
@@ -6126,6 +6141,7 @@ var NotificationCenter = class NotificationCenter {
6126
6141
  const payload = {
6127
6142
  ruleName: rule.name,
6128
6143
  delivery: rule.delivery,
6144
+ ...origin === "synthetic" ? { synthetic: true } : {},
6129
6145
  priority: rule.priority,
6130
6146
  ...rule.template !== void 0 ? { template: rule.template } : {},
6131
6147
  media: rule.media.attach,
@@ -6309,21 +6325,7 @@ var NotificationCenter = class NotificationCenter {
6309
6325
  const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
6310
6326
  for (const event of ordered) {
6311
6327
  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
- }));
6328
+ this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
6327
6329
  }
6328
6330
  await this.evalChain;
6329
6331
  if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
@@ -6491,7 +6493,7 @@ var NotificationCenter = class NotificationCenter {
6491
6493
  }
6492
6494
  } else if (rule.delivery === "track-end") {
6493
6495
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
6494
- for (const track of tracks) subjects.push(subjectFromTrack(track));
6496
+ for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
6495
6497
  }
6496
6498
  let lookup = (id) => this.deviceStates.get(id);
6497
6499
  const gated = rule.conditions.deviceState;
@@ -6594,6 +6596,24 @@ var ncActions = defineCustomActions({
6594
6596
  "nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
6595
6597
  kind: "mutation",
6596
6598
  caller: "required"
6599
+ }),
6600
+ /**
6601
+ * Inject a SYNTHETIC event through the whole notification path — match,
6602
+ * media, delivery — without producing a track, an event row or anything else
6603
+ * durable. See `test-event.ts` for what it does and does not do.
6604
+ *
6605
+ * A bridge action rather than a cap method, deliberately: it needs no
6606
+ * codegen, no `@camstack/system` republish and no place on a release train,
6607
+ * so the tester can ship and change at the speed of the addon it tests.
6608
+ *
6609
+ * `auth: 'admin'` because an injection DELIVERS: it reaches every target the
6610
+ * matched rule addresses, on other people's phones. `caller: 'required'` so
6611
+ * the log line names who pressed it.
6612
+ */
6613
+ "nc.injectTestEvent": customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
6614
+ kind: "mutation",
6615
+ auth: "admin",
6616
+ caller: "required"
6597
6617
  })
6598
6618
  });
6599
6619
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -6708,6 +6728,22 @@ function makeNcActionHandlers(deps) {
6708
6728
  await deps.snooze.cancel(input.snoozeId, c);
6709
6729
  return { success: true };
6710
6730
  },
6731
+ "nc.injectTestEvent": async (input, caller) => {
6732
+ const c = requireCaller(caller);
6733
+ if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
6734
+ const inject = deps.injectTestEvent;
6735
+ if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
6736
+ const parsed = NcTestEventInputSchema.parse(input);
6737
+ deps.logger.info("synthetic notification test requested", {
6738
+ tags: { deviceId: parsed.deviceId },
6739
+ meta: {
6740
+ by: c.userId,
6741
+ trigger: parsed.trigger,
6742
+ ...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
6743
+ }
6744
+ });
6745
+ return inject(parsed);
6746
+ },
6711
6747
  "nc.setRuleTargetEnabled": async (input, caller) => {
6712
6748
  const c = requireCaller(caller);
6713
6749
  assertRuleVisible(input.ruleId, c);
@@ -10985,6 +11021,214 @@ function createVideoclipsProvider(deps) {
10985
11021
  }
10986
11022
  };
10987
11023
  }
11024
+ /** Real time. See {@link EventMediaRequest.speed}. */
11025
+ var DEFAULT_SPEED = 1;
11026
+ /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
11027
+ * at or below 1080p is an identity scale and the broker copies it. */
11028
+ var RING_MP4_MAX_WIDTH = 1920;
11029
+ /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
11030
+ var RING_MP4_FPS = 15;
11031
+ var NO_MEDIA = {
11032
+ mp4: null,
11033
+ gif: null,
11034
+ source: "none",
11035
+ startOffsetMs: null,
11036
+ endOffsetMs: null,
11037
+ profile: null,
11038
+ video: null
11039
+ };
11040
+ var EventMediaService = class {
11041
+ deps;
11042
+ constructor(deps) {
11043
+ this.deps = deps;
11044
+ }
11045
+ /**
11046
+ * Cut this event. Never throws — a notification that lost its media is still
11047
+ * a notification, and every branch that drops it logs why.
11048
+ */
11049
+ async cut(request) {
11050
+ if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
11051
+ const produced = await this.produce(request);
11052
+ if (produced !== null) return produced;
11053
+ return this.fallbackToRing(request);
11054
+ }
11055
+ async produce(request) {
11056
+ const deviceId = request.deviceId;
11057
+ const log = this.deps.logger;
11058
+ const kinds = [];
11059
+ if (request.wantMp4) kinds.push("mp4");
11060
+ if (request.wantGif) kinds.push("gif");
11061
+ let production;
11062
+ try {
11063
+ production = await this.deps.produce({
11064
+ deviceId,
11065
+ aroundMs: request.aroundMs,
11066
+ preSeconds: request.preRollSec,
11067
+ postSeconds: request.postRollSec,
11068
+ kinds,
11069
+ gifMaxWidth: 640,
11070
+ gifFps: 8,
11071
+ speed: request.speed ?? DEFAULT_SPEED,
11072
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
11073
+ });
11074
+ } catch (err) {
11075
+ log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
11076
+ tags: { deviceId },
11077
+ meta: { error: err instanceof Error ? err.message : String(err) }
11078
+ });
11079
+ return null;
11080
+ }
11081
+ const mp4 = await this.redeem(deviceId, production.media, "mp4");
11082
+ const gif = await this.redeem(deviceId, production.media, "gif");
11083
+ if (mp4 === null && gif === null) {
11084
+ log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
11085
+ tags: { deviceId },
11086
+ meta: {
11087
+ kinds: kinds.join(","),
11088
+ profile: production.profile
11089
+ }
11090
+ });
11091
+ return null;
11092
+ }
11093
+ const startOffsetMs = production.coverage.fromTs - request.aroundMs;
11094
+ const endOffsetMs = production.coverage.toTs - request.aroundMs;
11095
+ log.info("nc media: one production, every attachment", {
11096
+ tags: { deviceId },
11097
+ meta: {
11098
+ profile: production.profile,
11099
+ video: production.video,
11100
+ mp4Bytes: mp4?.byteLength ?? null,
11101
+ gifBytes: gif?.byteLength ?? null,
11102
+ startOffsetMs,
11103
+ endOffsetMs,
11104
+ sharedWindow: true
11105
+ }
11106
+ });
11107
+ return {
11108
+ mp4,
11109
+ gif,
11110
+ source: "produced",
11111
+ startOffsetMs,
11112
+ endOffsetMs,
11113
+ profile: production.profile,
11114
+ video: production.video
11115
+ };
11116
+ }
11117
+ /**
11118
+ * Redeem one artifact handle at the node that produced it.
11119
+ *
11120
+ * `null` is not an error here — a production simply may not carry the kind
11121
+ * (a gif whose derive failed says so on the broker's own log line). A handle
11122
+ * that FAILS to redeem is different and is logged, because it means the bytes
11123
+ * existed and did not arrive.
11124
+ */
11125
+ async redeem(deviceId, media, kind) {
11126
+ const artifact = media.find((m) => m.kind === kind);
11127
+ if (artifact === void 0) return null;
11128
+ try {
11129
+ const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
11130
+ if (res === null) {
11131
+ this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
11132
+ tags: { deviceId },
11133
+ meta: {
11134
+ kind,
11135
+ handle: artifact.handle,
11136
+ nodeId: artifact.nodeId
11137
+ }
11138
+ });
11139
+ return null;
11140
+ }
11141
+ const buf = Buffer.from(res.base64, "base64");
11142
+ if (buf.byteLength === 0) return null;
11143
+ const bytes = new Uint8Array(buf.byteLength);
11144
+ bytes.set(buf);
11145
+ return bytes;
11146
+ } catch (err) {
11147
+ this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
11148
+ tags: { deviceId },
11149
+ meta: {
11150
+ kind,
11151
+ handle: artifact.handle,
11152
+ nodeId: artifact.nodeId,
11153
+ error: err instanceof Error ? err.message : String(err)
11154
+ }
11155
+ });
11156
+ return null;
11157
+ }
11158
+ }
11159
+ /**
11160
+ * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
11161
+ *
11162
+ * Weaker than a production and deliberately so — the coherence here rests on
11163
+ * the two calls being given IDENTICAL parameters rather than on there being
11164
+ * one render. That is defensible because the ring only ever grows forward and
11165
+ * `windowAround` selects by wall clock around the same instant, so two calls
11166
+ * seconds apart choose the same packets; the one thing that could differ is
11167
+ * the rendition, so the profile is pinned to whatever the FIRST call actually
11168
+ * used and handed to the second.
11169
+ *
11170
+ * It exists for one reason: a hub whose `@camstack/server` predates the
11171
+ * production method must not stop attaching footage, and three of the four
11172
+ * live rules on this install ask for a gif and nothing else.
11173
+ */
11174
+ async fallbackToRing(request) {
11175
+ const deviceId = request.deviceId;
11176
+ const speed = request.speed ?? DEFAULT_SPEED;
11177
+ const window = {
11178
+ deviceId,
11179
+ aroundMs: request.aroundMs,
11180
+ preRollSec: request.preRollSec,
11181
+ postRollSec: request.postRollSec,
11182
+ speed,
11183
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
11184
+ };
11185
+ try {
11186
+ const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
11187
+ ...window,
11188
+ format: "mp4",
11189
+ maxWidth: RING_MP4_MAX_WIDTH,
11190
+ fps: RING_MP4_FPS
11191
+ }) : null;
11192
+ const gif = request.wantGif ? await this.deps.renderRingClip({
11193
+ ...window,
11194
+ format: "gif",
11195
+ maxWidth: 640,
11196
+ fps: 8
11197
+ }) : null;
11198
+ if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
11199
+ this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
11200
+ tags: { deviceId },
11201
+ meta: { aroundMs: request.aroundMs }
11202
+ });
11203
+ return NO_MEDIA;
11204
+ }
11205
+ this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
11206
+ tags: { deviceId },
11207
+ meta: {
11208
+ mp4Bytes: mp4?.byteLength ?? null,
11209
+ gifBytes: gif?.byteLength ?? null,
11210
+ profile: request.profile ?? null,
11211
+ sharedWindow: "by-parameters"
11212
+ }
11213
+ });
11214
+ return {
11215
+ mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
11216
+ gif: gif !== null && gif.byteLength > 0 ? gif : null,
11217
+ source: "ring-fallback",
11218
+ startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
11219
+ endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
11220
+ profile: request.profile ?? null,
11221
+ video: null
11222
+ };
11223
+ } catch (err) {
11224
+ this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
11225
+ tags: { deviceId },
11226
+ meta: { error: err instanceof Error ? err.message : String(err) }
11227
+ });
11228
+ return NO_MEDIA;
11229
+ }
11230
+ }
11231
+ };
10988
11232
  //#endregion
10989
11233
  //#region src/shared/frame/crop-extractor.ts
10990
11234
  /**
@@ -25374,6 +25618,31 @@ var LinkedCamerasCache = class {
25374
25618
  }
25375
25619
  };
25376
25620
  /**
25621
+ * THE camera-set derivation for a sensor-cap state change — one function, so
25622
+ * every consumer of a device-event attributes it to the same cameras.
25623
+ *
25624
+ * It is shared on purpose. The self-camera half (a doorbell wrapper bound on
25625
+ * camera 615 makes 615 both source and subject) was added to the history-row
25626
+ * ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
25627
+ * which kept resolving `camerasFor` alone. The result: camera 615's real
25628
+ * doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
25629
+ * timeline marker, for weeks, with the divergence invisible in both codepaths.
25630
+ * Two derivations of "which cameras" is one too many.
25631
+ */
25632
+ async function resolveSensorEventCameras(cache, data) {
25633
+ const linkedCameraIds = await cache.camerasFor(data.deviceId);
25634
+ const isCamera = await cache.isCamera(data.deviceId);
25635
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
25636
+ if (cameraIds.length === 0) return {
25637
+ cameraIds,
25638
+ dropReason: isCamera ? "camera-native-cap" : "no-camera"
25639
+ };
25640
+ return {
25641
+ cameraIds,
25642
+ dropReason: null
25643
+ };
25644
+ }
25645
+ /**
25377
25646
  * One `DeviceStateChanged` → N history rows, one per camera the event belongs
25378
25647
  * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
25379
25648
  * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
@@ -25386,11 +25655,9 @@ var LinkedCamerasCache = class {
25386
25655
  async function ingestSensorStateChange(deps, data, timestamp) {
25387
25656
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
25388
25657
  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);
25658
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
25659
+ if (dropReason !== null) {
25660
+ deps.onDropped?.(dropReason, descriptor.kind);
25394
25661
  return 0;
25395
25662
  }
25396
25663
  const slice = data.slice;
@@ -26129,6 +26396,103 @@ var EventMediaDispatcher = class {
26129
26396
  }
26130
26397
  }
26131
26398
  };
26399
+ //#endregion
26400
+ //#region src/pipeline-analytics/services/sensor-marker-projector.ts
26401
+ /**
26402
+ * Sensor markers — the gate between "a mapped sensor/control cap changed
26403
+ * state" and "a synthetic track exists on the camera's timeline"
26404
+ * (event-kinds-taxonomy §8).
26405
+ *
26406
+ * It is the sibling of {@link ../services/audio-marker-projector.ts} and it
26407
+ * exists for the same reason: the projection has gates, and a gate that drops
26408
+ * work without saying so is indistinguishable from a feature that was never
26409
+ * wired.
26410
+ *
26411
+ * **What this fixes.** The projection used to resolve its cameras with
26412
+ * `camerasFor` alone — LINKED cameras only — while the history-row ingest
26413
+ * beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
26414
+ * is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
26415
+ * slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
26416
+ * projection returned on a `debug` line nobody runs at. Camera 615's real
26417
+ * press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
26418
+ * ring, an Alexa post and a matched notification rule — and no track. Both
26419
+ * halves now derive their cameras from {@link resolveSensorEventCameras}.
26420
+ *
26421
+ * **The asymmetry that used to be here was defended by a wrong premise.** The
26422
+ * old comment argued a self-projected track would feed the NC `track-end`
26423
+ * trigger and make every press evaluate the camera's object rules. It does
26424
+ * not: `persistSyntheticTrack` writes the row directly and the NC track-end
26425
+ * hook lives in the LIVE tracker's closer, which no synthetic track ever
26426
+ * enters. A linked contact sensor has been projecting onto its camera all
26427
+ * along under exactly the same mechanism.
26428
+ *
26429
+ * **Nothing here is silent.** A marker that lands emits one `info` line; every
26430
+ * branch that lands none emits one line carrying the reason. Both carry
26431
+ * `tags: { deviceId }` with the CAMERA id, because the question always arrives
26432
+ * as "why did 617 get a marker and 615 not".
26433
+ */
26434
+ /**
26435
+ * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
26436
+ * synthetic track per camera the event belongs to. Returns the number of
26437
+ * markers that landed. Best-effort (telemetry, D8) — a materializer that
26438
+ * throws propagates to the caller, which logs it.
26439
+ */
26440
+ async function projectSensorMarkers(deps, data, timestamp) {
26441
+ const descriptor = EVENT_KIND_BY_CAP[data.capName];
26442
+ if (descriptor === void 0) {
26443
+ deps.onSkipped({
26444
+ cameraId: null,
26445
+ sourceDeviceId: data.deviceId,
26446
+ capName: data.capName,
26447
+ kind: null,
26448
+ reason: "unmapped-cap"
26449
+ });
26450
+ return 0;
26451
+ }
26452
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
26453
+ if (dropReason !== null) {
26454
+ deps.onSkipped({
26455
+ cameraId: null,
26456
+ sourceDeviceId: data.deviceId,
26457
+ capName: data.capName,
26458
+ kind: descriptor.kind,
26459
+ reason: dropReason
26460
+ });
26461
+ return 0;
26462
+ }
26463
+ const producingDeviceName = await deps.producingDeviceName(data.deviceId);
26464
+ let landed = 0;
26465
+ for (const cameraId of cameraIds) {
26466
+ const track = await deps.markers.materialize({
26467
+ cameraId,
26468
+ sourceDeviceId: data.deviceId,
26469
+ source: "sensor",
26470
+ kind: descriptor.kind,
26471
+ timestamp,
26472
+ ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
26473
+ });
26474
+ if (track === null) {
26475
+ deps.onSkipped({
26476
+ cameraId,
26477
+ sourceDeviceId: data.deviceId,
26478
+ capName: data.capName,
26479
+ kind: descriptor.kind,
26480
+ reason: "not-materialized"
26481
+ });
26482
+ continue;
26483
+ }
26484
+ landed++;
26485
+ deps.onProjected({
26486
+ cameraId,
26487
+ sourceDeviceId: data.deviceId,
26488
+ capName: data.capName,
26489
+ kind: descriptor.kind,
26490
+ trackId: track.trackId,
26491
+ selfCamera: cameraId === data.deviceId
26492
+ });
26493
+ }
26494
+ return landed;
26495
+ }
26132
26496
  /** JPEG quality for the downscaled full frame — matches the crop path. */
26133
26497
  var FULL_FRAME_QUALITY = 80;
26134
26498
  /**
@@ -29100,15 +29464,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
29100
29464
  * Absent / empty / non-string all fall back to the hub default — the exact
29101
29465
  * narrowing the old raw read applied inline. */
29102
29466
  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
29467
  /** Per-install HMAC secret behind the signed artifact links (minted once). */
29113
29468
  var NcArtifactSecretSchema = string();
29114
29469
  var EmbeddingEnabledSchema = boolean();
@@ -29282,38 +29637,82 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29282
29637
  * data-plane handle. Null until served / when the facility is absent. */
29283
29638
  ncArtifactPlane = null;
29284
29639
  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
29640
  /**
29290
- * One attempt at the fMP4 seam. `null` means "the ring serves this one", and
29291
- * the REASON is always logged with `tags: { deviceId }`.
29641
+ * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
29292
29642
  *
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.
29643
+ * Built on first use — a hub whose rules never attach footage never
29644
+ * constructs it, and constructing it costs nothing until `cut`.
29296
29645
  */
29297
- async renderSeamClip(req) {
29298
- const log = this.ctx.logger;
29646
+ ncEventMedia = null;
29647
+ /**
29648
+ * The dispatcher's single media dep.
29649
+ *
29650
+ * ONE call for the gif and the mp4, deliberately: rendering them separately
29651
+ * is what produced a gif around the event and a video seven seconds after it
29652
+ * (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
29653
+ * slot, asks the broker's own prebuffer ring for the window at that slot's
29654
+ * own width in real time — which is what makes the broker COPY rather than
29655
+ * re-encode — and derives the gif from those same bytes.
29656
+ */
29657
+ async renderEventMedia(req) {
29299
29658
  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 })
29659
+ this.ncEventMedia ??= new EventMediaService({
29660
+ logger: this.ctx.logger.child("nc-event-media"),
29661
+ produce: (input) => api.streamBroker.produceEventMedia.mutate({
29662
+ deviceId: input.deviceId,
29663
+ aroundMs: input.aroundMs,
29664
+ preSeconds: input.preSeconds,
29665
+ postSeconds: input.postSeconds,
29666
+ kinds: [...input.kinds],
29667
+ gifMaxWidth: input.gifMaxWidth,
29668
+ gifFps: input.gifFps,
29669
+ speed: input.speed,
29670
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29671
+ }),
29672
+ fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? nodePin(nodeId) : void 0),
29673
+ renderRingClip: async (input) => {
29674
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
29675
+ deviceId: input.deviceId,
29676
+ aroundMs: input.aroundMs,
29677
+ format: input.format,
29678
+ preRollSec: input.preRollSec,
29679
+ postRollSec: input.postRollSec,
29680
+ maxWidth: input.maxWidth,
29681
+ fps: input.fps,
29682
+ speed: input.speed,
29683
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29684
+ });
29685
+ const buf = Buffer.from(res.base64, "base64");
29686
+ if (buf.byteLength === 0) return null;
29687
+ const bytes = new Uint8Array(buf.byteLength);
29688
+ bytes.set(buf);
29689
+ return bytes;
29690
+ }
29303
29691
  });
29304
29692
  try {
29305
- const result = await this.ncSeamClips.renderClip({
29693
+ const media = await this.ncEventMedia.cut({
29306
29694
  deviceId: req.deviceId,
29307
29695
  aroundMs: req.aroundMs,
29308
- postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC
29696
+ wantMp4: req.wantMp4,
29697
+ wantGif: req.wantGif,
29698
+ preRollSec: req.preRollSec,
29699
+ postRollSec: req.postRollSec,
29700
+ ...req.profile !== void 0 ? { profile: req.profile } : {},
29701
+ ...req.speed !== void 0 ? { speed: req.speed } : {}
29309
29702
  });
29310
- return result.ok ? result.clip.bytes : null;
29703
+ return {
29704
+ mp4: media.mp4,
29705
+ gif: media.gif
29706
+ };
29311
29707
  } catch (err) {
29312
- log.warn("nc clip: the fMP4 seam threw — falling back to the clip ring", {
29708
+ this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
29313
29709
  tags: { deviceId: req.deviceId },
29314
29710
  meta: { error: err instanceof Error ? err.message : String(err) }
29315
29711
  });
29316
- return null;
29712
+ return {
29713
+ mp4: null,
29714
+ gif: null
29715
+ };
29317
29716
  }
29318
29717
  }
29319
29718
  /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
@@ -29818,7 +30217,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29818
30217
  },
29819
30218
  listCallerTargetIds: async (userId) => {
29820
30219
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
29821
- }
30220
+ },
30221
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
29822
30222
  });
29823
30223
  }
29824
30224
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -29952,7 +30352,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29952
30352
  let storage = this.ctx.kernel.storage;
29953
30353
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
29954
30354
  if (mediaRoot) {
29955
- const { FilesystemStorageProvider } = await import("../node-pIpcQ_aN.mjs");
30355
+ const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
29956
30356
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
29957
30357
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
29958
30358
  }
@@ -30410,28 +30810,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
30410
30810
  y: pt.y
30411
30811
  })));
30412
30812
  },
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
- },
30813
+ renderEventMedia: (req) => this.renderEventMedia(req),
30435
30814
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
30436
30815
  listTargets: async () => {
30437
30816
  return (await api.notificationOutput.listTargets.query({})).map((t) => {
@@ -33376,29 +33755,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
33376
33755
  });
33377
33756
  }
33378
33757
  const materializer = this.syntheticTracks;
33379
- const descriptor = EVENT_KIND_BY_CAP[data.capName];
33380
- if (materializer === null || descriptor === void 0) return;
33758
+ if (materializer === null) return;
33381
33759
  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 },
33760
+ await projectSensorMarkers({
33761
+ cache,
33762
+ markers: materializer,
33763
+ producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
33764
+ onProjected: (r) => this.ctx.logger.info("synthetic sensor-track projected", {
33765
+ tags: { deviceId: r.cameraId },
33386
33766
  meta: {
33387
- capName: data.capName,
33388
- kind: descriptor.kind
33767
+ capName: r.capName,
33768
+ kind: r.kind,
33769
+ trackId: r.trackId,
33770
+ sourceDeviceId: r.sourceDeviceId,
33771
+ selfCamera: r.selfCamera
33389
33772
  }
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
- });
33773
+ }),
33774
+ onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
33775
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
33776
+ meta: {
33777
+ capName: r.capName,
33778
+ kind: r.kind,
33779
+ reason: r.reason,
33780
+ sourceDeviceId: r.sourceDeviceId
33781
+ }
33782
+ })
33783
+ }, data, timestamp);
33402
33784
  } catch (err) {
33403
33785
  this.ctx.logger.warn("synthetic sensor-track materialize failed", {
33404
33786
  tags: { deviceId: data.deviceId },