@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.
@@ -2,12 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-CnSzKo7x.js");
5
+ const require_dist = require("../dist-k21GqaWS.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
9
9
  let node_crypto = require("node:crypto");
10
- let node_child_process = require("node:child_process");
11
10
  let sharp = require("sharp");
12
11
  sharp = require_dist.__toESM(sharp);
13
12
  //#region src/notification-center/action-token.ts
@@ -1109,557 +1108,6 @@ function mimeFromExtension(file) {
1109
1108
  return "application/octet-stream";
1110
1109
  }
1111
1110
  //#endregion
1112
- //#region src/notification-center/fmp4-clip/clip-assembler.ts
1113
- /**
1114
- * Drain a subscription into one mp4.
1115
- *
1116
- * The subscription is ALWAYS released before this settles, on every path. It
1117
- * holds an ffmpeg's fragments in a bounded queue; leaking one keeps a camera's
1118
- * child alive for a notification that was sent minutes ago.
1119
- */
1120
- async function assembleFmp4Clip(input) {
1121
- const now = input.now ?? Date.now;
1122
- const schedule = input.setTimeoutFn ?? setTimeout;
1123
- const parts = [];
1124
- let initBytes = 0;
1125
- let fragments = 0;
1126
- let totalBytes = 0;
1127
- let firstFragmentAt = null;
1128
- let lastFragmentAt = null;
1129
- let stop = null;
1130
- const iterator = input.subscription.packets()[Symbol.asyncIterator]();
1131
- try {
1132
- for (;;) {
1133
- const remaining = input.deadlineMs - now();
1134
- if (remaining <= 0) {
1135
- stop = "deadline";
1136
- break;
1137
- }
1138
- const packet = await nextWithin(iterator, remaining, schedule);
1139
- if (packet === TIMED_OUT) {
1140
- stop = "deadline";
1141
- break;
1142
- }
1143
- if (packet === null) {
1144
- stop = input.subscription.closedReason === "slow-consumer" ? "slow-consumer" : "producer-ended";
1145
- break;
1146
- }
1147
- if (packet.kind === "init") {
1148
- if (initBytes > 0) {
1149
- stop = "producer-ended";
1150
- break;
1151
- }
1152
- initBytes = packet.data.length;
1153
- totalBytes += packet.data.length;
1154
- parts.push(packet.data);
1155
- continue;
1156
- }
1157
- if (initBytes === 0) return {
1158
- ok: false,
1159
- refusal: "no-init"
1160
- };
1161
- const arrivedAt = now();
1162
- if (totalBytes + packet.data.length > input.maxBytes) {
1163
- stop = "byte-cap";
1164
- break;
1165
- }
1166
- parts.push(packet.data);
1167
- totalBytes += packet.data.length;
1168
- fragments += 1;
1169
- if (firstFragmentAt === null) firstFragmentAt = arrivedAt;
1170
- lastFragmentAt = arrivedAt;
1171
- if (packet.isLast) {
1172
- stop = "producer-ended";
1173
- break;
1174
- }
1175
- if (arrivedAt >= input.collectUntilMs) {
1176
- stop = "window";
1177
- break;
1178
- }
1179
- }
1180
- } finally {
1181
- input.subscription.release();
1182
- }
1183
- if (initBytes === 0) return {
1184
- ok: false,
1185
- refusal: "no-init"
1186
- };
1187
- if (fragments === 0) return {
1188
- ok: false,
1189
- refusal: "no-fragments"
1190
- };
1191
- const stoppedBecause = stop ?? "producer-ended";
1192
- return {
1193
- ok: true,
1194
- clip: {
1195
- bytes: concat(parts, totalBytes),
1196
- initBytes,
1197
- fragments,
1198
- arrivalSpanMs: firstFragmentAt === null || lastFragmentAt === null ? 0 : lastFragmentAt - firstFragmentAt,
1199
- stoppedBecause,
1200
- truncated: stoppedBecause !== "window"
1201
- }
1202
- };
1203
- }
1204
- /** One line for the log — a refused clip that says nothing reads as a bug. */
1205
- function clipAssemblyRefusalReason(refusal) {
1206
- switch (refusal) {
1207
- case "no-init": return "the first packet was a fragment, not an initialisation segment — the bytes would not decode";
1208
- case "no-fragments": return "the initialisation segment arrived but no media fragment did";
1209
- }
1210
- }
1211
- var TIMED_OUT = Symbol("fmp4-clip-timeout");
1212
- /** `setTimeout`'s 32-bit ceiling. Above it Node fires after 1 ms. */
1213
- var MAX_TIMER_MS = 2 ** 31 - 1;
1214
- /**
1215
- * The next packet, `null` when the stream completed, or {@link TIMED_OUT}.
1216
- *
1217
- * The plane's generator parks on a promise that only a publish resolves, so a
1218
- * camera that goes silent mid-clip parks it forever. Racing a timer is the only
1219
- * way out — and the abandoned `next()` is harmless because the caller releases
1220
- * the subscription immediately afterwards.
1221
- */
1222
- async function nextWithin(iterator, withinMs, schedule) {
1223
- let timer = null;
1224
- const timeout = new Promise((resolve) => {
1225
- timer = schedule(() => resolve(TIMED_OUT), Math.min(withinMs, MAX_TIMER_MS));
1226
- timer.unref?.();
1227
- });
1228
- try {
1229
- return await Promise.race([iterator.next().then((r) => r.done === true ? null : r.value), timeout]);
1230
- } finally {
1231
- if (timer !== null) clearTimeout(timer);
1232
- }
1233
- }
1234
- function concat(parts, total) {
1235
- const out = new Uint8Array(total);
1236
- let offset = 0;
1237
- for (const part of parts) {
1238
- out.set(part, offset);
1239
- offset += part.length;
1240
- }
1241
- return out;
1242
- }
1243
- //#endregion
1244
- //#region src/notification-center/fmp4-clip/clip-source-pick.ts
1245
- /**
1246
- * The tallest frame a notification clip is cut from.
1247
- *
1248
- * 1080 rather than 720: the attachment is native-resolution H.264 that nothing
1249
- * re-encodes, and the operator asked for a real clip. A 4-second 1080p copy
1250
- * fragment measured ~500 KB on this fleet against ~6.35 MB at 4K, which is the
1251
- * difference between "every backend takes it" and "pushover drops it".
1252
- */
1253
- var CLIP_MAX_HEIGHT = 1080;
1254
- /** The seam copies; it never transcodes. That means H.264 and only H.264. */
1255
- var COPYABLE_CODEC = "h264";
1256
- function pickClipSource(entries) {
1257
- const enabled = entries.filter((e) => e.enabled);
1258
- if (enabled.length === 0) return {
1259
- ok: false,
1260
- refusal: "no-enabled-stream"
1261
- };
1262
- const copyable = enabled.filter((e) => normaliseCodec(e.codec) === COPYABLE_CODEC);
1263
- if (copyable.length === 0) return {
1264
- ok: false,
1265
- refusal: "no-h264-stream"
1266
- };
1267
- const sized = copyable.filter(hasUsableResolution);
1268
- if (sized.length === 0) return {
1269
- ok: false,
1270
- refusal: "no-resolution"
1271
- };
1272
- const withinCeiling = sized.filter((e) => e.resolution.height <= CLIP_MAX_HEIGHT);
1273
- 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];
1274
- if (best === void 0) return {
1275
- ok: false,
1276
- refusal: "no-resolution"
1277
- };
1278
- return {
1279
- ok: true,
1280
- source: {
1281
- brokerId: best.brokerId,
1282
- profile: best.profile ?? best.brokerId,
1283
- url: best.url,
1284
- width: best.resolution.width,
1285
- height: best.resolution.height
1286
- }
1287
- };
1288
- }
1289
- /** One line for the log — a withheld clip that says nothing reads as a bug. */
1290
- function clipRefusalReason(refusal) {
1291
- switch (refusal) {
1292
- case "no-enabled-stream": return "the camera has no enabled profile slot to dial";
1293
- case "no-h264-stream": return "every enabled profile is H.265 — the seam copies and never transcodes, so the clip ring serves this camera";
1294
- case "no-resolution": return "no enabled profile declares a resolution, so no slot could be sized";
1295
- }
1296
- }
1297
- function hasUsableResolution(entry) {
1298
- const r = entry.resolution;
1299
- return r !== void 0 && r.width > 0 && r.height > 0;
1300
- }
1301
- /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
1302
- function normaliseCodec(codec) {
1303
- return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
1304
- }
1305
- //#endregion
1306
- //#region src/notification-center/fmp4-clip/fmp4-clip-service.ts
1307
- /**
1308
- * Cut a notification clip from the LIVE fMP4 fragment seam — roadmap 4b, the
1309
- * second consumer [D80](../../../../../../docs/decisions/adr-0080.md) named and
1310
- * deliberately did not build.
1311
- *
1312
- * ## What changes for the operator
1313
- *
1314
- * The clip ring path (`streamBroker.renderPreBufferClip`, D32) re-encodes to
1315
- * 480 px wide, 5 fps, 2× speed, `-an`. That is a thumbnail with motion. This
1316
- * one is `-c:v copy`: the camera's own H.264 at its own resolution and frame
1317
- * rate, real time, with in-band AAC — a clip you can actually watch.
1318
- *
1319
- * ## Where the consumer lives, and why it is HERE
1320
- *
1321
- * [D84](../../../../../../docs/decisions/adr-0084.md) established the answer
1322
- * the hard way: a `Fmp4FragmentPlane` is a live object, so it cannot cross a
1323
- * process boundary and no capability can carry one. The first consumer — the
1324
- * HKSV delegate — is `hub-only` in its own runner and therefore spawns its OWN
1325
- * child against the broker's restream, taking the transport from
1326
- * `@camstack/types/node`. The notification centre is in yet another runner
1327
- * (`addon-post-analysis`), so it does exactly the same thing. The alternative,
1328
- * a `streamBroker.renderFragmentClip` cap method, is a `*.cap.ts` change plus
1329
- * codegen plus a `@camstack/system` train to move bytes that are already
1330
- * reachable over RTSP.
1331
- *
1332
- * ## ON-DEMAND, and what that costs — read this before "fixing" the pre-roll
1333
- *
1334
- * HKSV's prebuffer exists because its ffmpeg never stops. That is affordable
1335
- * (measured: 0.7 % of a core, 30 MB RSS on the copy branch) but it is still a
1336
- * PERMANENT child per camera, and a notification clip does not justify one on
1337
- * every camera that has a rule. So this child is spawned at the event and
1338
- * killed when the clip is assembled.
1339
- *
1340
- * **This path has NO pre-roll, and the guess that it might have had one was
1341
- * wrong.** `RtspRestreamer` bursts the current GOP to a joining session, so it
1342
- * looked as though the first fragment would open before we asked for it.
1343
- * Measured against 615's 720p slot from outside the hub — the exact argv, the
1344
- * real child, the real plane:
1345
- *
1346
- * | dial → init | fragments | media duration | wall clock | bytes |
1347
- * | --- | --- | --- | --- | --- |
1348
- * | 4479 ms | 3 × ~141 KB | 12.03 s | 12.42 s | 425 KB |
1349
- *
1350
- * Media duration and wall clock differ by 387 ms — the dial. Nothing from
1351
- * before the spawn survives into the file: ffmpeg discards up to the first key
1352
- * frame it can open on, which is also why the first fragment costs one full
1353
- * key-frame interval. So `preRollSec` is NOT honoured here; the fallback's is.
1354
- *
1355
- * A real pre-roll needs a plane that was already running. The seam is ready for
1356
- * one — the assembler takes any `Fmp4Subscription`, including a prebuffered one
1357
- * — and it is queued rather than built, because a warm child per
1358
- * camera-with-a-clip-rule is a new permanent cost and that is an operator
1359
- * decision, not an implementation one.
1360
- *
1361
- * ## Fail-closed, and always with the reason
1362
- *
1363
- * Every refusal returns a code AND a sentence, and the caller falls back to the
1364
- * clip ring. An H.265 camera, a broker on another node, an ffmpeg that will not
1365
- * start: each is a `debug`/`warn` line carrying `tags: { deviceId }`, because a
1366
- * clip that silently changed shape per camera is exactly the thing nobody can
1367
- * diagnose from an attachment.
1368
- */
1369
- /**
1370
- * The fragment length asked of the muxer.
1371
- *
1372
- * Deliberately SHORT. `-min_frag_duration` can only ever DELAY a cut past a key
1373
- * frame (D80), so asking for 1 s against a 4 s key-frame grid yields exactly the
1374
- * source cadence — the finest granularity the stream can give. Asking for the
1375
- * grid's own length is the D84 bug: the deadline lands on the same instant as
1376
- * the key frame, loses, and DOUBLES every fragment.
1377
- */
1378
- var CLIP_FRAGMENT_MS = 1e3;
1379
- /**
1380
- * How long the child may produce nothing before it is declared dead.
1381
- *
1382
- * Shorter than the transport's 12 s default on purpose: this runs inside the
1383
- * notification drain, and the commonest cause of a stall here is a restream url
1384
- * that is not reachable from THIS runner (the broker for that camera lives on
1385
- * another node and its url is loopback). Paying 12 s per notification for that
1386
- * is worse than falling back sooner.
1387
- *
1388
- * NOT shorter than 9 s, and that bound is measured rather than chosen: the init
1389
- * segment cannot be written before ffmpeg has a key frame to open on, and 615
1390
- * took **4479 ms** over the LAN on a 4.0 s grid. A camera on an 8 s grid needs
1391
- * roughly twice that, so anything under ~9 s would declare healthy cameras dead
1392
- * and cool them down for ten minutes.
1393
- */
1394
- var CLIP_FIRST_UNIT_TIMEOUT_MS = 9e3;
1395
- /** Slack past the requested window for one more key-frame interval to land. */
1396
- var CLIP_CADENCE_SLACK_MS = 1e4;
1397
- /** The whole attempt, spawn included. A notification is not worth more. */
1398
- var CLIP_TOTAL_BUDGET_MS = 25e3;
1399
- /**
1400
- * Ceiling on the assembled file.
1401
- *
1402
- * The degrade engine DROPS an over-`maxBytes` attachment rather than degrading
1403
- * it to a link, and the SMALLEST cap declared today is pushover's 5 MiB. So the
1404
- * cap is that, not something roomier: a clip truncated to four seconds and
1405
- * delivered beats a whole one the engine throws away, and the truncation is
1406
- * marked either way. A 1080p copy clip of a few seconds is 1-2 MB, so this only
1407
- * ever bites a 4K-only camera.
1408
- */
1409
- var CLIP_MAX_BYTES = 5 * 1024 * 1024;
1410
- /**
1411
- * How long a camera is left alone after the seam failed to start on it.
1412
- *
1413
- * Without this, a camera whose broker is on another node pays
1414
- * {@link CLIP_FIRST_UNIT_TIMEOUT_MS} on EVERY notification, forever. The
1415
- * cooldown is announced once and the suppressed attempts are counted into the
1416
- * next line, the same shape the NC's own rate-limited lines use.
1417
- */
1418
- var CLIP_FAILURE_COOLDOWN_MS = 10 * 6e4;
1419
- /** ffmpeg on `PATH`, as every other spawn in this repo resolves it. */
1420
- var FFMPEG_BINARY = "ffmpeg";
1421
- var Fmp4ClipService = class {
1422
- deps;
1423
- cooldowns = /* @__PURE__ */ new Map();
1424
- inFlight = /* @__PURE__ */ new Set();
1425
- transport = null;
1426
- transportProbed = false;
1427
- constructor(deps) {
1428
- this.deps = deps;
1429
- }
1430
- /**
1431
- * Cut one clip. Never throws — every failure is a `{ ok: false }` with a
1432
- * code and a sentence, because the caller's only correct response is to fall
1433
- * back to the clip ring.
1434
- *
1435
- * The DECLINE IS LOGGED HERE and not at the call site, so there is exactly one
1436
- * place that can forget to. Two cameras silently attaching two different kinds
1437
- * of clip is precisely what an operator cannot diagnose from a phone, and the
1438
- * question is always per-camera — hence `tags: { deviceId }` on every line.
1439
- */
1440
- async renderClip(request) {
1441
- const result = await this.attempt(request);
1442
- if (!result.ok) this.deps.logger.debug("nc clip: the fMP4 seam declined — the clip ring serves this one", {
1443
- tags: { deviceId: request.deviceId },
1444
- meta: {
1445
- code: result.code,
1446
- reason: result.reason
1447
- }
1448
- });
1449
- return result;
1450
- }
1451
- async attempt(request) {
1452
- const now = this.deps.now ?? Date.now;
1453
- const log = this.deps.logger;
1454
- const deviceId = request.deviceId;
1455
- const cooling = this.cooldowns.get(deviceId);
1456
- if (cooling !== void 0 && cooling.until > now()) {
1457
- cooling.suppressed += 1;
1458
- return {
1459
- ok: false,
1460
- code: "cooldown",
1461
- reason: `the seam failed to start on this camera and is suppressed for another ${Math.round((cooling.until - now()) / 1e3)}s`
1462
- };
1463
- }
1464
- if (this.inFlight.has(deviceId)) return {
1465
- ok: false,
1466
- code: "busy",
1467
- reason: "a seam clip for this camera is already being cut"
1468
- };
1469
- const transport = await this.loadTransport();
1470
- if (transport === null) return {
1471
- ok: false,
1472
- code: "transport-missing",
1473
- reason: "this host has no fMP4 fragment transport — @camstack/types predates it"
1474
- };
1475
- const entries = await this.readEntries(deviceId);
1476
- if (entries === null || entries.length === 0) return {
1477
- ok: false,
1478
- code: "no-entries",
1479
- reason: "the camera reported no profile RTSP entries"
1480
- };
1481
- const choice = pickClipSource(entries);
1482
- if (!choice.ok) return {
1483
- ok: false,
1484
- code: choice.refusal,
1485
- reason: clipRefusalReason(choice.refusal)
1486
- };
1487
- const source = choice.source;
1488
- this.inFlight.add(deviceId);
1489
- const plane = new transport.Fmp4FragmentPlane(log.child("nc-clip-plane"));
1490
- const child = new transport.Fmp4FragmentChild({
1491
- logger: log.child("nc-clip-fmp4"),
1492
- ffmpegBinaryPath: this.deps.ffmpegBinaryPath ?? FFMPEG_BINARY,
1493
- spawnFn: this.deps.spawnFn ?? node_child_process.spawn,
1494
- firstUnitTimeoutMs: CLIP_FIRST_UNIT_TIMEOUT_MS
1495
- }, {
1496
- sourceId: `nc-clip/${deviceId}`,
1497
- deviceId,
1498
- fragmentMs: CLIP_FRAGMENT_MS,
1499
- invocation: buildClipInvocation(source.url),
1500
- plane
1501
- });
1502
- const startedAt = now();
1503
- try {
1504
- await child.start();
1505
- } catch (err) {
1506
- this.noteStartFailure(deviceId, err);
1507
- await child.stop().catch(() => void 0);
1508
- plane.dispose();
1509
- this.inFlight.delete(deviceId);
1510
- return {
1511
- ok: false,
1512
- code: "child-failed",
1513
- reason: `ffmpeg produced no fMP4 initialisation segment: ${errText(err)}`
1514
- };
1515
- }
1516
- this.clearCooldown(deviceId);
1517
- const subscribedAt = now();
1518
- const postRollMs = Math.max(0, request.postRollSec) * 1e3;
1519
- const collectUntilMs = Math.max(request.aroundMs, subscribedAt) + postRollMs;
1520
- const budgetEnd = startedAt + CLIP_TOTAL_BUDGET_MS;
1521
- const deadlineMs = Math.min(collectUntilMs + CLIP_CADENCE_SLACK_MS, budgetEnd);
1522
- try {
1523
- const outcome = await assembleFmp4Clip({
1524
- subscription: plane.subscribe({ tag: `nc-clip/${deviceId}` }),
1525
- collectUntilMs,
1526
- deadlineMs,
1527
- maxBytes: CLIP_MAX_BYTES,
1528
- ...this.deps.now !== void 0 ? { now: this.deps.now } : {},
1529
- ...this.deps.setTimeoutFn !== void 0 ? { setTimeoutFn: this.deps.setTimeoutFn } : {}
1530
- });
1531
- if (!outcome.ok) {
1532
- log.warn("nc clip: the fragment seam produced nothing usable — falling back to the clip ring", {
1533
- tags: { deviceId },
1534
- meta: {
1535
- refusal: outcome.refusal,
1536
- reason: clipAssemblyRefusalReason(outcome.refusal),
1537
- brokerId: source.brokerId
1538
- }
1539
- });
1540
- return {
1541
- ok: false,
1542
- code: outcome.refusal,
1543
- reason: clipAssemblyRefusalReason(outcome.refusal)
1544
- };
1545
- }
1546
- const clip = outcome.clip;
1547
- log.info("nc clip: cut from the fMP4 seam", {
1548
- tags: { deviceId },
1549
- meta: {
1550
- brokerId: source.brokerId,
1551
- profile: source.profile,
1552
- resolution: `${source.width}x${source.height}`,
1553
- fragments: clip.fragments,
1554
- bytes: clip.bytes.length,
1555
- arrivalSpanMs: clip.arrivalSpanMs,
1556
- truncated: clip.truncated,
1557
- stoppedBecause: clip.stoppedBecause,
1558
- dialLatencyMs: subscribedAt - startedAt,
1559
- eventToDialMs: subscribedAt - request.aroundMs
1560
- }
1561
- });
1562
- return {
1563
- ok: true,
1564
- clip,
1565
- source
1566
- };
1567
- } finally {
1568
- await child.stop().catch(() => void 0);
1569
- plane.dispose();
1570
- this.inFlight.delete(deviceId);
1571
- }
1572
- }
1573
- async loadTransport() {
1574
- if (this.transportProbed) return this.transport;
1575
- this.transportProbed = true;
1576
- const load = this.deps.loadTransport ?? defaultLoadTransport;
1577
- try {
1578
- this.transport = await load();
1579
- } catch (err) {
1580
- this.transport = null;
1581
- this.deps.logger.warn("nc clip: this host carries no fMP4 fragment transport — every clip falls back to the ring", { meta: { error: errText(err) } });
1582
- }
1583
- return this.transport;
1584
- }
1585
- async readEntries(deviceId) {
1586
- try {
1587
- return await this.deps.listProfileEntries(deviceId);
1588
- } catch (err) {
1589
- this.deps.logger.debug("nc clip: could not read the camera profile entries", {
1590
- tags: { deviceId },
1591
- meta: { error: errText(err) }
1592
- });
1593
- return null;
1594
- }
1595
- }
1596
- noteStartFailure(deviceId, err) {
1597
- const now = this.deps.now ?? Date.now;
1598
- this.cooldowns.set(deviceId, {
1599
- until: now() + CLIP_FAILURE_COOLDOWN_MS,
1600
- suppressed: 0
1601
- });
1602
- this.deps.logger.warn("nc clip: the fMP4 child would not start — this camera falls back to the clip ring for now", {
1603
- tags: { deviceId },
1604
- meta: {
1605
- error: errText(err),
1606
- cooldownMs: CLIP_FAILURE_COOLDOWN_MS
1607
- }
1608
- });
1609
- }
1610
- clearCooldown(deviceId) {
1611
- const previous = this.cooldowns.get(deviceId);
1612
- if (previous === void 0) return;
1613
- this.cooldowns.delete(deviceId);
1614
- this.deps.logger.info("nc clip: the fMP4 seam recovered on this camera", {
1615
- tags: { deviceId },
1616
- meta: { suppressedAttempts: previous.suppressed }
1617
- });
1618
- }
1619
- };
1620
- /**
1621
- * The invocation, minus the sink the child owns.
1622
- *
1623
- * `copy` is the entire point (see the class docblock) and the audio IS encoded,
1624
- * because camera microphones are G.711/PCM and an mp4 attachment has to carry
1625
- * AAC to play anywhere. `-c:a aac` with no audio stream in the source is a
1626
- * no-op in ffmpeg's default stream selection, so a silent camera needs no
1627
- * second branch.
1628
- */
1629
- function buildClipInvocation(url) {
1630
- return {
1631
- logLevel: "error",
1632
- decodeHwAccel: null,
1633
- input: {
1634
- url,
1635
- rtspTransport: "tcp",
1636
- analyzeDurationUs: 1e6,
1637
- probeSizeBytes: 1e6
1638
- },
1639
- video: { kind: "copy" },
1640
- audio: {
1641
- kind: "encode",
1642
- codec: "aac",
1643
- bitrateKbps: 64,
1644
- sampleRateHz: 24e3,
1645
- channels: 1
1646
- },
1647
- threadCount: 0,
1648
- outputArgs: []
1649
- };
1650
- }
1651
- async function defaultLoadTransport() {
1652
- const mod = await Promise.resolve().then(() => require("../node-CnEKzHgS.js"));
1653
- if (typeof mod.Fmp4FragmentChild !== "function" || typeof mod.Fmp4FragmentPlane !== "function") return null;
1654
- return {
1655
- Fmp4FragmentChild: mod.Fmp4FragmentChild,
1656
- Fmp4FragmentPlane: mod.Fmp4FragmentPlane
1657
- };
1658
- }
1659
- function errText(err) {
1660
- return err instanceof Error ? err.message : String(err);
1661
- }
1662
- //#endregion
1663
1111
  //#region src/pipeline-analytics/pipeline/label-utils.ts
1664
1112
  /**
1665
1113
  * Every macro class the pipeline can carry on a detection. A `label` exists to
@@ -2562,20 +2010,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
2562
2010
  //#endregion
2563
2011
  //#region src/notification-center/dispatcher.ts
2564
2012
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
2565
- /** GIF and MP4 are the same cut in two containers — one render request each. */
2566
- var FOOTAGE_FORMATS = [{
2567
- flag: "mediaGif",
2568
- format: "gif",
2569
- mediaType: "gif",
2570
- mime: "image/gif",
2571
- name: "event.gif"
2572
- }, {
2573
- flag: "mediaClip",
2574
- format: "mp4",
2575
- mediaType: "video",
2576
- mime: "video/mp4",
2577
- name: "event.mp4"
2578
- }];
2579
2013
  var NcDispatcher = class {
2580
2014
  deps;
2581
2015
  targetCache = null;
@@ -2959,31 +2393,33 @@ var NcDispatcher = class {
2959
2393
  } : still);
2960
2394
  } else out.push(still);
2961
2395
  }
2962
- for (const want of FOOTAGE_FORMATS) {
2963
- if (entry.payload[want.flag] !== true || !this.deps.renderFootage) continue;
2964
- try {
2965
- const rendered = await this.deps.renderFootage({
2966
- deviceId: entry.payload.subject.deviceId,
2967
- aroundMs: entry.payload.subject.timestamp,
2968
- format: want.format,
2969
- ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
2970
- ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
2971
- ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
2972
- ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
2973
- });
2974
- if (rendered !== null && rendered.byteLength > 0) {
2975
- const bytes = new Uint8Array(rendered.byteLength);
2976
- bytes.set(rendered);
2977
- out.push({
2978
- mediaType: want.mediaType,
2979
- bytes,
2980
- mime: want.mime,
2981
- name: want.name
2982
- });
2983
- }
2984
- } catch (err) {
2985
- this.notePendingFootage(entry, err, want.format === "gif" ? "gif" : "clip");
2986
- }
2396
+ const wantGif = entry.payload.mediaGif === true;
2397
+ const wantMp4 = entry.payload.mediaClip === true;
2398
+ if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
2399
+ const media = await this.deps.renderEventMedia({
2400
+ deviceId: entry.payload.subject.deviceId,
2401
+ aroundMs: entry.payload.subject.timestamp,
2402
+ wantGif,
2403
+ wantMp4,
2404
+ preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
2405
+ postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
2406
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
2407
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
2408
+ });
2409
+ if (media.gif !== null && media.gif.byteLength > 0) out.push({
2410
+ mediaType: "gif",
2411
+ bytes: copyBytes(media.gif),
2412
+ mime: "image/gif",
2413
+ name: "event.gif"
2414
+ });
2415
+ if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
2416
+ mediaType: "video",
2417
+ bytes: copyBytes(media.mp4),
2418
+ mime: "video/mp4",
2419
+ name: "event.mp4"
2420
+ });
2421
+ } catch (err) {
2422
+ this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
2987
2423
  }
2988
2424
  return out;
2989
2425
  }
@@ -2994,8 +2430,8 @@ var NcDispatcher = class {
2994
2430
  try {
2995
2431
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
2996
2432
  if (points.length === 0) return null;
2997
- const { default: sharp$7 } = await import("sharp");
2998
- const img = sharp$7(Buffer.from(jpeg));
2433
+ const { default: sharp$8 } = await import("sharp");
2434
+ const img = sharp$8(Buffer.from(jpeg));
2999
2435
  const meta = await img.metadata();
3000
2436
  const W = meta.width ?? 0;
3001
2437
  const H = meta.height ?? 0;
@@ -3152,6 +2588,18 @@ function paramNumber(params, key) {
3152
2588
  function clampPriority(priority) {
3153
2589
  return Math.max(1, Math.min(5, Math.round(priority)));
3154
2590
  }
2591
+ /**
2592
+ * A copy backed by its own `ArrayBuffer`.
2593
+ *
2594
+ * The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
2595
+ * by an assembler may sit on a shared buffer. Copying is cheap next to the
2596
+ * encode that produced it and removes a whole class of aliasing question.
2597
+ */
2598
+ function copyBytes(bytes) {
2599
+ const out = new Uint8Array(bytes.byteLength);
2600
+ out.set(bytes);
2601
+ return out;
2602
+ }
3155
2603
  //#endregion
3156
2604
  //#region src/notification-center/occupancy-watcher.ts
3157
2605
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
@@ -4008,6 +3456,414 @@ function rowToEntry$1(id, data) {
4008
3456
  };
4009
3457
  }
4010
3458
  //#endregion
3459
+ //#region src/notification-center/event-intake.ts
3460
+ /** Object-event insert (movement-state transitions + zone crossings). */
3461
+ function incomingFromObjectEvent(event) {
3462
+ return {
3463
+ subject: subjectFromObjectEvent(event),
3464
+ kind: "object-event",
3465
+ origin: "pipeline",
3466
+ log: () => ({
3467
+ tags: { deviceId: event.deviceId },
3468
+ meta: { eventId: event.id }
3469
+ })
3470
+ };
3471
+ }
3472
+ /** `TrackCloser.closeExpired` — the ONE close point. */
3473
+ function incomingFromTrack(track, info) {
3474
+ return {
3475
+ subject: subjectFromTrack(track, info),
3476
+ kind: "track-end",
3477
+ origin: "pipeline",
3478
+ log: () => ({
3479
+ tags: { deviceId: track.deviceId },
3480
+ meta: { trackId: track.trackId }
3481
+ })
3482
+ };
3483
+ }
3484
+ /** Sensor-event persist (one row per linked camera). */
3485
+ function incomingFromSensorEvent(event) {
3486
+ return {
3487
+ subject: subjectFromSensorEvent(event),
3488
+ kind: "device-event",
3489
+ origin: "sensor",
3490
+ log: () => ({
3491
+ tags: { deviceId: event.deviceId },
3492
+ meta: {
3493
+ sensorEventId: event.id,
3494
+ kind: event.kind
3495
+ }
3496
+ })
3497
+ };
3498
+ }
3499
+ /** Audio-event persist (classified episode). */
3500
+ function incomingFromAudioEvent(event) {
3501
+ return {
3502
+ subject: subjectFromAudioEvent(event),
3503
+ kind: "audio-event",
3504
+ origin: "pipeline",
3505
+ log: () => ({
3506
+ tags: { deviceId: event.deviceId },
3507
+ meta: {
3508
+ audioEventId: event.id,
3509
+ class: event.classification?.className
3510
+ }
3511
+ })
3512
+ };
3513
+ }
3514
+ /** Package object-event persist (delivered / picked-up). */
3515
+ function incomingFromPackageEvent(event, phase) {
3516
+ return {
3517
+ subject: subjectFromPackageEvent(event, phase),
3518
+ kind: "package-event",
3519
+ origin: "pipeline",
3520
+ log: () => ({
3521
+ tags: { deviceId: event.deviceId },
3522
+ meta: {
3523
+ eventId: event.id,
3524
+ phase
3525
+ }
3526
+ })
3527
+ };
3528
+ }
3529
+ /** A committed ZoneAnalytics occupancy edge. */
3530
+ function incomingFromOccupancyEdge(edge) {
3531
+ return {
3532
+ subject: subjectFromOccupancyEvent(edge),
3533
+ kind: "occupancy-event",
3534
+ origin: "pipeline",
3535
+ log: () => ({
3536
+ tags: { deviceId: edge.deviceId },
3537
+ meta: {
3538
+ zoneId: edge.zoneId ?? "@frame",
3539
+ className: edge.className ?? "@all",
3540
+ count: edge.count,
3541
+ threshold: edge.threshold,
3542
+ occupied: edge.occupied
3543
+ }
3544
+ })
3545
+ };
3546
+ }
3547
+ /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
3548
+ * record — same subject, honest origin in the log. */
3549
+ function asReconcile(event) {
3550
+ return {
3551
+ ...event,
3552
+ origin: "reconcile"
3553
+ };
3554
+ }
3555
+ /**
3556
+ * The delivery an intake kind rides. `object-event` and `audio-event` both ride
3557
+ * `immediate` (classified audio has no delivery of its own — it is gated by the
3558
+ * audio-class opt-in instead); `occupancy-event` rides `device-event`.
3559
+ *
3560
+ * Extracted from the centre so the tester can tell an operator which rules a
3561
+ * given simulated trigger will even be considered against, without evaluating.
3562
+ */
3563
+ function deliveryForKind(kind) {
3564
+ if (kind === "object-event" || kind === "audio-event") return "immediate";
3565
+ if (kind === "occupancy-event") return "device-event";
3566
+ return kind;
3567
+ }
3568
+ //#endregion
3569
+ //#region src/notification-center/test-event.ts
3570
+ /** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
3571
+ var BboxSchema = require_dist.object({
3572
+ x: require_dist.number().min(0).max(1),
3573
+ y: require_dist.number().min(0).max(1),
3574
+ w: require_dist.number().min(0).max(1),
3575
+ h: require_dist.number().min(0).max(1)
3576
+ });
3577
+ /**
3578
+ * Which trigger to simulate. One value per {@link NcSubjectKind}, because the
3579
+ * kind is what decides the delivery a rule must declare to be a candidate at
3580
+ * all — picking the wrong one is the single most common reason an operator's
3581
+ * rule "does not fire", and making it an explicit choice is half the point of
3582
+ * the tester.
3583
+ */
3584
+ var TriggerSchema = require_dist._enum([
3585
+ "object-event",
3586
+ "track-end",
3587
+ "device-event",
3588
+ "package-event",
3589
+ "audio-event",
3590
+ "occupancy-event"
3591
+ ]);
3592
+ /**
3593
+ * What the operator typed. Every field maps onto exactly one field of
3594
+ * {@link NcRuleSubject} — there is no test-only condition input, because a
3595
+ * condition the tester could set and a real record could not is a condition the
3596
+ * test result would lie about.
3597
+ */
3598
+ var NcTestEventInputSchema = require_dist.object({
3599
+ trigger: TriggerSchema,
3600
+ /** The CAMERA the event is attributed to (what a notification shows). */
3601
+ deviceId: require_dist.number().int(),
3602
+ /** DEVICE-EVENT: the linked device whose state changed — the contact, the
3603
+ * button, the doorbell — when that is not the camera. */
3604
+ sourceDeviceId: require_dist.number().int().optional(),
3605
+ /** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
3606
+ classNames: require_dist.array(require_dist.string()).default([]),
3607
+ /** Collapsed display label — an identity name, a plate, a subclass. */
3608
+ label: require_dist.string().optional(),
3609
+ /** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
3610
+ * condition FAILS on an absent confidence, and the tester must be able to
3611
+ * reproduce that. */
3612
+ confidence: require_dist.number().min(0).max(1).optional(),
3613
+ /** Identity/plate MATCH confidence — distinct from `confidence`. */
3614
+ labelConfidence: require_dist.number().min(0).max(1).optional(),
3615
+ /** Key-event importance [0,1] (track-end only, in practice). */
3616
+ importance: require_dist.number().min(0).max(1).optional(),
3617
+ /** Track lifespan in seconds (track-end only). */
3618
+ dwellSeconds: require_dist.number().min(0).optional(),
3619
+ /** Admin zone ids the subject is in (object) / visited (track). */
3620
+ zones: require_dist.array(require_dist.string()).default([]),
3621
+ /** OBJECT-EVENT: simulate a confirmed zone crossing. */
3622
+ crossing: require_dist.object({
3623
+ zoneId: require_dist.string(),
3624
+ direction: require_dist._enum(["enter", "exit"])
3625
+ }).optional(),
3626
+ /** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
3627
+ sensorKind: require_dist.string().optional(),
3628
+ /** DEVICE-EVENT: the raw device event-type token, when the source is an
3629
+ * event-emitter slice. */
3630
+ eventType: require_dist.string().optional(),
3631
+ /** PACKAGE-EVENT: which lifecycle phase. */
3632
+ packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
3633
+ /** Detection provenance. Absent = `pipeline` (what a record with no stamped
3634
+ * source reads as). */
3635
+ source: require_dist._enum([
3636
+ "pipeline",
3637
+ "onboard",
3638
+ "sensor",
3639
+ "audio"
3640
+ ]).optional(),
3641
+ /** Normalized bbox — feeds `customZones`, AND is what lets the still be
3642
+ * cropped / boxed the way the rule's media policy asks. */
3643
+ bbox: BboxSchema.optional(),
3644
+ /** OCCUPANCY-EVENT: the committed count edge. */
3645
+ occupancy: require_dist.object({
3646
+ zoneId: require_dist.string().optional(),
3647
+ zoneName: require_dist.string().optional(),
3648
+ className: require_dist.string().optional(),
3649
+ count: require_dist.number().int().min(0),
3650
+ previousCount: require_dist.number().int().min(0).default(0),
3651
+ occupied: require_dist.boolean(),
3652
+ threshold: require_dist.number().int().min(1)
3653
+ }).optional(),
3654
+ /** The instant the event happened. Absent = now. Set it to test a schedule
3655
+ * condition without waiting for the hour. */
3656
+ timestamp: require_dist.number().int().optional(),
3657
+ /** Evaluate only this rule (what the per-rule Test button sends). */
3658
+ onlyRuleId: require_dist.string().optional(),
3659
+ bypassCooldown: require_dist.boolean().default(false),
3660
+ bypassMute: require_dist.boolean().default(false),
3661
+ /** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
3662
+ * not open a gate the operator did not ask to open. */
3663
+ runActions: require_dist.boolean().default(false),
3664
+ /** Where the still comes from. `live` takes a snapshot of the camera NOW;
3665
+ * `none` sends the notification without one. */
3666
+ still: require_dist._enum(["live", "none"]).default("live")
3667
+ });
3668
+ /** Per-rule verdict, as the tester reports it. */
3669
+ var NcTestEventRuleOutcomeSchema = require_dist.object({
3670
+ ruleId: require_dist.string(),
3671
+ ruleName: require_dist.string(),
3672
+ matched: require_dist.boolean(),
3673
+ failedCondition: require_dist.string().optional(),
3674
+ matchedOn: require_dist.array(require_dist.string()).optional(),
3675
+ cooldown: require_dist._enum([
3676
+ "n/a",
3677
+ "clear",
3678
+ "suppressed",
3679
+ "bypassed"
3680
+ ]),
3681
+ enqueued: require_dist.number().int(),
3682
+ targetIds: require_dist.array(require_dist.string())
3683
+ });
3684
+ var NcTestEventResultSchema = require_dist.object({
3685
+ /** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
3686
+ * for in the logs, in the outbox and in the delivery history. */
3687
+ recordId: require_dist.string(),
3688
+ /** Always `synthetic` — the marker the whole path carried. */
3689
+ origin: require_dist.literal("synthetic"),
3690
+ /** The delivery the chosen trigger rides. A rule declaring anything else was
3691
+ * never a candidate, which is the answer to most "it did not fire". */
3692
+ delivery: require_dist.string(),
3693
+ candidates: require_dist.number().int(),
3694
+ outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
3695
+ /** What the still ended up being. `unattachable` means the chosen TRIGGER
3696
+ * cannot carry one at all — a device-event notification has no media owner,
3697
+ * live or synthetic — and that is a property of production, not of the test. */
3698
+ still: require_dist._enum([
3699
+ "live",
3700
+ "none",
3701
+ "unavailable",
3702
+ "unattachable"
3703
+ ]),
3704
+ /** Everything the operator has to know to read the result honestly. */
3705
+ notes: require_dist.array(require_dist.string()),
3706
+ droppedBy: require_dist.string().optional()
3707
+ });
3708
+ /** The id shape every synthetic record carries. One prefix, everywhere. */
3709
+ var SYNTHETIC_ID_PREFIX = "synthetic:";
3710
+ /** True for a record id this module minted. Used by the media shim. */
3711
+ function isSyntheticId(id) {
3712
+ return id.startsWith(SYNTHETIC_ID_PREFIX);
3713
+ }
3714
+ /**
3715
+ * Whether a subject of this kind can carry a still AT ALL, in production.
3716
+ *
3717
+ * The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
3718
+ * owner onto the payload only for the kinds that have one: an `eventId` for
3719
+ * object/package events, a `trackId` for track-scoped ones. A device-event, an
3720
+ * audio episode and an occupancy edge freeze neither — so those notifications
3721
+ * ship text-only today, and a tester that attached an image to them would be
3722
+ * advertising a feature that does not exist.
3723
+ */
3724
+ function triggerCanCarryStill(kind) {
3725
+ return kind === "object-event" || kind === "package-event" || kind === "track-end";
3726
+ }
3727
+ /**
3728
+ * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
3729
+ * event, so the red-green test can assert that this subject and a
3730
+ * producer-built one are indistinguishable.
3731
+ */
3732
+ function buildSyntheticEvent(input, recordId, now) {
3733
+ const kind = input.trigger;
3734
+ const timestamp = input.timestamp ?? now;
3735
+ const trackScoped = kind === "object-event" || kind === "track-end";
3736
+ return {
3737
+ subject: {
3738
+ kind,
3739
+ recordId,
3740
+ deviceId: input.deviceId,
3741
+ ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
3742
+ timestamp,
3743
+ classNames: input.classNames,
3744
+ ...input.label !== void 0 ? { label: input.label } : {},
3745
+ ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
3746
+ zones: input.zones,
3747
+ ...trackScoped ? { trackId: recordId } : {},
3748
+ source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
3749
+ ...input.importance !== void 0 ? { importance: input.importance } : {},
3750
+ ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
3751
+ ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
3752
+ ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
3753
+ ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
3754
+ ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
3755
+ ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
3756
+ ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
3757
+ ...input.occupancy !== void 0 ? { occupancy: {
3758
+ ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
3759
+ ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
3760
+ ...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
3761
+ count: input.occupancy.count,
3762
+ previousCount: input.occupancy.previousCount,
3763
+ occupied: input.occupancy.occupied,
3764
+ threshold: input.occupancy.threshold
3765
+ } } : {}
3766
+ },
3767
+ kind,
3768
+ origin: "synthetic",
3769
+ controls: {
3770
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
3771
+ bypassCooldown: input.bypassCooldown,
3772
+ bypassMute: input.bypassMute,
3773
+ runActions: input.runActions
3774
+ },
3775
+ log: () => ({
3776
+ tags: { deviceId: input.deviceId },
3777
+ meta: {
3778
+ eventId: recordId,
3779
+ synthetic: true,
3780
+ trigger: input.trigger,
3781
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
3782
+ }
3783
+ })
3784
+ };
3785
+ }
3786
+ /**
3787
+ * Turn one live JPEG into the media set the dispatcher's ladders look for.
3788
+ *
3789
+ * Every entry is the SAME photograph, differing only in how it was cut — which
3790
+ * is exactly what the ladders mean by their kind names, so nothing here claims
3791
+ * a frame it does not have:
3792
+ *
3793
+ * - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
3794
+ * - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
3795
+ * - `crop` / `thumbnail` — that bbox, cut out.
3796
+ *
3797
+ * With NO bbox only the clean-scene kinds are produced, so a rule asking for
3798
+ * `frame: 'cropped'` correctly finds nothing rather than being handed a whole
3799
+ * scene it did not ask for. sharp failures degrade the same way: the clean
3800
+ * scene always survives.
3801
+ */
3802
+ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
3803
+ const base64 = Buffer.from(jpeg).toString("base64");
3804
+ const clean = (kind) => ({
3805
+ key: `${SYNTHETIC_ID_PREFIX}${kind}`,
3806
+ kind,
3807
+ base64,
3808
+ sizeBytes: jpeg.byteLength,
3809
+ timestamp
3810
+ });
3811
+ const files = [
3812
+ clean("fullFrame"),
3813
+ clean("keyFrame"),
3814
+ clean("firstFrame")
3815
+ ];
3816
+ if (bbox === void 0) return files;
3817
+ try {
3818
+ const { default: sharp$7 } = await import("sharp");
3819
+ const meta = await sharp$7(Buffer.from(jpeg)).metadata();
3820
+ const W = meta.width ?? 0;
3821
+ const H = meta.height ?? 0;
3822
+ if (W === 0 || H === 0) return files;
3823
+ const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
3824
+ const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
3825
+ const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
3826
+ const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
3827
+ const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
3828
+ left,
3829
+ top,
3830
+ width,
3831
+ height
3832
+ }).jpeg({ quality: 85 }).toBuffer();
3833
+ const cropB64 = cropBuf.toString("base64");
3834
+ files.push({
3835
+ key: `${SYNTHETIC_ID_PREFIX}crop`,
3836
+ kind: "crop",
3837
+ base64: cropB64,
3838
+ sizeBytes: cropBuf.byteLength,
3839
+ timestamp
3840
+ }, {
3841
+ key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
3842
+ kind: "thumbnail",
3843
+ base64: cropB64,
3844
+ sizeBytes: cropBuf.byteLength,
3845
+ timestamp
3846
+ });
3847
+ const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
3848
+ 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>`);
3849
+ const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
3850
+ input: overlay,
3851
+ top: 0,
3852
+ left: 0
3853
+ }]).jpeg({ quality: 85 }).toBuffer();
3854
+ files.push({
3855
+ key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
3856
+ kind: "fullFrameBoxed",
3857
+ base64: boxedBuf.toString("base64"),
3858
+ sizeBytes: boxedBuf.byteLength,
3859
+ timestamp
3860
+ });
3861
+ } catch {
3862
+ return files;
3863
+ }
3864
+ return files;
3865
+ }
3866
+ //#endregion
4011
3867
  //#region src/notification-center/action-buttons.ts
4012
3868
  /** Sequence names the rule actually declares. A button may only name one. */
4013
3869
  function declaredSequences(actions) {
@@ -5309,6 +5165,16 @@ var WATERMARK_EVERY_TICKS = 15;
5309
5165
  var OUTBOX_RETENTION_MS = 168 * 36e5;
5310
5166
  var TEST_RULE_MAX_RESULTS = 200;
5311
5167
  /**
5168
+ * How long a synthetic injection's live snapshot stays in RAM.
5169
+ *
5170
+ * Long enough to cover the drain tick plus the first handful of outbox
5171
+ * backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
5172
+ * cannot hold a JPEG for the life of the process. A row still retrying after
5173
+ * this loses its picture and ships text-only — the same degradation a real
5174
+ * event gets when its media never landed.
5175
+ */
5176
+ var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
5177
+ /**
5312
5178
  * The className every durable package event carries (mirrors
5313
5179
  * `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
5314
5180
  * stays free of a cross-module import into the pipeline). Delivery events use
@@ -5422,6 +5288,13 @@ var NotificationCenter = class NotificationCenter {
5422
5288
  alarmPanel = null;
5423
5289
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
5424
5290
  lastFiredAt = /* @__PURE__ */ new Map();
5291
+ /**
5292
+ * The live snapshot a synthetic injection took, held for as long as the
5293
+ * outbox may still be retrying its rows. In RAM and only in RAM: a test that
5294
+ * left a media blob behind would be a test that persisted something, which
5295
+ * is the one thing the synthetic producer promises not to do.
5296
+ */
5297
+ syntheticMedia = /* @__PURE__ */ new Map();
5425
5298
  /** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
5426
5299
  lastNoMatchReportAt = /* @__PURE__ */ new Map();
5427
5300
  noMatchSuppressed = /* @__PURE__ */ new Map();
@@ -5579,6 +5452,11 @@ var NotificationCenter = class NotificationCenter {
5579
5452
  this.dispatcher = new NcDispatcher({
5580
5453
  ...deps.dispatcher,
5581
5454
  logger: this.logger.child("dispatch"),
5455
+ getMediaForOwner: async (ownerKind, ownerId) => {
5456
+ const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
5457
+ if (held !== void 0) return held.files;
5458
+ return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
5459
+ },
5582
5460
  ...deps.now !== void 0 ? { now: deps.now } : {},
5583
5461
  isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
5584
5462
  snoozeGate: (input) => this.snoozeGate(input)
@@ -5657,19 +5535,157 @@ var NotificationCenter = class NotificationCenter {
5657
5535
  this.evaluationActive = false;
5658
5536
  }
5659
5537
  /**
5538
+ * Consume ONE event. THE entry point of the notification path — every
5539
+ * producer arrives here and nothing else does.
5540
+ *
5541
+ * Fire-and-forget: the frame path must never await a notification. The
5542
+ * evaluation is appended to the serialized chain, so consumption order is
5543
+ * persist order (see {@link evalChain}).
5544
+ *
5545
+ * It persists nothing about the event itself. Whether a durable row exists is
5546
+ * the PRODUCER's business — the tracker writes its tracks, the sensor ingest
5547
+ * its rows, the synthetic tester nothing at all — and the only thing written
5548
+ * from here on is the outbox row, which is the notification's own delivery
5549
+ * queue and not a record of the event.
5550
+ */
5551
+ consumeEvent(event) {
5552
+ if (!this.evaluationActive) return;
5553
+ this.scheduleEvaluation(event);
5554
+ }
5555
+ /**
5556
+ * The same consumption, awaited, with the verdict returned. Identical code
5557
+ * path — {@link consumeEvent} is this function with the report discarded — so
5558
+ * a producer that reads the report is testing what the live producers do, not
5559
+ * a parallel implementation of it.
5560
+ */
5561
+ async consumeEventAndReport(event) {
5562
+ if (!this.evaluationActive) return {
5563
+ recordId: event.subject.recordId,
5564
+ kind: event.kind,
5565
+ delivery: deliveryForKind(event.kind),
5566
+ origin: event.origin,
5567
+ candidates: 0,
5568
+ outcomes: [],
5569
+ droppedBy: "evaluation-inactive"
5570
+ };
5571
+ const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
5572
+ this.evalChain = result.then(() => void 0, () => void 0);
5573
+ return result;
5574
+ }
5575
+ /**
5576
+ * The SYNTHETIC producer — an operator-driven event, through the whole path.
5577
+ *
5578
+ * It builds an envelope and calls {@link consumeEventAndReport}. That is the
5579
+ * entire integration: no bypass, no second matcher, no shortcut into the
5580
+ * dispatcher. What the operator receives on their phone was matched by
5581
+ * `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
5582
+ * tick, given media by the ordinary ladders, degraded by the ordinary engine
5583
+ * and delivered by the ordinary notifier.
5584
+ *
5585
+ * What it does NOT do is persist the event. No track row, no object-event
5586
+ * row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
5587
+ * expiry. The outbox row is the exception that proves the rule: it is the
5588
+ * notification's delivery queue, not a record of the event, and it carries
5589
+ * `synthetic: true` so it can never be mistaken for one.
5590
+ */
5591
+ async injectTestEvent(input, deps = {}) {
5592
+ const now = this.now();
5593
+ this.pruneSyntheticMedia(now);
5594
+ const recordId = `${SYNTHETIC_ID_PREFIX}${(0, node_crypto.randomUUID)()}`;
5595
+ const event = buildSyntheticEvent(input, recordId, now);
5596
+ const notes = [];
5597
+ let still = "none";
5598
+ if (!triggerCanCarryStill(event.kind)) {
5599
+ still = "unattachable";
5600
+ 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`);
5601
+ } else if (input.still === "live") {
5602
+ const jpeg = await this.captureLiveStill(input.deviceId, deps);
5603
+ if (jpeg === null) {
5604
+ still = "unavailable";
5605
+ notes.push("the camera returned no snapshot — the notification ships without a still");
5606
+ } else {
5607
+ const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
5608
+ this.syntheticMedia.set(recordId, {
5609
+ files,
5610
+ expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
5611
+ });
5612
+ still = "live";
5613
+ 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");
5614
+ }
5615
+ }
5616
+ if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
5617
+ if (input.bypassMute) notes.push("per-camera notification mute bypassed");
5618
+ if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
5619
+ this.logger.info("synthetic event injected", {
5620
+ tags: { deviceId: input.deviceId },
5621
+ meta: {
5622
+ eventId: recordId,
5623
+ synthetic: true,
5624
+ trigger: input.trigger,
5625
+ classes: input.classNames,
5626
+ zones: input.zones,
5627
+ still,
5628
+ ...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
5629
+ }
5630
+ });
5631
+ const report = await this.consumeEventAndReport(event);
5632
+ 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}"`);
5633
+ if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
5634
+ if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
5635
+ 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");
5636
+ return {
5637
+ recordId,
5638
+ origin: "synthetic",
5639
+ delivery: report.delivery,
5640
+ candidates: report.candidates,
5641
+ outcomes: report.outcomes.map((o) => ({
5642
+ ruleId: o.ruleId,
5643
+ ruleName: o.ruleName,
5644
+ matched: o.matched,
5645
+ ...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
5646
+ ...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
5647
+ cooldown: o.cooldown,
5648
+ enqueued: o.enqueued,
5649
+ targetIds: [...o.targetIds]
5650
+ })),
5651
+ still,
5652
+ notes,
5653
+ ...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
5654
+ };
5655
+ }
5656
+ /** One live frame, or null. Never throws into an injection. */
5657
+ async captureLiveStill(deviceId, deps) {
5658
+ const capture = deps.getSnapshot;
5659
+ if (capture === void 0) return null;
5660
+ try {
5661
+ const shot = await capture(deviceId);
5662
+ if (shot === null || shot.base64.length === 0) return null;
5663
+ const buf = Buffer.from(shot.base64, "base64");
5664
+ const bytes = new Uint8Array(buf.byteLength);
5665
+ bytes.set(buf);
5666
+ return bytes;
5667
+ } catch (err) {
5668
+ this.logger.warn("synthetic injection could not photograph the camera", {
5669
+ tags: { deviceId },
5670
+ meta: { error: String(err) }
5671
+ });
5672
+ return null;
5673
+ }
5674
+ }
5675
+ /** Drop expired synthetic stills. Called on every injection — there is no
5676
+ * timer, because the map is empty except while a test is in flight. */
5677
+ pruneSyntheticMedia(now) {
5678
+ for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
5679
+ }
5680
+ /**
5660
5681
  * Called at the object-event persist site, in the SAME moment as the
5661
5682
  * durable insert. Fire-and-forget from the frame path (never blocks the
5662
5683
  * pipeline); the boot reconcile covers a crash between persist and
5663
5684
  * outbox insert.
5664
5685
  */
5665
5686
  onObjectEventPersisted(event, _track) {
5666
- if (!this.evaluationActive) return;
5667
5687
  if (packagePhaseOf(event) !== null) return;
5668
- const subject = subjectFromObjectEvent(event);
5669
- this.scheduleEvaluation(subject, "object-event", () => ({
5670
- tags: { deviceId: event.deviceId },
5671
- meta: { eventId: event.id }
5672
- }));
5688
+ this.consumeEvent(incomingFromObjectEvent(event));
5673
5689
  }
5674
5690
  /**
5675
5691
  * Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
@@ -5678,12 +5694,7 @@ var NotificationCenter = class NotificationCenter {
5678
5694
  * post-close promotions are picked up).
5679
5695
  */
5680
5696
  onTrackClosed(track, _ownedMedia, info) {
5681
- if (!this.evaluationActive) return;
5682
- const subject = subjectFromTrack(track, info);
5683
- this.scheduleEvaluation(subject, "track-end", () => ({
5684
- tags: { deviceId: track.deviceId },
5685
- meta: { trackId: track.trackId }
5686
- }));
5697
+ this.consumeEvent(incomingFromTrack(track, info));
5687
5698
  }
5688
5699
  /**
5689
5700
  * Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
@@ -5699,15 +5710,7 @@ var NotificationCenter = class NotificationCenter {
5699
5710
  * before it.
5700
5711
  */
5701
5712
  onSensorEventPersisted(event) {
5702
- if (!this.evaluationActive) return;
5703
- const subject = subjectFromSensorEvent(event);
5704
- this.scheduleEvaluation(subject, "device-event", () => ({
5705
- tags: { deviceId: event.deviceId },
5706
- meta: {
5707
- sensorEventId: event.id,
5708
- kind: event.kind
5709
- }
5710
- }));
5713
+ this.consumeEvent(incomingFromSensorEvent(event));
5711
5714
  }
5712
5715
  /**
5713
5716
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -5722,15 +5725,7 @@ var NotificationCenter = class NotificationCenter {
5722
5725
  * durable guarantee begins at this hook, matching the device-event boundary.
5723
5726
  */
5724
5727
  onAudioEventPersisted(event) {
5725
- if (!this.evaluationActive) return;
5726
- const subject = subjectFromAudioEvent(event);
5727
- this.scheduleEvaluation(subject, "audio-event", () => ({
5728
- tags: { deviceId: event.deviceId },
5729
- meta: {
5730
- audioEventId: event.id,
5731
- class: event.classification?.className
5732
- }
5733
- }));
5728
+ this.consumeEvent(incomingFromAudioEvent(event));
5734
5729
  }
5735
5730
  /**
5736
5731
  * Called at the package object-event persist site (`PackageDropDetector` —
@@ -5739,15 +5734,7 @@ var NotificationCenter = class NotificationCenter {
5739
5734
  * crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
5740
5735
  */
5741
5736
  onPackageEventPersisted(event, phase) {
5742
- if (!this.evaluationActive) return;
5743
- const subject = subjectFromPackageEvent(event, phase);
5744
- this.scheduleEvaluation(subject, "package-event", () => ({
5745
- tags: { deviceId: event.deviceId },
5746
- meta: {
5747
- eventId: event.id,
5748
- phase
5749
- }
5750
- }));
5737
+ this.consumeEvent(incomingFromPackageEvent(event, phase));
5751
5738
  }
5752
5739
  /**
5753
5740
  * Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
@@ -5783,18 +5770,7 @@ var NotificationCenter = class NotificationCenter {
5783
5770
  * Public so a test / a future out-of-band edge source can drive it directly.
5784
5771
  */
5785
5772
  onOccupancyEdge(edge) {
5786
- if (!this.evaluationActive) return;
5787
- const subject = subjectFromOccupancyEvent(edge);
5788
- this.scheduleEvaluation(subject, "occupancy-event", () => ({
5789
- tags: { deviceId: edge.deviceId },
5790
- meta: {
5791
- zoneId: edge.zoneId ?? "@frame",
5792
- className: edge.className ?? "@all",
5793
- count: edge.count,
5794
- threshold: edge.threshold,
5795
- occupied: edge.occupied
5796
- }
5797
- }));
5773
+ this.consumeEvent(incomingFromOccupancyEdge(edge));
5798
5774
  }
5799
5775
  buildProvider() {
5800
5776
  return {
@@ -5969,40 +5945,55 @@ var NotificationCenter = class NotificationCenter {
5969
5945
  });
5970
5946
  }
5971
5947
  /** Append one evaluation to the serialized chain (see {@link evalChain}). */
5972
- scheduleEvaluation(subject, kind, logContext) {
5948
+ scheduleEvaluation(event) {
5973
5949
  this.evalChain = this.evalChain.then(async () => {
5974
5950
  try {
5975
- await this.evaluateAndEnqueue(subject, kind);
5951
+ await this.evaluateAndEnqueue(event);
5976
5952
  } catch (err) {
5977
- const ctx = logContext();
5953
+ const ctx = event.log();
5978
5954
  this.logger.warn("rule evaluation failed", {
5979
5955
  tags: ctx.tags,
5980
5956
  meta: {
5981
5957
  ...ctx.meta,
5982
- kind,
5958
+ kind: event.kind,
5959
+ origin: event.origin,
5983
5960
  error: String(err)
5984
5961
  }
5985
5962
  });
5986
5963
  }
5987
5964
  });
5988
5965
  }
5989
- async evaluateAndEnqueue(subject, kind) {
5990
- if (this.deviceMutes.isMuted(subject.deviceId)) {
5966
+ async evaluateAndEnqueue(event) {
5967
+ const { subject, kind, origin } = event;
5968
+ const controls = event.controls ?? {};
5969
+ const outcomes = [];
5970
+ const report = (extra = {}) => ({
5971
+ recordId: subject.recordId,
5972
+ kind,
5973
+ delivery: deliveryForKind(kind),
5974
+ origin,
5975
+ candidates: 0,
5976
+ outcomes,
5977
+ ...extra
5978
+ });
5979
+ if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
5991
5980
  this.reportMutedDrop(subject, kind, this.now());
5992
- return;
5981
+ return report({ droppedBy: "muted" });
5993
5982
  }
5994
- const delivery = kind === "object-event" || kind === "audio-event" ? "immediate" : kind === "occupancy-event" ? "device-event" : kind;
5995
- const candidates = this.rules.listEnabled(delivery);
5983
+ const delivery = deliveryForKind(kind);
5984
+ const enabled = this.rules.listEnabled(delivery);
5985
+ const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
5996
5986
  if (candidates.length === 0) {
5997
5987
  this.logger.debug("no enabled rule for this trigger", {
5998
5988
  tags: { deviceId: subject.deviceId },
5999
5989
  meta: {
6000
5990
  delivery,
6001
5991
  kind,
5992
+ origin,
6002
5993
  rulesLoaded: this.rules.list().length
6003
5994
  }
6004
5995
  });
6005
- return;
5996
+ return report({ droppedBy: "no-candidate-rule" });
6006
5997
  }
6007
5998
  const now = this.now();
6008
5999
  let anyMatched = false;
@@ -6011,6 +6002,15 @@ var NotificationCenter = class NotificationCenter {
6011
6002
  const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
6012
6003
  if (!evaluation.matched) {
6013
6004
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
6005
+ outcomes.push({
6006
+ ruleId: rule.id,
6007
+ ruleName: rule.name,
6008
+ matched: false,
6009
+ ...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
6010
+ cooldown: "n/a",
6011
+ enqueued: 0,
6012
+ targetIds: []
6013
+ });
6014
6014
  this.logger.debug("rule did not match", {
6015
6015
  tags: { deviceId: subject.deviceId },
6016
6016
  meta: {
@@ -6026,7 +6026,8 @@ var NotificationCenter = class NotificationCenter {
6026
6026
  continue;
6027
6027
  }
6028
6028
  const key = cooldownKey(rule, subject);
6029
- if (isCoolingDown(rule, this.lastFiredAt.get(key), now)) {
6029
+ const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
6030
+ if (cooling && controls.bypassCooldown !== true) {
6030
6031
  this.logger.debug("rule matched but is cooling down", {
6031
6032
  tags: { deviceId: subject.deviceId },
6032
6033
  meta: {
@@ -6037,6 +6038,15 @@ var NotificationCenter = class NotificationCenter {
6037
6038
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
6038
6039
  }
6039
6040
  });
6041
+ outcomes.push({
6042
+ ruleId: rule.id,
6043
+ ruleName: rule.name,
6044
+ matched: true,
6045
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6046
+ cooldown: "suppressed",
6047
+ enqueued: 0,
6048
+ targetIds: []
6049
+ });
6040
6050
  continue;
6041
6051
  }
6042
6052
  this.logger.info("rule matched — enqueueing", {
@@ -6045,6 +6055,7 @@ var NotificationCenter = class NotificationCenter {
6045
6055
  ruleId: rule.id,
6046
6056
  rule: rule.name,
6047
6057
  kind,
6058
+ origin,
6048
6059
  targets: rule.targets.length,
6049
6060
  eventId: subject.recordId,
6050
6061
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
@@ -6053,7 +6064,7 @@ var NotificationCenter = class NotificationCenter {
6053
6064
  }
6054
6065
  });
6055
6066
  const sequences = rule.actions?.onTrigger;
6056
- if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
6067
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
6057
6068
  ruleId: rule.id,
6058
6069
  ruleName: rule.name,
6059
6070
  deviceId: subject.deviceId,
@@ -6069,10 +6080,22 @@ var NotificationCenter = class NotificationCenter {
6069
6080
  }
6070
6081
  });
6071
6082
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
6072
- if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
6083
+ const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
6084
+ const inserted = await this.outbox.enqueue(entries);
6085
+ if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
6086
+ outcomes.push({
6087
+ ruleId: rule.id,
6088
+ ruleName: rule.name,
6089
+ matched: true,
6090
+ ...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
6091
+ cooldown: cooling ? "bypassed" : "clear",
6092
+ enqueued: inserted,
6093
+ targetIds: entries.map((e) => e.targetId)
6094
+ });
6073
6095
  anyMatched = true;
6074
6096
  }
6075
6097
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
6098
+ return report({ candidates: candidates.length });
6076
6099
  }
6077
6100
  /**
6078
6101
  * One INFO line when a MUTED camera's event is dropped, bounded to one per
@@ -6124,7 +6147,7 @@ var NotificationCenter = class NotificationCenter {
6124
6147
  }
6125
6148
  });
6126
6149
  }
6127
- buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
6150
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
6128
6151
  const hasEventMedia = kind === "object-event" || kind === "package-event";
6129
6152
  const isTrackScoped = kind === "object-event" || kind === "track-end";
6130
6153
  const direct = new Set(rule.targets.map((t) => t.targetId));
@@ -6132,6 +6155,7 @@ var NotificationCenter = class NotificationCenter {
6132
6155
  const payload = {
6133
6156
  ruleName: rule.name,
6134
6157
  delivery: rule.delivery,
6158
+ ...origin === "synthetic" ? { synthetic: true } : {},
6135
6159
  priority: rule.priority,
6136
6160
  ...rule.template !== void 0 ? { template: rule.template } : {},
6137
6161
  media: rule.media.attach,
@@ -6315,21 +6339,7 @@ var NotificationCenter = class NotificationCenter {
6315
6339
  const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
6316
6340
  for (const event of ordered) {
6317
6341
  const phase = packagePhaseOf(event);
6318
- if (phase !== null) this.scheduleEvaluation(subjectFromPackageEvent(event, phase), "package-event", () => ({
6319
- tags: { deviceId: event.deviceId },
6320
- meta: {
6321
- eventId: event.id,
6322
- phase,
6323
- reconcile: true
6324
- }
6325
- }));
6326
- else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
6327
- tags: { deviceId: event.deviceId },
6328
- meta: {
6329
- eventId: event.id,
6330
- reconcile: true
6331
- }
6332
- }));
6342
+ this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
6333
6343
  }
6334
6344
  await this.evalChain;
6335
6345
  if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
@@ -6497,7 +6507,7 @@ var NotificationCenter = class NotificationCenter {
6497
6507
  }
6498
6508
  } else if (rule.delivery === "track-end") {
6499
6509
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
6500
- for (const track of tracks) subjects.push(subjectFromTrack(track));
6510
+ for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
6501
6511
  }
6502
6512
  let lookup = (id) => this.deviceStates.get(id);
6503
6513
  const gated = rule.conditions.deviceState;
@@ -6600,6 +6610,24 @@ var ncActions = require_dist.defineCustomActions({
6600
6610
  "nc.cancelSnooze": require_dist.customAction(require_dist.object({ snoozeId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
6601
6611
  kind: "mutation",
6602
6612
  caller: "required"
6613
+ }),
6614
+ /**
6615
+ * Inject a SYNTHETIC event through the whole notification path — match,
6616
+ * media, delivery — without producing a track, an event row or anything else
6617
+ * durable. See `test-event.ts` for what it does and does not do.
6618
+ *
6619
+ * A bridge action rather than a cap method, deliberately: it needs no
6620
+ * codegen, no `@camstack/system` republish and no place on a release train,
6621
+ * so the tester can ship and change at the speed of the addon it tests.
6622
+ *
6623
+ * `auth: 'admin'` because an injection DELIVERS: it reaches every target the
6624
+ * matched rule addresses, on other people's phones. `caller: 'required'` so
6625
+ * the log line names who pressed it.
6626
+ */
6627
+ "nc.injectTestEvent": require_dist.customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
6628
+ kind: "mutation",
6629
+ auth: "admin",
6630
+ caller: "required"
6603
6631
  })
6604
6632
  });
6605
6633
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -6714,6 +6742,22 @@ function makeNcActionHandlers(deps) {
6714
6742
  await deps.snooze.cancel(input.snoozeId, c);
6715
6743
  return { success: true };
6716
6744
  },
6745
+ "nc.injectTestEvent": async (input, caller) => {
6746
+ const c = requireCaller(caller);
6747
+ if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
6748
+ const inject = deps.injectTestEvent;
6749
+ if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
6750
+ const parsed = NcTestEventInputSchema.parse(input);
6751
+ deps.logger.info("synthetic notification test requested", {
6752
+ tags: { deviceId: parsed.deviceId },
6753
+ meta: {
6754
+ by: c.userId,
6755
+ trigger: parsed.trigger,
6756
+ ...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
6757
+ }
6758
+ });
6759
+ return inject(parsed);
6760
+ },
6717
6761
  "nc.setRuleTargetEnabled": async (input, caller) => {
6718
6762
  const c = requireCaller(caller);
6719
6763
  assertRuleVisible(input.ruleId, c);
@@ -10991,6 +11035,214 @@ function createVideoclipsProvider(deps) {
10991
11035
  }
10992
11036
  };
10993
11037
  }
11038
+ /** Real time. See {@link EventMediaRequest.speed}. */
11039
+ var DEFAULT_SPEED = 1;
11040
+ /** The fallback mp4's requested width — the cap's own ceiling, so a rendition
11041
+ * at or below 1080p is an identity scale and the broker copies it. */
11042
+ var RING_MP4_MAX_WIDTH = 1920;
11043
+ /** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
11044
+ var RING_MP4_FPS = 15;
11045
+ var NO_MEDIA = {
11046
+ mp4: null,
11047
+ gif: null,
11048
+ source: "none",
11049
+ startOffsetMs: null,
11050
+ endOffsetMs: null,
11051
+ profile: null,
11052
+ video: null
11053
+ };
11054
+ var EventMediaService = class {
11055
+ deps;
11056
+ constructor(deps) {
11057
+ this.deps = deps;
11058
+ }
11059
+ /**
11060
+ * Cut this event. Never throws — a notification that lost its media is still
11061
+ * a notification, and every branch that drops it logs why.
11062
+ */
11063
+ async cut(request) {
11064
+ if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
11065
+ const produced = await this.produce(request);
11066
+ if (produced !== null) return produced;
11067
+ return this.fallbackToRing(request);
11068
+ }
11069
+ async produce(request) {
11070
+ const deviceId = request.deviceId;
11071
+ const log = this.deps.logger;
11072
+ const kinds = [];
11073
+ if (request.wantMp4) kinds.push("mp4");
11074
+ if (request.wantGif) kinds.push("gif");
11075
+ let production;
11076
+ try {
11077
+ production = await this.deps.produce({
11078
+ deviceId,
11079
+ aroundMs: request.aroundMs,
11080
+ preSeconds: request.preRollSec,
11081
+ postSeconds: request.postRollSec,
11082
+ kinds,
11083
+ gifMaxWidth: 640,
11084
+ gifFps: 8,
11085
+ speed: request.speed ?? DEFAULT_SPEED,
11086
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
11087
+ });
11088
+ } catch (err) {
11089
+ log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
11090
+ tags: { deviceId },
11091
+ meta: { error: err instanceof Error ? err.message : String(err) }
11092
+ });
11093
+ return null;
11094
+ }
11095
+ const mp4 = await this.redeem(deviceId, production.media, "mp4");
11096
+ const gif = await this.redeem(deviceId, production.media, "gif");
11097
+ if (mp4 === null && gif === null) {
11098
+ log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
11099
+ tags: { deviceId },
11100
+ meta: {
11101
+ kinds: kinds.join(","),
11102
+ profile: production.profile
11103
+ }
11104
+ });
11105
+ return null;
11106
+ }
11107
+ const startOffsetMs = production.coverage.fromTs - request.aroundMs;
11108
+ const endOffsetMs = production.coverage.toTs - request.aroundMs;
11109
+ log.info("nc media: one production, every attachment", {
11110
+ tags: { deviceId },
11111
+ meta: {
11112
+ profile: production.profile,
11113
+ video: production.video,
11114
+ mp4Bytes: mp4?.byteLength ?? null,
11115
+ gifBytes: gif?.byteLength ?? null,
11116
+ startOffsetMs,
11117
+ endOffsetMs,
11118
+ sharedWindow: true
11119
+ }
11120
+ });
11121
+ return {
11122
+ mp4,
11123
+ gif,
11124
+ source: "produced",
11125
+ startOffsetMs,
11126
+ endOffsetMs,
11127
+ profile: production.profile,
11128
+ video: production.video
11129
+ };
11130
+ }
11131
+ /**
11132
+ * Redeem one artifact handle at the node that produced it.
11133
+ *
11134
+ * `null` is not an error here — a production simply may not carry the kind
11135
+ * (a gif whose derive failed says so on the broker's own log line). A handle
11136
+ * that FAILS to redeem is different and is logged, because it means the bytes
11137
+ * existed and did not arrive.
11138
+ */
11139
+ async redeem(deviceId, media, kind) {
11140
+ const artifact = media.find((m) => m.kind === kind);
11141
+ if (artifact === void 0) return null;
11142
+ try {
11143
+ const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
11144
+ if (res === null) {
11145
+ this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
11146
+ tags: { deviceId },
11147
+ meta: {
11148
+ kind,
11149
+ handle: artifact.handle,
11150
+ nodeId: artifact.nodeId
11151
+ }
11152
+ });
11153
+ return null;
11154
+ }
11155
+ const buf = Buffer.from(res.base64, "base64");
11156
+ if (buf.byteLength === 0) return null;
11157
+ const bytes = new Uint8Array(buf.byteLength);
11158
+ bytes.set(buf);
11159
+ return bytes;
11160
+ } catch (err) {
11161
+ this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
11162
+ tags: { deviceId },
11163
+ meta: {
11164
+ kind,
11165
+ handle: artifact.handle,
11166
+ nodeId: artifact.nodeId,
11167
+ error: err instanceof Error ? err.message : String(err)
11168
+ }
11169
+ });
11170
+ return null;
11171
+ }
11172
+ }
11173
+ /**
11174
+ * The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
11175
+ *
11176
+ * Weaker than a production and deliberately so — the coherence here rests on
11177
+ * the two calls being given IDENTICAL parameters rather than on there being
11178
+ * one render. That is defensible because the ring only ever grows forward and
11179
+ * `windowAround` selects by wall clock around the same instant, so two calls
11180
+ * seconds apart choose the same packets; the one thing that could differ is
11181
+ * the rendition, so the profile is pinned to whatever the FIRST call actually
11182
+ * used and handed to the second.
11183
+ *
11184
+ * It exists for one reason: a hub whose `@camstack/server` predates the
11185
+ * production method must not stop attaching footage, and three of the four
11186
+ * live rules on this install ask for a gif and nothing else.
11187
+ */
11188
+ async fallbackToRing(request) {
11189
+ const deviceId = request.deviceId;
11190
+ const speed = request.speed ?? DEFAULT_SPEED;
11191
+ const window = {
11192
+ deviceId,
11193
+ aroundMs: request.aroundMs,
11194
+ preRollSec: request.preRollSec,
11195
+ postRollSec: request.postRollSec,
11196
+ speed,
11197
+ ...request.profile !== void 0 ? { profile: request.profile } : {}
11198
+ };
11199
+ try {
11200
+ const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
11201
+ ...window,
11202
+ format: "mp4",
11203
+ maxWidth: RING_MP4_MAX_WIDTH,
11204
+ fps: RING_MP4_FPS
11205
+ }) : null;
11206
+ const gif = request.wantGif ? await this.deps.renderRingClip({
11207
+ ...window,
11208
+ format: "gif",
11209
+ maxWidth: 640,
11210
+ fps: 8
11211
+ }) : null;
11212
+ if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
11213
+ this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
11214
+ tags: { deviceId },
11215
+ meta: { aroundMs: request.aroundMs }
11216
+ });
11217
+ return NO_MEDIA;
11218
+ }
11219
+ this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
11220
+ tags: { deviceId },
11221
+ meta: {
11222
+ mp4Bytes: mp4?.byteLength ?? null,
11223
+ gifBytes: gif?.byteLength ?? null,
11224
+ profile: request.profile ?? null,
11225
+ sharedWindow: "by-parameters"
11226
+ }
11227
+ });
11228
+ return {
11229
+ mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
11230
+ gif: gif !== null && gif.byteLength > 0 ? gif : null,
11231
+ source: "ring-fallback",
11232
+ startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
11233
+ endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
11234
+ profile: request.profile ?? null,
11235
+ video: null
11236
+ };
11237
+ } catch (err) {
11238
+ this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
11239
+ tags: { deviceId },
11240
+ meta: { error: err instanceof Error ? err.message : String(err) }
11241
+ });
11242
+ return NO_MEDIA;
11243
+ }
11244
+ }
11245
+ };
10994
11246
  //#endregion
10995
11247
  //#region src/shared/frame/crop-extractor.ts
10996
11248
  /**
@@ -25380,6 +25632,31 @@ var LinkedCamerasCache = class {
25380
25632
  }
25381
25633
  };
25382
25634
  /**
25635
+ * THE camera-set derivation for a sensor-cap state change — one function, so
25636
+ * every consumer of a device-event attributes it to the same cameras.
25637
+ *
25638
+ * It is shared on purpose. The self-camera half (a doorbell wrapper bound on
25639
+ * camera 615 makes 615 both source and subject) was added to the history-row
25640
+ * ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
25641
+ * which kept resolving `camerasFor` alone. The result: camera 615's real
25642
+ * doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
25643
+ * timeline marker, for weeks, with the divergence invisible in both codepaths.
25644
+ * Two derivations of "which cameras" is one too many.
25645
+ */
25646
+ async function resolveSensorEventCameras(cache, data) {
25647
+ const linkedCameraIds = await cache.camerasFor(data.deviceId);
25648
+ const isCamera = await cache.isCamera(data.deviceId);
25649
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
25650
+ if (cameraIds.length === 0) return {
25651
+ cameraIds,
25652
+ dropReason: isCamera ? "camera-native-cap" : "no-camera"
25653
+ };
25654
+ return {
25655
+ cameraIds,
25656
+ dropReason: null
25657
+ };
25658
+ }
25659
+ /**
25383
25660
  * One `DeviceStateChanged` → N history rows, one per camera the event belongs
25384
25661
  * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
25385
25662
  * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
@@ -25392,11 +25669,9 @@ var LinkedCamerasCache = class {
25392
25669
  async function ingestSensorStateChange(deps, data, timestamp) {
25393
25670
  const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
25394
25671
  if (descriptor === void 0) return 0;
25395
- const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
25396
- const isCamera = await deps.cache.isCamera(data.deviceId);
25397
- const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
25398
- if (cameraIds.length === 0) {
25399
- deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
25672
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
25673
+ if (dropReason !== null) {
25674
+ deps.onDropped?.(dropReason, descriptor.kind);
25400
25675
  return 0;
25401
25676
  }
25402
25677
  const slice = data.slice;
@@ -26135,6 +26410,103 @@ var EventMediaDispatcher = class {
26135
26410
  }
26136
26411
  }
26137
26412
  };
26413
+ //#endregion
26414
+ //#region src/pipeline-analytics/services/sensor-marker-projector.ts
26415
+ /**
26416
+ * Sensor markers — the gate between "a mapped sensor/control cap changed
26417
+ * state" and "a synthetic track exists on the camera's timeline"
26418
+ * (event-kinds-taxonomy §8).
26419
+ *
26420
+ * It is the sibling of {@link ../services/audio-marker-projector.ts} and it
26421
+ * exists for the same reason: the projection has gates, and a gate that drops
26422
+ * work without saying so is indistinguishable from a feature that was never
26423
+ * wired.
26424
+ *
26425
+ * **What this fixes.** The projection used to resolve its cameras with
26426
+ * `camerasFor` alone — LINKED cameras only — while the history-row ingest
26427
+ * beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
26428
+ * is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
26429
+ * slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
26430
+ * projection returned on a `debug` line nobody runs at. Camera 615's real
26431
+ * press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
26432
+ * ring, an Alexa post and a matched notification rule — and no track. Both
26433
+ * halves now derive their cameras from {@link resolveSensorEventCameras}.
26434
+ *
26435
+ * **The asymmetry that used to be here was defended by a wrong premise.** The
26436
+ * old comment argued a self-projected track would feed the NC `track-end`
26437
+ * trigger and make every press evaluate the camera's object rules. It does
26438
+ * not: `persistSyntheticTrack` writes the row directly and the NC track-end
26439
+ * hook lives in the LIVE tracker's closer, which no synthetic track ever
26440
+ * enters. A linked contact sensor has been projecting onto its camera all
26441
+ * along under exactly the same mechanism.
26442
+ *
26443
+ * **Nothing here is silent.** A marker that lands emits one `info` line; every
26444
+ * branch that lands none emits one line carrying the reason. Both carry
26445
+ * `tags: { deviceId }` with the CAMERA id, because the question always arrives
26446
+ * as "why did 617 get a marker and 615 not".
26447
+ */
26448
+ /**
26449
+ * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
26450
+ * synthetic track per camera the event belongs to. Returns the number of
26451
+ * markers that landed. Best-effort (telemetry, D8) — a materializer that
26452
+ * throws propagates to the caller, which logs it.
26453
+ */
26454
+ async function projectSensorMarkers(deps, data, timestamp) {
26455
+ const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
26456
+ if (descriptor === void 0) {
26457
+ deps.onSkipped({
26458
+ cameraId: null,
26459
+ sourceDeviceId: data.deviceId,
26460
+ capName: data.capName,
26461
+ kind: null,
26462
+ reason: "unmapped-cap"
26463
+ });
26464
+ return 0;
26465
+ }
26466
+ const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
26467
+ if (dropReason !== null) {
26468
+ deps.onSkipped({
26469
+ cameraId: null,
26470
+ sourceDeviceId: data.deviceId,
26471
+ capName: data.capName,
26472
+ kind: descriptor.kind,
26473
+ reason: dropReason
26474
+ });
26475
+ return 0;
26476
+ }
26477
+ const producingDeviceName = await deps.producingDeviceName(data.deviceId);
26478
+ let landed = 0;
26479
+ for (const cameraId of cameraIds) {
26480
+ const track = await deps.markers.materialize({
26481
+ cameraId,
26482
+ sourceDeviceId: data.deviceId,
26483
+ source: "sensor",
26484
+ kind: descriptor.kind,
26485
+ timestamp,
26486
+ ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
26487
+ });
26488
+ if (track === null) {
26489
+ deps.onSkipped({
26490
+ cameraId,
26491
+ sourceDeviceId: data.deviceId,
26492
+ capName: data.capName,
26493
+ kind: descriptor.kind,
26494
+ reason: "not-materialized"
26495
+ });
26496
+ continue;
26497
+ }
26498
+ landed++;
26499
+ deps.onProjected({
26500
+ cameraId,
26501
+ sourceDeviceId: data.deviceId,
26502
+ capName: data.capName,
26503
+ kind: descriptor.kind,
26504
+ trackId: track.trackId,
26505
+ selfCamera: cameraId === data.deviceId
26506
+ });
26507
+ }
26508
+ return landed;
26509
+ }
26138
26510
  /** JPEG quality for the downscaled full frame — matches the crop path. */
26139
26511
  var FULL_FRAME_QUALITY = 80;
26140
26512
  /**
@@ -29106,15 +29478,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
29106
29478
  * Absent / empty / non-string all fall back to the hub default — the exact
29107
29479
  * narrowing the old raw read applied inline. */
29108
29480
  var PostProcessingNodeIdSchema = require_dist.string().min(1);
29109
- /**
29110
- * Footage-attachment window + geometry, used when the rule states none. The
29111
- * window is CENTRED on the event, so the recipient sees the approach and what
29112
- * followed rather than one side of it.
29113
- */
29114
- var NC_FOOTAGE_PRE_ROLL_SEC = 3;
29115
- var NC_FOOTAGE_POST_ROLL_SEC = 5;
29116
- var NC_FOOTAGE_MAX_WIDTH = 480;
29117
- var NC_FOOTAGE_FPS = 5;
29118
29481
  /** Per-install HMAC secret behind the signed artifact links (minted once). */
29119
29482
  var NcArtifactSecretSchema = require_dist.string();
29120
29483
  var EmbeddingEnabledSchema = require_dist.boolean();
@@ -29288,38 +29651,82 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29288
29651
  * data-plane handle. Null until served / when the facility is absent. */
29289
29652
  ncArtifactPlane = null;
29290
29653
  ncArtifactDataPlane = null;
29291
- /** Roadmap 4b: notification clips cut from the live fMP4 fragment seam.
29292
- * Built on first use — a hub whose rules never ask for an mp4 never
29293
- * constructs it, and constructing it costs nothing until `renderClip`. */
29294
- ncSeamClips = null;
29295
29654
  /**
29296
- * One attempt at the fMP4 seam. `null` means "the ring serves this one", and
29297
- * the REASON is always logged with `tags: { deviceId }`.
29655
+ * Roadmap 4b: ONE cut of an event, in every container the rule asked for.
29298
29656
  *
29299
- * That log line is the whole point of the fallback being explicit rather than
29300
- * a try/catch: two cameras silently attaching two different kinds of clip is
29301
- * precisely the thing an operator cannot diagnose from a phone.
29657
+ * Built on first use — a hub whose rules never attach footage never
29658
+ * constructs it, and constructing it costs nothing until `cut`.
29302
29659
  */
29303
- async renderSeamClip(req) {
29304
- const log = this.ctx.logger;
29660
+ ncEventMedia = null;
29661
+ /**
29662
+ * The dispatcher's single media dep.
29663
+ *
29664
+ * ONE call for the gif and the mp4, deliberately: rendering them separately
29665
+ * is what produced a gif around the event and a video seven seconds after it
29666
+ * (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
29667
+ * slot, asks the broker's own prebuffer ring for the window at that slot's
29668
+ * own width in real time — which is what makes the broker COPY rather than
29669
+ * re-encode — and derives the gif from those same bytes.
29670
+ */
29671
+ async renderEventMedia(req) {
29305
29672
  const api = this.ctx.api;
29306
- this.ncSeamClips ??= new Fmp4ClipService({
29307
- logger: log.child("nc-seam-clip"),
29308
- listProfileEntries: (deviceId) => api.cameraStreams.getProfileRtspEntries.query({ deviceId })
29673
+ this.ncEventMedia ??= new EventMediaService({
29674
+ logger: this.ctx.logger.child("nc-event-media"),
29675
+ produce: (input) => api.streamBroker.produceEventMedia.mutate({
29676
+ deviceId: input.deviceId,
29677
+ aroundMs: input.aroundMs,
29678
+ preSeconds: input.preSeconds,
29679
+ postSeconds: input.postSeconds,
29680
+ kinds: [...input.kinds],
29681
+ gifMaxWidth: input.gifMaxWidth,
29682
+ gifFps: input.gifFps,
29683
+ speed: input.speed,
29684
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29685
+ }),
29686
+ fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? require_dist.nodePin(nodeId) : void 0),
29687
+ renderRingClip: async (input) => {
29688
+ const res = await api.streamBroker.renderPreBufferClip.mutate({
29689
+ deviceId: input.deviceId,
29690
+ aroundMs: input.aroundMs,
29691
+ format: input.format,
29692
+ preRollSec: input.preRollSec,
29693
+ postRollSec: input.postRollSec,
29694
+ maxWidth: input.maxWidth,
29695
+ fps: input.fps,
29696
+ speed: input.speed,
29697
+ ...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
29698
+ });
29699
+ const buf = Buffer.from(res.base64, "base64");
29700
+ if (buf.byteLength === 0) return null;
29701
+ const bytes = new Uint8Array(buf.byteLength);
29702
+ bytes.set(buf);
29703
+ return bytes;
29704
+ }
29309
29705
  });
29310
29706
  try {
29311
- const result = await this.ncSeamClips.renderClip({
29707
+ const media = await this.ncEventMedia.cut({
29312
29708
  deviceId: req.deviceId,
29313
29709
  aroundMs: req.aroundMs,
29314
- postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC
29710
+ wantMp4: req.wantMp4,
29711
+ wantGif: req.wantGif,
29712
+ preRollSec: req.preRollSec,
29713
+ postRollSec: req.postRollSec,
29714
+ ...req.profile !== void 0 ? { profile: req.profile } : {},
29715
+ ...req.speed !== void 0 ? { speed: req.speed } : {}
29315
29716
  });
29316
- return result.ok ? result.clip.bytes : null;
29717
+ return {
29718
+ mp4: media.mp4,
29719
+ gif: media.gif
29720
+ };
29317
29721
  } catch (err) {
29318
- log.warn("nc clip: the fMP4 seam threw — falling back to the clip ring", {
29722
+ this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
29319
29723
  tags: { deviceId: req.deviceId },
29320
29724
  meta: { error: err instanceof Error ? err.message : String(err) }
29321
29725
  });
29322
- return null;
29726
+ return {
29727
+ mp4: null,
29728
+ gif: null
29729
+ };
29323
29730
  }
29324
29731
  }
29325
29732
  /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
@@ -29824,7 +30231,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29824
30231
  },
29825
30232
  listCallerTargetIds: async (userId) => {
29826
30233
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
29827
- }
30234
+ },
30235
+ injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
29828
30236
  });
29829
30237
  }
29830
30238
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -29958,7 +30366,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
29958
30366
  let storage = this.ctx.kernel.storage;
29959
30367
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
29960
30368
  if (mediaRoot) {
29961
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CnEKzHgS.js"));
30369
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-B5brepKo.js"));
29962
30370
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
29963
30371
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
29964
30372
  }
@@ -30416,28 +30824,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
30416
30824
  y: pt.y
30417
30825
  })));
30418
30826
  },
30419
- renderFootage: async (req) => {
30420
- if (req.format === "mp4") {
30421
- const seam = await this.renderSeamClip(req);
30422
- if (seam !== null) return seam;
30423
- }
30424
- const res = await api.streamBroker.renderPreBufferClip.mutate({
30425
- deviceId: req.deviceId,
30426
- aroundMs: req.aroundMs,
30427
- format: req.format,
30428
- preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
30429
- postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
30430
- maxWidth: NC_FOOTAGE_MAX_WIDTH,
30431
- fps: NC_FOOTAGE_FPS,
30432
- ...req.speed !== void 0 ? { speed: req.speed } : {},
30433
- ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
30434
- });
30435
- const buf = Buffer.from(res.base64, "base64");
30436
- if (buf.byteLength === 0) return null;
30437
- const bytes = new Uint8Array(buf.byteLength);
30438
- bytes.set(buf);
30439
- return bytes;
30440
- },
30827
+ renderEventMedia: (req) => this.renderEventMedia(req),
30441
30828
  publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
30442
30829
  listTargets: async () => {
30443
30830
  return (await api.notificationOutput.listTargets.query({})).map((t) => {
@@ -33382,29 +33769,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
33382
33769
  });
33383
33770
  }
33384
33771
  const materializer = this.syntheticTracks;
33385
- const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
33386
- if (materializer === null || descriptor === void 0) return;
33772
+ if (materializer === null) return;
33387
33773
  try {
33388
- const cameraIds = await cache.camerasFor(data.deviceId);
33389
- if (cameraIds.length === 0) {
33390
- this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
33391
- tags: { deviceId: data.deviceId },
33774
+ await projectSensorMarkers({
33775
+ cache,
33776
+ markers: materializer,
33777
+ producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
33778
+ onProjected: (r) => this.ctx.logger.info("synthetic sensor-track projected", {
33779
+ tags: { deviceId: r.cameraId },
33392
33780
  meta: {
33393
- capName: data.capName,
33394
- kind: descriptor.kind
33781
+ capName: r.capName,
33782
+ kind: r.kind,
33783
+ trackId: r.trackId,
33784
+ sourceDeviceId: r.sourceDeviceId,
33785
+ selfCamera: r.selfCamera
33395
33786
  }
33396
- });
33397
- return;
33398
- }
33399
- const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
33400
- for (const cameraId of cameraIds) await materializer.materialize({
33401
- cameraId,
33402
- sourceDeviceId: data.deviceId,
33403
- source: "sensor",
33404
- kind: descriptor.kind,
33405
- timestamp,
33406
- ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
33407
- });
33787
+ }),
33788
+ onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
33789
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
33790
+ meta: {
33791
+ capName: r.capName,
33792
+ kind: r.kind,
33793
+ reason: r.reason,
33794
+ sourceDeviceId: r.sourceDeviceId
33795
+ }
33796
+ })
33797
+ }, data, timestamp);
33408
33798
  } catch (err) {
33409
33799
  this.ctx.logger.warn("synthetic sensor-track materialize failed", {
33410
33800
  tags: { deviceId: data.deviceId },