@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.
- package/dist/{dist-DTeVOZBt.mjs → dist-CDLLzMJw.mjs} +104 -469
- package/dist/{dist-CnSzKo7x.js → dist-k21GqaWS.js} +104 -486
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/node-B5brepKo.js +151 -0
- package/dist/node-Co0YpEFr.mjs +148 -0
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-D-6lLgLp.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C-EUVj-u.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-PkY2z2Wo.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DPOdauxs.mjs → hostInit-DJUXj7Ya.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1142 -752
- package/dist/pipeline-analytics/index.mjs +1132 -750
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/node-CnEKzHgS.js +0 -653
- package/dist/node-pIpcQ_aN.mjs +0 -649
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-JSo2V9IT.mjs +0 -26
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as boolean, A as encodeVectorBase64, B as vectorDimFromBase64, C as alarmPanelCapability, D as customAction, E as cosineSimilarity, F as notificationRulesCapability, G as DeviceType, H as zoneAnalyticsCapability, I as pipelineAnalyticsCapability, J as isDeviceScopedCap, K as createEvent, L as plateGalleryCapability, N as isScheduleActive, O as defineCustomActions, P as kebabToCamel, Q as array, R as readDeviceStateFrom, S as addonWidgetsSourceCapability, T as buildEventKindDescriptor, U as errMsg, V as videoclipsCapability, W as BaseAddon, X as sleep, Y as nodePin, Z as _enum, _ as OpsLogEntrySchema, a as LabelAttributionSchema, at as unknown, b as TimelapseRuleSchema, c as NC_TAXONOMY, d as NcRulePatchSchema, et as literal, f as NcRuleSchema, g as NcTaxonomySchema, h as NcSnoozeSuppressedSchema, i as EVENT_PAD_MS, it as string, j as faceGalleryCapability, l as NcConditionDescriptorSchema, m as NcSnoozeSchema, n as DEFAULT_EVENT_COLOR, nt as object, o as MACRO_LABELS, ot as EventCategory, p as NcSnoozeInputSchema, q as hydrateSchema, r as EVENT_KIND_BY_CAP, rt as record, s as NC_CONDITION_CATALOG, t as BaseDevice, tt as number, u as NcRuleInputSchema, v as RetrainStatusSchema, w as audioMetricsCapability, x as TrackSourceSchema, y as TimelapseRuleInputSchema, z as subKindsOf } from "../dist-CDLLzMJw.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
5
|
import sharp from "sharp";
|
|
7
6
|
//#region src/notification-center/action-token.ts
|
|
8
7
|
/**
|
|
@@ -1103,557 +1102,6 @@ function mimeFromExtension(file) {
|
|
|
1103
1102
|
return "application/octet-stream";
|
|
1104
1103
|
}
|
|
1105
1104
|
//#endregion
|
|
1106
|
-
//#region src/notification-center/fmp4-clip/clip-assembler.ts
|
|
1107
|
-
/**
|
|
1108
|
-
* Drain a subscription into one mp4.
|
|
1109
|
-
*
|
|
1110
|
-
* The subscription is ALWAYS released before this settles, on every path. It
|
|
1111
|
-
* holds an ffmpeg's fragments in a bounded queue; leaking one keeps a camera's
|
|
1112
|
-
* child alive for a notification that was sent minutes ago.
|
|
1113
|
-
*/
|
|
1114
|
-
async function assembleFmp4Clip(input) {
|
|
1115
|
-
const now = input.now ?? Date.now;
|
|
1116
|
-
const schedule = input.setTimeoutFn ?? setTimeout;
|
|
1117
|
-
const parts = [];
|
|
1118
|
-
let initBytes = 0;
|
|
1119
|
-
let fragments = 0;
|
|
1120
|
-
let totalBytes = 0;
|
|
1121
|
-
let firstFragmentAt = null;
|
|
1122
|
-
let lastFragmentAt = null;
|
|
1123
|
-
let stop = null;
|
|
1124
|
-
const iterator = input.subscription.packets()[Symbol.asyncIterator]();
|
|
1125
|
-
try {
|
|
1126
|
-
for (;;) {
|
|
1127
|
-
const remaining = input.deadlineMs - now();
|
|
1128
|
-
if (remaining <= 0) {
|
|
1129
|
-
stop = "deadline";
|
|
1130
|
-
break;
|
|
1131
|
-
}
|
|
1132
|
-
const packet = await nextWithin(iterator, remaining, schedule);
|
|
1133
|
-
if (packet === TIMED_OUT) {
|
|
1134
|
-
stop = "deadline";
|
|
1135
|
-
break;
|
|
1136
|
-
}
|
|
1137
|
-
if (packet === null) {
|
|
1138
|
-
stop = input.subscription.closedReason === "slow-consumer" ? "slow-consumer" : "producer-ended";
|
|
1139
|
-
break;
|
|
1140
|
-
}
|
|
1141
|
-
if (packet.kind === "init") {
|
|
1142
|
-
if (initBytes > 0) {
|
|
1143
|
-
stop = "producer-ended";
|
|
1144
|
-
break;
|
|
1145
|
-
}
|
|
1146
|
-
initBytes = packet.data.length;
|
|
1147
|
-
totalBytes += packet.data.length;
|
|
1148
|
-
parts.push(packet.data);
|
|
1149
|
-
continue;
|
|
1150
|
-
}
|
|
1151
|
-
if (initBytes === 0) return {
|
|
1152
|
-
ok: false,
|
|
1153
|
-
refusal: "no-init"
|
|
1154
|
-
};
|
|
1155
|
-
const arrivedAt = now();
|
|
1156
|
-
if (totalBytes + packet.data.length > input.maxBytes) {
|
|
1157
|
-
stop = "byte-cap";
|
|
1158
|
-
break;
|
|
1159
|
-
}
|
|
1160
|
-
parts.push(packet.data);
|
|
1161
|
-
totalBytes += packet.data.length;
|
|
1162
|
-
fragments += 1;
|
|
1163
|
-
if (firstFragmentAt === null) firstFragmentAt = arrivedAt;
|
|
1164
|
-
lastFragmentAt = arrivedAt;
|
|
1165
|
-
if (packet.isLast) {
|
|
1166
|
-
stop = "producer-ended";
|
|
1167
|
-
break;
|
|
1168
|
-
}
|
|
1169
|
-
if (arrivedAt >= input.collectUntilMs) {
|
|
1170
|
-
stop = "window";
|
|
1171
|
-
break;
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
} finally {
|
|
1175
|
-
input.subscription.release();
|
|
1176
|
-
}
|
|
1177
|
-
if (initBytes === 0) return {
|
|
1178
|
-
ok: false,
|
|
1179
|
-
refusal: "no-init"
|
|
1180
|
-
};
|
|
1181
|
-
if (fragments === 0) return {
|
|
1182
|
-
ok: false,
|
|
1183
|
-
refusal: "no-fragments"
|
|
1184
|
-
};
|
|
1185
|
-
const stoppedBecause = stop ?? "producer-ended";
|
|
1186
|
-
return {
|
|
1187
|
-
ok: true,
|
|
1188
|
-
clip: {
|
|
1189
|
-
bytes: concat(parts, totalBytes),
|
|
1190
|
-
initBytes,
|
|
1191
|
-
fragments,
|
|
1192
|
-
arrivalSpanMs: firstFragmentAt === null || lastFragmentAt === null ? 0 : lastFragmentAt - firstFragmentAt,
|
|
1193
|
-
stoppedBecause,
|
|
1194
|
-
truncated: stoppedBecause !== "window"
|
|
1195
|
-
}
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
/** One line for the log — a refused clip that says nothing reads as a bug. */
|
|
1199
|
-
function clipAssemblyRefusalReason(refusal) {
|
|
1200
|
-
switch (refusal) {
|
|
1201
|
-
case "no-init": return "the first packet was a fragment, not an initialisation segment — the bytes would not decode";
|
|
1202
|
-
case "no-fragments": return "the initialisation segment arrived but no media fragment did";
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
var TIMED_OUT = Symbol("fmp4-clip-timeout");
|
|
1206
|
-
/** `setTimeout`'s 32-bit ceiling. Above it Node fires after 1 ms. */
|
|
1207
|
-
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
1208
|
-
/**
|
|
1209
|
-
* The next packet, `null` when the stream completed, or {@link TIMED_OUT}.
|
|
1210
|
-
*
|
|
1211
|
-
* The plane's generator parks on a promise that only a publish resolves, so a
|
|
1212
|
-
* camera that goes silent mid-clip parks it forever. Racing a timer is the only
|
|
1213
|
-
* way out — and the abandoned `next()` is harmless because the caller releases
|
|
1214
|
-
* the subscription immediately afterwards.
|
|
1215
|
-
*/
|
|
1216
|
-
async function nextWithin(iterator, withinMs, schedule) {
|
|
1217
|
-
let timer = null;
|
|
1218
|
-
const timeout = new Promise((resolve) => {
|
|
1219
|
-
timer = schedule(() => resolve(TIMED_OUT), Math.min(withinMs, MAX_TIMER_MS));
|
|
1220
|
-
timer.unref?.();
|
|
1221
|
-
});
|
|
1222
|
-
try {
|
|
1223
|
-
return await Promise.race([iterator.next().then((r) => r.done === true ? null : r.value), timeout]);
|
|
1224
|
-
} finally {
|
|
1225
|
-
if (timer !== null) clearTimeout(timer);
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
function concat(parts, total) {
|
|
1229
|
-
const out = new Uint8Array(total);
|
|
1230
|
-
let offset = 0;
|
|
1231
|
-
for (const part of parts) {
|
|
1232
|
-
out.set(part, offset);
|
|
1233
|
-
offset += part.length;
|
|
1234
|
-
}
|
|
1235
|
-
return out;
|
|
1236
|
-
}
|
|
1237
|
-
//#endregion
|
|
1238
|
-
//#region src/notification-center/fmp4-clip/clip-source-pick.ts
|
|
1239
|
-
/**
|
|
1240
|
-
* The tallest frame a notification clip is cut from.
|
|
1241
|
-
*
|
|
1242
|
-
* 1080 rather than 720: the attachment is native-resolution H.264 that nothing
|
|
1243
|
-
* re-encodes, and the operator asked for a real clip. A 4-second 1080p copy
|
|
1244
|
-
* fragment measured ~500 KB on this fleet against ~6.35 MB at 4K, which is the
|
|
1245
|
-
* difference between "every backend takes it" and "pushover drops it".
|
|
1246
|
-
*/
|
|
1247
|
-
var CLIP_MAX_HEIGHT = 1080;
|
|
1248
|
-
/** The seam copies; it never transcodes. That means H.264 and only H.264. */
|
|
1249
|
-
var COPYABLE_CODEC = "h264";
|
|
1250
|
-
function pickClipSource(entries) {
|
|
1251
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
1252
|
-
if (enabled.length === 0) return {
|
|
1253
|
-
ok: false,
|
|
1254
|
-
refusal: "no-enabled-stream"
|
|
1255
|
-
};
|
|
1256
|
-
const copyable = enabled.filter((e) => normaliseCodec(e.codec) === COPYABLE_CODEC);
|
|
1257
|
-
if (copyable.length === 0) return {
|
|
1258
|
-
ok: false,
|
|
1259
|
-
refusal: "no-h264-stream"
|
|
1260
|
-
};
|
|
1261
|
-
const sized = copyable.filter(hasUsableResolution);
|
|
1262
|
-
if (sized.length === 0) return {
|
|
1263
|
-
ok: false,
|
|
1264
|
-
refusal: "no-resolution"
|
|
1265
|
-
};
|
|
1266
|
-
const withinCeiling = sized.filter((e) => e.resolution.height <= CLIP_MAX_HEIGHT);
|
|
1267
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? b.resolution.height - a.resolution.height : a.resolution.height - b.resolution.height)[0];
|
|
1268
|
-
if (best === void 0) return {
|
|
1269
|
-
ok: false,
|
|
1270
|
-
refusal: "no-resolution"
|
|
1271
|
-
};
|
|
1272
|
-
return {
|
|
1273
|
-
ok: true,
|
|
1274
|
-
source: {
|
|
1275
|
-
brokerId: best.brokerId,
|
|
1276
|
-
profile: best.profile ?? best.brokerId,
|
|
1277
|
-
url: best.url,
|
|
1278
|
-
width: best.resolution.width,
|
|
1279
|
-
height: best.resolution.height
|
|
1280
|
-
}
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
/** One line for the log — a withheld clip that says nothing reads as a bug. */
|
|
1284
|
-
function clipRefusalReason(refusal) {
|
|
1285
|
-
switch (refusal) {
|
|
1286
|
-
case "no-enabled-stream": return "the camera has no enabled profile slot to dial";
|
|
1287
|
-
case "no-h264-stream": return "every enabled profile is H.265 — the seam copies and never transcodes, so the clip ring serves this camera";
|
|
1288
|
-
case "no-resolution": return "no enabled profile declares a resolution, so no slot could be sized";
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
function hasUsableResolution(entry) {
|
|
1292
|
-
const r = entry.resolution;
|
|
1293
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
1294
|
-
}
|
|
1295
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
1296
|
-
function normaliseCodec(codec) {
|
|
1297
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
1298
|
-
}
|
|
1299
|
-
//#endregion
|
|
1300
|
-
//#region src/notification-center/fmp4-clip/fmp4-clip-service.ts
|
|
1301
|
-
/**
|
|
1302
|
-
* Cut a notification clip from the LIVE fMP4 fragment seam — roadmap 4b, the
|
|
1303
|
-
* second consumer [D80](../../../../../../docs/decisions/adr-0080.md) named and
|
|
1304
|
-
* deliberately did not build.
|
|
1305
|
-
*
|
|
1306
|
-
* ## What changes for the operator
|
|
1307
|
-
*
|
|
1308
|
-
* The clip ring path (`streamBroker.renderPreBufferClip`, D32) re-encodes to
|
|
1309
|
-
* 480 px wide, 5 fps, 2× speed, `-an`. That is a thumbnail with motion. This
|
|
1310
|
-
* one is `-c:v copy`: the camera's own H.264 at its own resolution and frame
|
|
1311
|
-
* rate, real time, with in-band AAC — a clip you can actually watch.
|
|
1312
|
-
*
|
|
1313
|
-
* ## Where the consumer lives, and why it is HERE
|
|
1314
|
-
*
|
|
1315
|
-
* [D84](../../../../../../docs/decisions/adr-0084.md) established the answer
|
|
1316
|
-
* the hard way: a `Fmp4FragmentPlane` is a live object, so it cannot cross a
|
|
1317
|
-
* process boundary and no capability can carry one. The first consumer — the
|
|
1318
|
-
* HKSV delegate — is `hub-only` in its own runner and therefore spawns its OWN
|
|
1319
|
-
* child against the broker's restream, taking the transport from
|
|
1320
|
-
* `@camstack/types/node`. The notification centre is in yet another runner
|
|
1321
|
-
* (`addon-post-analysis`), so it does exactly the same thing. The alternative,
|
|
1322
|
-
* a `streamBroker.renderFragmentClip` cap method, is a `*.cap.ts` change plus
|
|
1323
|
-
* codegen plus a `@camstack/system` train to move bytes that are already
|
|
1324
|
-
* reachable over RTSP.
|
|
1325
|
-
*
|
|
1326
|
-
* ## ON-DEMAND, and what that costs — read this before "fixing" the pre-roll
|
|
1327
|
-
*
|
|
1328
|
-
* HKSV's prebuffer exists because its ffmpeg never stops. That is affordable
|
|
1329
|
-
* (measured: 0.7 % of a core, 30 MB RSS on the copy branch) but it is still a
|
|
1330
|
-
* PERMANENT child per camera, and a notification clip does not justify one on
|
|
1331
|
-
* every camera that has a rule. So this child is spawned at the event and
|
|
1332
|
-
* killed when the clip is assembled.
|
|
1333
|
-
*
|
|
1334
|
-
* **This path has NO pre-roll, and the guess that it might have had one was
|
|
1335
|
-
* wrong.** `RtspRestreamer` bursts the current GOP to a joining session, so it
|
|
1336
|
-
* looked as though the first fragment would open before we asked for it.
|
|
1337
|
-
* Measured against 615's 720p slot from outside the hub — the exact argv, the
|
|
1338
|
-
* real child, the real plane:
|
|
1339
|
-
*
|
|
1340
|
-
* | dial → init | fragments | media duration | wall clock | bytes |
|
|
1341
|
-
* | --- | --- | --- | --- | --- |
|
|
1342
|
-
* | 4479 ms | 3 × ~141 KB | 12.03 s | 12.42 s | 425 KB |
|
|
1343
|
-
*
|
|
1344
|
-
* Media duration and wall clock differ by 387 ms — the dial. Nothing from
|
|
1345
|
-
* before the spawn survives into the file: ffmpeg discards up to the first key
|
|
1346
|
-
* frame it can open on, which is also why the first fragment costs one full
|
|
1347
|
-
* key-frame interval. So `preRollSec` is NOT honoured here; the fallback's is.
|
|
1348
|
-
*
|
|
1349
|
-
* A real pre-roll needs a plane that was already running. The seam is ready for
|
|
1350
|
-
* one — the assembler takes any `Fmp4Subscription`, including a prebuffered one
|
|
1351
|
-
* — and it is queued rather than built, because a warm child per
|
|
1352
|
-
* camera-with-a-clip-rule is a new permanent cost and that is an operator
|
|
1353
|
-
* decision, not an implementation one.
|
|
1354
|
-
*
|
|
1355
|
-
* ## Fail-closed, and always with the reason
|
|
1356
|
-
*
|
|
1357
|
-
* Every refusal returns a code AND a sentence, and the caller falls back to the
|
|
1358
|
-
* clip ring. An H.265 camera, a broker on another node, an ffmpeg that will not
|
|
1359
|
-
* start: each is a `debug`/`warn` line carrying `tags: { deviceId }`, because a
|
|
1360
|
-
* clip that silently changed shape per camera is exactly the thing nobody can
|
|
1361
|
-
* diagnose from an attachment.
|
|
1362
|
-
*/
|
|
1363
|
-
/**
|
|
1364
|
-
* The fragment length asked of the muxer.
|
|
1365
|
-
*
|
|
1366
|
-
* Deliberately SHORT. `-min_frag_duration` can only ever DELAY a cut past a key
|
|
1367
|
-
* frame (D80), so asking for 1 s against a 4 s key-frame grid yields exactly the
|
|
1368
|
-
* source cadence — the finest granularity the stream can give. Asking for the
|
|
1369
|
-
* grid's own length is the D84 bug: the deadline lands on the same instant as
|
|
1370
|
-
* the key frame, loses, and DOUBLES every fragment.
|
|
1371
|
-
*/
|
|
1372
|
-
var CLIP_FRAGMENT_MS = 1e3;
|
|
1373
|
-
/**
|
|
1374
|
-
* How long the child may produce nothing before it is declared dead.
|
|
1375
|
-
*
|
|
1376
|
-
* Shorter than the transport's 12 s default on purpose: this runs inside the
|
|
1377
|
-
* notification drain, and the commonest cause of a stall here is a restream url
|
|
1378
|
-
* that is not reachable from THIS runner (the broker for that camera lives on
|
|
1379
|
-
* another node and its url is loopback). Paying 12 s per notification for that
|
|
1380
|
-
* is worse than falling back sooner.
|
|
1381
|
-
*
|
|
1382
|
-
* NOT shorter than 9 s, and that bound is measured rather than chosen: the init
|
|
1383
|
-
* segment cannot be written before ffmpeg has a key frame to open on, and 615
|
|
1384
|
-
* took **4479 ms** over the LAN on a 4.0 s grid. A camera on an 8 s grid needs
|
|
1385
|
-
* roughly twice that, so anything under ~9 s would declare healthy cameras dead
|
|
1386
|
-
* and cool them down for ten minutes.
|
|
1387
|
-
*/
|
|
1388
|
-
var CLIP_FIRST_UNIT_TIMEOUT_MS = 9e3;
|
|
1389
|
-
/** Slack past the requested window for one more key-frame interval to land. */
|
|
1390
|
-
var CLIP_CADENCE_SLACK_MS = 1e4;
|
|
1391
|
-
/** The whole attempt, spawn included. A notification is not worth more. */
|
|
1392
|
-
var CLIP_TOTAL_BUDGET_MS = 25e3;
|
|
1393
|
-
/**
|
|
1394
|
-
* Ceiling on the assembled file.
|
|
1395
|
-
*
|
|
1396
|
-
* The degrade engine DROPS an over-`maxBytes` attachment rather than degrading
|
|
1397
|
-
* it to a link, and the SMALLEST cap declared today is pushover's 5 MiB. So the
|
|
1398
|
-
* cap is that, not something roomier: a clip truncated to four seconds and
|
|
1399
|
-
* delivered beats a whole one the engine throws away, and the truncation is
|
|
1400
|
-
* marked either way. A 1080p copy clip of a few seconds is 1-2 MB, so this only
|
|
1401
|
-
* ever bites a 4K-only camera.
|
|
1402
|
-
*/
|
|
1403
|
-
var CLIP_MAX_BYTES = 5 * 1024 * 1024;
|
|
1404
|
-
/**
|
|
1405
|
-
* How long a camera is left alone after the seam failed to start on it.
|
|
1406
|
-
*
|
|
1407
|
-
* Without this, a camera whose broker is on another node pays
|
|
1408
|
-
* {@link CLIP_FIRST_UNIT_TIMEOUT_MS} on EVERY notification, forever. The
|
|
1409
|
-
* cooldown is announced once and the suppressed attempts are counted into the
|
|
1410
|
-
* next line, the same shape the NC's own rate-limited lines use.
|
|
1411
|
-
*/
|
|
1412
|
-
var CLIP_FAILURE_COOLDOWN_MS = 10 * 6e4;
|
|
1413
|
-
/** ffmpeg on `PATH`, as every other spawn in this repo resolves it. */
|
|
1414
|
-
var FFMPEG_BINARY = "ffmpeg";
|
|
1415
|
-
var Fmp4ClipService = class {
|
|
1416
|
-
deps;
|
|
1417
|
-
cooldowns = /* @__PURE__ */ new Map();
|
|
1418
|
-
inFlight = /* @__PURE__ */ new Set();
|
|
1419
|
-
transport = null;
|
|
1420
|
-
transportProbed = false;
|
|
1421
|
-
constructor(deps) {
|
|
1422
|
-
this.deps = deps;
|
|
1423
|
-
}
|
|
1424
|
-
/**
|
|
1425
|
-
* Cut one clip. Never throws — every failure is a `{ ok: false }` with a
|
|
1426
|
-
* code and a sentence, because the caller's only correct response is to fall
|
|
1427
|
-
* back to the clip ring.
|
|
1428
|
-
*
|
|
1429
|
-
* The DECLINE IS LOGGED HERE and not at the call site, so there is exactly one
|
|
1430
|
-
* place that can forget to. Two cameras silently attaching two different kinds
|
|
1431
|
-
* of clip is precisely what an operator cannot diagnose from a phone, and the
|
|
1432
|
-
* question is always per-camera — hence `tags: { deviceId }` on every line.
|
|
1433
|
-
*/
|
|
1434
|
-
async renderClip(request) {
|
|
1435
|
-
const result = await this.attempt(request);
|
|
1436
|
-
if (!result.ok) this.deps.logger.debug("nc clip: the fMP4 seam declined — the clip ring serves this one", {
|
|
1437
|
-
tags: { deviceId: request.deviceId },
|
|
1438
|
-
meta: {
|
|
1439
|
-
code: result.code,
|
|
1440
|
-
reason: result.reason
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1443
|
-
return result;
|
|
1444
|
-
}
|
|
1445
|
-
async attempt(request) {
|
|
1446
|
-
const now = this.deps.now ?? Date.now;
|
|
1447
|
-
const log = this.deps.logger;
|
|
1448
|
-
const deviceId = request.deviceId;
|
|
1449
|
-
const cooling = this.cooldowns.get(deviceId);
|
|
1450
|
-
if (cooling !== void 0 && cooling.until > now()) {
|
|
1451
|
-
cooling.suppressed += 1;
|
|
1452
|
-
return {
|
|
1453
|
-
ok: false,
|
|
1454
|
-
code: "cooldown",
|
|
1455
|
-
reason: `the seam failed to start on this camera and is suppressed for another ${Math.round((cooling.until - now()) / 1e3)}s`
|
|
1456
|
-
};
|
|
1457
|
-
}
|
|
1458
|
-
if (this.inFlight.has(deviceId)) return {
|
|
1459
|
-
ok: false,
|
|
1460
|
-
code: "busy",
|
|
1461
|
-
reason: "a seam clip for this camera is already being cut"
|
|
1462
|
-
};
|
|
1463
|
-
const transport = await this.loadTransport();
|
|
1464
|
-
if (transport === null) return {
|
|
1465
|
-
ok: false,
|
|
1466
|
-
code: "transport-missing",
|
|
1467
|
-
reason: "this host has no fMP4 fragment transport — @camstack/types predates it"
|
|
1468
|
-
};
|
|
1469
|
-
const entries = await this.readEntries(deviceId);
|
|
1470
|
-
if (entries === null || entries.length === 0) return {
|
|
1471
|
-
ok: false,
|
|
1472
|
-
code: "no-entries",
|
|
1473
|
-
reason: "the camera reported no profile RTSP entries"
|
|
1474
|
-
};
|
|
1475
|
-
const choice = pickClipSource(entries);
|
|
1476
|
-
if (!choice.ok) return {
|
|
1477
|
-
ok: false,
|
|
1478
|
-
code: choice.refusal,
|
|
1479
|
-
reason: clipRefusalReason(choice.refusal)
|
|
1480
|
-
};
|
|
1481
|
-
const source = choice.source;
|
|
1482
|
-
this.inFlight.add(deviceId);
|
|
1483
|
-
const plane = new transport.Fmp4FragmentPlane(log.child("nc-clip-plane"));
|
|
1484
|
-
const child = new transport.Fmp4FragmentChild({
|
|
1485
|
-
logger: log.child("nc-clip-fmp4"),
|
|
1486
|
-
ffmpegBinaryPath: this.deps.ffmpegBinaryPath ?? FFMPEG_BINARY,
|
|
1487
|
-
spawnFn: this.deps.spawnFn ?? spawn,
|
|
1488
|
-
firstUnitTimeoutMs: CLIP_FIRST_UNIT_TIMEOUT_MS
|
|
1489
|
-
}, {
|
|
1490
|
-
sourceId: `nc-clip/${deviceId}`,
|
|
1491
|
-
deviceId,
|
|
1492
|
-
fragmentMs: CLIP_FRAGMENT_MS,
|
|
1493
|
-
invocation: buildClipInvocation(source.url),
|
|
1494
|
-
plane
|
|
1495
|
-
});
|
|
1496
|
-
const startedAt = now();
|
|
1497
|
-
try {
|
|
1498
|
-
await child.start();
|
|
1499
|
-
} catch (err) {
|
|
1500
|
-
this.noteStartFailure(deviceId, err);
|
|
1501
|
-
await child.stop().catch(() => void 0);
|
|
1502
|
-
plane.dispose();
|
|
1503
|
-
this.inFlight.delete(deviceId);
|
|
1504
|
-
return {
|
|
1505
|
-
ok: false,
|
|
1506
|
-
code: "child-failed",
|
|
1507
|
-
reason: `ffmpeg produced no fMP4 initialisation segment: ${errText(err)}`
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
this.clearCooldown(deviceId);
|
|
1511
|
-
const subscribedAt = now();
|
|
1512
|
-
const postRollMs = Math.max(0, request.postRollSec) * 1e3;
|
|
1513
|
-
const collectUntilMs = Math.max(request.aroundMs, subscribedAt) + postRollMs;
|
|
1514
|
-
const budgetEnd = startedAt + CLIP_TOTAL_BUDGET_MS;
|
|
1515
|
-
const deadlineMs = Math.min(collectUntilMs + CLIP_CADENCE_SLACK_MS, budgetEnd);
|
|
1516
|
-
try {
|
|
1517
|
-
const outcome = await assembleFmp4Clip({
|
|
1518
|
-
subscription: plane.subscribe({ tag: `nc-clip/${deviceId}` }),
|
|
1519
|
-
collectUntilMs,
|
|
1520
|
-
deadlineMs,
|
|
1521
|
-
maxBytes: CLIP_MAX_BYTES,
|
|
1522
|
-
...this.deps.now !== void 0 ? { now: this.deps.now } : {},
|
|
1523
|
-
...this.deps.setTimeoutFn !== void 0 ? { setTimeoutFn: this.deps.setTimeoutFn } : {}
|
|
1524
|
-
});
|
|
1525
|
-
if (!outcome.ok) {
|
|
1526
|
-
log.warn("nc clip: the fragment seam produced nothing usable — falling back to the clip ring", {
|
|
1527
|
-
tags: { deviceId },
|
|
1528
|
-
meta: {
|
|
1529
|
-
refusal: outcome.refusal,
|
|
1530
|
-
reason: clipAssemblyRefusalReason(outcome.refusal),
|
|
1531
|
-
brokerId: source.brokerId
|
|
1532
|
-
}
|
|
1533
|
-
});
|
|
1534
|
-
return {
|
|
1535
|
-
ok: false,
|
|
1536
|
-
code: outcome.refusal,
|
|
1537
|
-
reason: clipAssemblyRefusalReason(outcome.refusal)
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
const clip = outcome.clip;
|
|
1541
|
-
log.info("nc clip: cut from the fMP4 seam", {
|
|
1542
|
-
tags: { deviceId },
|
|
1543
|
-
meta: {
|
|
1544
|
-
brokerId: source.brokerId,
|
|
1545
|
-
profile: source.profile,
|
|
1546
|
-
resolution: `${source.width}x${source.height}`,
|
|
1547
|
-
fragments: clip.fragments,
|
|
1548
|
-
bytes: clip.bytes.length,
|
|
1549
|
-
arrivalSpanMs: clip.arrivalSpanMs,
|
|
1550
|
-
truncated: clip.truncated,
|
|
1551
|
-
stoppedBecause: clip.stoppedBecause,
|
|
1552
|
-
dialLatencyMs: subscribedAt - startedAt,
|
|
1553
|
-
eventToDialMs: subscribedAt - request.aroundMs
|
|
1554
|
-
}
|
|
1555
|
-
});
|
|
1556
|
-
return {
|
|
1557
|
-
ok: true,
|
|
1558
|
-
clip,
|
|
1559
|
-
source
|
|
1560
|
-
};
|
|
1561
|
-
} finally {
|
|
1562
|
-
await child.stop().catch(() => void 0);
|
|
1563
|
-
plane.dispose();
|
|
1564
|
-
this.inFlight.delete(deviceId);
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
async loadTransport() {
|
|
1568
|
-
if (this.transportProbed) return this.transport;
|
|
1569
|
-
this.transportProbed = true;
|
|
1570
|
-
const load = this.deps.loadTransport ?? defaultLoadTransport;
|
|
1571
|
-
try {
|
|
1572
|
-
this.transport = await load();
|
|
1573
|
-
} catch (err) {
|
|
1574
|
-
this.transport = null;
|
|
1575
|
-
this.deps.logger.warn("nc clip: this host carries no fMP4 fragment transport — every clip falls back to the ring", { meta: { error: errText(err) } });
|
|
1576
|
-
}
|
|
1577
|
-
return this.transport;
|
|
1578
|
-
}
|
|
1579
|
-
async readEntries(deviceId) {
|
|
1580
|
-
try {
|
|
1581
|
-
return await this.deps.listProfileEntries(deviceId);
|
|
1582
|
-
} catch (err) {
|
|
1583
|
-
this.deps.logger.debug("nc clip: could not read the camera profile entries", {
|
|
1584
|
-
tags: { deviceId },
|
|
1585
|
-
meta: { error: errText(err) }
|
|
1586
|
-
});
|
|
1587
|
-
return null;
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
noteStartFailure(deviceId, err) {
|
|
1591
|
-
const now = this.deps.now ?? Date.now;
|
|
1592
|
-
this.cooldowns.set(deviceId, {
|
|
1593
|
-
until: now() + CLIP_FAILURE_COOLDOWN_MS,
|
|
1594
|
-
suppressed: 0
|
|
1595
|
-
});
|
|
1596
|
-
this.deps.logger.warn("nc clip: the fMP4 child would not start — this camera falls back to the clip ring for now", {
|
|
1597
|
-
tags: { deviceId },
|
|
1598
|
-
meta: {
|
|
1599
|
-
error: errText(err),
|
|
1600
|
-
cooldownMs: CLIP_FAILURE_COOLDOWN_MS
|
|
1601
|
-
}
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
clearCooldown(deviceId) {
|
|
1605
|
-
const previous = this.cooldowns.get(deviceId);
|
|
1606
|
-
if (previous === void 0) return;
|
|
1607
|
-
this.cooldowns.delete(deviceId);
|
|
1608
|
-
this.deps.logger.info("nc clip: the fMP4 seam recovered on this camera", {
|
|
1609
|
-
tags: { deviceId },
|
|
1610
|
-
meta: { suppressedAttempts: previous.suppressed }
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
};
|
|
1614
|
-
/**
|
|
1615
|
-
* The invocation, minus the sink the child owns.
|
|
1616
|
-
*
|
|
1617
|
-
* `copy` is the entire point (see the class docblock) and the audio IS encoded,
|
|
1618
|
-
* because camera microphones are G.711/PCM and an mp4 attachment has to carry
|
|
1619
|
-
* AAC to play anywhere. `-c:a aac` with no audio stream in the source is a
|
|
1620
|
-
* no-op in ffmpeg's default stream selection, so a silent camera needs no
|
|
1621
|
-
* second branch.
|
|
1622
|
-
*/
|
|
1623
|
-
function buildClipInvocation(url) {
|
|
1624
|
-
return {
|
|
1625
|
-
logLevel: "error",
|
|
1626
|
-
decodeHwAccel: null,
|
|
1627
|
-
input: {
|
|
1628
|
-
url,
|
|
1629
|
-
rtspTransport: "tcp",
|
|
1630
|
-
analyzeDurationUs: 1e6,
|
|
1631
|
-
probeSizeBytes: 1e6
|
|
1632
|
-
},
|
|
1633
|
-
video: { kind: "copy" },
|
|
1634
|
-
audio: {
|
|
1635
|
-
kind: "encode",
|
|
1636
|
-
codec: "aac",
|
|
1637
|
-
bitrateKbps: 64,
|
|
1638
|
-
sampleRateHz: 24e3,
|
|
1639
|
-
channels: 1
|
|
1640
|
-
},
|
|
1641
|
-
threadCount: 0,
|
|
1642
|
-
outputArgs: []
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
async function defaultLoadTransport() {
|
|
1646
|
-
const mod = await import("../node-pIpcQ_aN.mjs");
|
|
1647
|
-
if (typeof mod.Fmp4FragmentChild !== "function" || typeof mod.Fmp4FragmentPlane !== "function") return null;
|
|
1648
|
-
return {
|
|
1649
|
-
Fmp4FragmentChild: mod.Fmp4FragmentChild,
|
|
1650
|
-
Fmp4FragmentPlane: mod.Fmp4FragmentPlane
|
|
1651
|
-
};
|
|
1652
|
-
}
|
|
1653
|
-
function errText(err) {
|
|
1654
|
-
return err instanceof Error ? err.message : String(err);
|
|
1655
|
-
}
|
|
1656
|
-
//#endregion
|
|
1657
1105
|
//#region src/pipeline-analytics/pipeline/label-utils.ts
|
|
1658
1106
|
/**
|
|
1659
1107
|
* Every macro class the pipeline can carry on a detection. A `label` exists to
|
|
@@ -2556,20 +2004,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
2556
2004
|
//#endregion
|
|
2557
2005
|
//#region src/notification-center/dispatcher.ts
|
|
2558
2006
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
2559
|
-
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
2560
|
-
var FOOTAGE_FORMATS = [{
|
|
2561
|
-
flag: "mediaGif",
|
|
2562
|
-
format: "gif",
|
|
2563
|
-
mediaType: "gif",
|
|
2564
|
-
mime: "image/gif",
|
|
2565
|
-
name: "event.gif"
|
|
2566
|
-
}, {
|
|
2567
|
-
flag: "mediaClip",
|
|
2568
|
-
format: "mp4",
|
|
2569
|
-
mediaType: "video",
|
|
2570
|
-
mime: "video/mp4",
|
|
2571
|
-
name: "event.mp4"
|
|
2572
|
-
}];
|
|
2573
2007
|
var NcDispatcher = class {
|
|
2574
2008
|
deps;
|
|
2575
2009
|
targetCache = null;
|
|
@@ -2953,31 +2387,33 @@ var NcDispatcher = class {
|
|
|
2953
2387
|
} : still);
|
|
2954
2388
|
} else out.push(still);
|
|
2955
2389
|
}
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
}
|
|
2390
|
+
const wantGif = entry.payload.mediaGif === true;
|
|
2391
|
+
const wantMp4 = entry.payload.mediaClip === true;
|
|
2392
|
+
if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
|
|
2393
|
+
const media = await this.deps.renderEventMedia({
|
|
2394
|
+
deviceId: entry.payload.subject.deviceId,
|
|
2395
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
2396
|
+
wantGif,
|
|
2397
|
+
wantMp4,
|
|
2398
|
+
preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
|
|
2399
|
+
postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
|
|
2400
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
|
|
2401
|
+
...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
|
|
2402
|
+
});
|
|
2403
|
+
if (media.gif !== null && media.gif.byteLength > 0) out.push({
|
|
2404
|
+
mediaType: "gif",
|
|
2405
|
+
bytes: copyBytes(media.gif),
|
|
2406
|
+
mime: "image/gif",
|
|
2407
|
+
name: "event.gif"
|
|
2408
|
+
});
|
|
2409
|
+
if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
|
|
2410
|
+
mediaType: "video",
|
|
2411
|
+
bytes: copyBytes(media.mp4),
|
|
2412
|
+
mime: "video/mp4",
|
|
2413
|
+
name: "event.mp4"
|
|
2414
|
+
});
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
|
|
2981
2417
|
}
|
|
2982
2418
|
return out;
|
|
2983
2419
|
}
|
|
@@ -3146,6 +2582,18 @@ function paramNumber(params, key) {
|
|
|
3146
2582
|
function clampPriority(priority) {
|
|
3147
2583
|
return Math.max(1, Math.min(5, Math.round(priority)));
|
|
3148
2584
|
}
|
|
2585
|
+
/**
|
|
2586
|
+
* A copy backed by its own `ArrayBuffer`.
|
|
2587
|
+
*
|
|
2588
|
+
* The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
|
|
2589
|
+
* by an assembler may sit on a shared buffer. Copying is cheap next to the
|
|
2590
|
+
* encode that produced it and removes a whole class of aliasing question.
|
|
2591
|
+
*/
|
|
2592
|
+
function copyBytes(bytes) {
|
|
2593
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
2594
|
+
out.set(bytes);
|
|
2595
|
+
return out;
|
|
2596
|
+
}
|
|
3149
2597
|
//#endregion
|
|
3150
2598
|
//#region src/notification-center/occupancy-watcher.ts
|
|
3151
2599
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
@@ -4002,6 +3450,406 @@ function rowToEntry$1(id, data) {
|
|
|
4002
3450
|
};
|
|
4003
3451
|
}
|
|
4004
3452
|
//#endregion
|
|
3453
|
+
//#region src/notification-center/event-intake.ts
|
|
3454
|
+
/** Object-event insert (movement-state transitions + zone crossings). */
|
|
3455
|
+
function incomingFromObjectEvent(event) {
|
|
3456
|
+
return {
|
|
3457
|
+
subject: subjectFromObjectEvent(event),
|
|
3458
|
+
kind: "object-event",
|
|
3459
|
+
origin: "pipeline",
|
|
3460
|
+
log: () => ({
|
|
3461
|
+
tags: { deviceId: event.deviceId },
|
|
3462
|
+
meta: { eventId: event.id }
|
|
3463
|
+
})
|
|
3464
|
+
};
|
|
3465
|
+
}
|
|
3466
|
+
/** `TrackCloser.closeExpired` — the ONE close point. */
|
|
3467
|
+
function incomingFromTrack(track, info) {
|
|
3468
|
+
return {
|
|
3469
|
+
subject: subjectFromTrack(track, info),
|
|
3470
|
+
kind: "track-end",
|
|
3471
|
+
origin: "pipeline",
|
|
3472
|
+
log: () => ({
|
|
3473
|
+
tags: { deviceId: track.deviceId },
|
|
3474
|
+
meta: { trackId: track.trackId }
|
|
3475
|
+
})
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
/** Sensor-event persist (one row per linked camera). */
|
|
3479
|
+
function incomingFromSensorEvent(event) {
|
|
3480
|
+
return {
|
|
3481
|
+
subject: subjectFromSensorEvent(event),
|
|
3482
|
+
kind: "device-event",
|
|
3483
|
+
origin: "sensor",
|
|
3484
|
+
log: () => ({
|
|
3485
|
+
tags: { deviceId: event.deviceId },
|
|
3486
|
+
meta: {
|
|
3487
|
+
sensorEventId: event.id,
|
|
3488
|
+
kind: event.kind
|
|
3489
|
+
}
|
|
3490
|
+
})
|
|
3491
|
+
};
|
|
3492
|
+
}
|
|
3493
|
+
/** Audio-event persist (classified episode). */
|
|
3494
|
+
function incomingFromAudioEvent(event) {
|
|
3495
|
+
return {
|
|
3496
|
+
subject: subjectFromAudioEvent(event),
|
|
3497
|
+
kind: "audio-event",
|
|
3498
|
+
origin: "pipeline",
|
|
3499
|
+
log: () => ({
|
|
3500
|
+
tags: { deviceId: event.deviceId },
|
|
3501
|
+
meta: {
|
|
3502
|
+
audioEventId: event.id,
|
|
3503
|
+
class: event.classification?.className
|
|
3504
|
+
}
|
|
3505
|
+
})
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
/** Package object-event persist (delivered / picked-up). */
|
|
3509
|
+
function incomingFromPackageEvent(event, phase) {
|
|
3510
|
+
return {
|
|
3511
|
+
subject: subjectFromPackageEvent(event, phase),
|
|
3512
|
+
kind: "package-event",
|
|
3513
|
+
origin: "pipeline",
|
|
3514
|
+
log: () => ({
|
|
3515
|
+
tags: { deviceId: event.deviceId },
|
|
3516
|
+
meta: {
|
|
3517
|
+
eventId: event.id,
|
|
3518
|
+
phase
|
|
3519
|
+
}
|
|
3520
|
+
})
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
/** A committed ZoneAnalytics occupancy edge. */
|
|
3524
|
+
function incomingFromOccupancyEdge(edge) {
|
|
3525
|
+
return {
|
|
3526
|
+
subject: subjectFromOccupancyEvent(edge),
|
|
3527
|
+
kind: "occupancy-event",
|
|
3528
|
+
origin: "pipeline",
|
|
3529
|
+
log: () => ({
|
|
3530
|
+
tags: { deviceId: edge.deviceId },
|
|
3531
|
+
meta: {
|
|
3532
|
+
zoneId: edge.zoneId ?? "@frame",
|
|
3533
|
+
className: edge.className ?? "@all",
|
|
3534
|
+
count: edge.count,
|
|
3535
|
+
threshold: edge.threshold,
|
|
3536
|
+
occupied: edge.occupied
|
|
3537
|
+
}
|
|
3538
|
+
})
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
3542
|
+
* record — same subject, honest origin in the log. */
|
|
3543
|
+
function asReconcile(event) {
|
|
3544
|
+
return {
|
|
3545
|
+
...event,
|
|
3546
|
+
origin: "reconcile"
|
|
3547
|
+
};
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* The delivery an intake kind rides. `object-event` and `audio-event` both ride
|
|
3551
|
+
* `immediate` (classified audio has no delivery of its own — it is gated by the
|
|
3552
|
+
* audio-class opt-in instead); `occupancy-event` rides `device-event`.
|
|
3553
|
+
*
|
|
3554
|
+
* Extracted from the centre so the tester can tell an operator which rules a
|
|
3555
|
+
* given simulated trigger will even be considered against, without evaluating.
|
|
3556
|
+
*/
|
|
3557
|
+
function deliveryForKind(kind) {
|
|
3558
|
+
if (kind === "object-event" || kind === "audio-event") return "immediate";
|
|
3559
|
+
if (kind === "occupancy-event") return "device-event";
|
|
3560
|
+
return kind;
|
|
3561
|
+
}
|
|
3562
|
+
//#endregion
|
|
3563
|
+
//#region src/notification-center/test-event.ts
|
|
3564
|
+
/** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
|
|
3565
|
+
var BboxSchema = object({
|
|
3566
|
+
x: number().min(0).max(1),
|
|
3567
|
+
y: number().min(0).max(1),
|
|
3568
|
+
w: number().min(0).max(1),
|
|
3569
|
+
h: number().min(0).max(1)
|
|
3570
|
+
});
|
|
3571
|
+
/**
|
|
3572
|
+
* What the operator typed. Every field maps onto exactly one field of
|
|
3573
|
+
* {@link NcRuleSubject} — there is no test-only condition input, because a
|
|
3574
|
+
* condition the tester could set and a real record could not is a condition the
|
|
3575
|
+
* test result would lie about.
|
|
3576
|
+
*/
|
|
3577
|
+
var NcTestEventInputSchema = object({
|
|
3578
|
+
trigger: _enum([
|
|
3579
|
+
"object-event",
|
|
3580
|
+
"track-end",
|
|
3581
|
+
"device-event",
|
|
3582
|
+
"package-event",
|
|
3583
|
+
"audio-event",
|
|
3584
|
+
"occupancy-event"
|
|
3585
|
+
]),
|
|
3586
|
+
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
3587
|
+
deviceId: number().int(),
|
|
3588
|
+
/** DEVICE-EVENT: the linked device whose state changed — the contact, the
|
|
3589
|
+
* button, the doorbell — when that is not the camera. */
|
|
3590
|
+
sourceDeviceId: number().int().optional(),
|
|
3591
|
+
/** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
|
|
3592
|
+
classNames: array(string()).default([]),
|
|
3593
|
+
/** Collapsed display label — an identity name, a plate, a subclass. */
|
|
3594
|
+
label: string().optional(),
|
|
3595
|
+
/** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
|
|
3596
|
+
* condition FAILS on an absent confidence, and the tester must be able to
|
|
3597
|
+
* reproduce that. */
|
|
3598
|
+
confidence: number().min(0).max(1).optional(),
|
|
3599
|
+
/** Identity/plate MATCH confidence — distinct from `confidence`. */
|
|
3600
|
+
labelConfidence: number().min(0).max(1).optional(),
|
|
3601
|
+
/** Key-event importance [0,1] (track-end only, in practice). */
|
|
3602
|
+
importance: number().min(0).max(1).optional(),
|
|
3603
|
+
/** Track lifespan in seconds (track-end only). */
|
|
3604
|
+
dwellSeconds: number().min(0).optional(),
|
|
3605
|
+
/** Admin zone ids the subject is in (object) / visited (track). */
|
|
3606
|
+
zones: array(string()).default([]),
|
|
3607
|
+
/** OBJECT-EVENT: simulate a confirmed zone crossing. */
|
|
3608
|
+
crossing: object({
|
|
3609
|
+
zoneId: string(),
|
|
3610
|
+
direction: _enum(["enter", "exit"])
|
|
3611
|
+
}).optional(),
|
|
3612
|
+
/** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
|
|
3613
|
+
sensorKind: string().optional(),
|
|
3614
|
+
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
3615
|
+
* event-emitter slice. */
|
|
3616
|
+
eventType: string().optional(),
|
|
3617
|
+
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
3618
|
+
packagePhase: _enum(["delivered", "picked-up"]).optional(),
|
|
3619
|
+
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
3620
|
+
* source reads as). */
|
|
3621
|
+
source: _enum([
|
|
3622
|
+
"pipeline",
|
|
3623
|
+
"onboard",
|
|
3624
|
+
"sensor",
|
|
3625
|
+
"audio"
|
|
3626
|
+
]).optional(),
|
|
3627
|
+
/** Normalized bbox — feeds `customZones`, AND is what lets the still be
|
|
3628
|
+
* cropped / boxed the way the rule's media policy asks. */
|
|
3629
|
+
bbox: BboxSchema.optional(),
|
|
3630
|
+
/** OCCUPANCY-EVENT: the committed count edge. */
|
|
3631
|
+
occupancy: object({
|
|
3632
|
+
zoneId: string().optional(),
|
|
3633
|
+
zoneName: string().optional(),
|
|
3634
|
+
className: string().optional(),
|
|
3635
|
+
count: number().int().min(0),
|
|
3636
|
+
previousCount: number().int().min(0).default(0),
|
|
3637
|
+
occupied: boolean(),
|
|
3638
|
+
threshold: number().int().min(1)
|
|
3639
|
+
}).optional(),
|
|
3640
|
+
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
3641
|
+
* condition without waiting for the hour. */
|
|
3642
|
+
timestamp: number().int().optional(),
|
|
3643
|
+
/** Evaluate only this rule (what the per-rule Test button sends). */
|
|
3644
|
+
onlyRuleId: string().optional(),
|
|
3645
|
+
bypassCooldown: boolean().default(false),
|
|
3646
|
+
bypassMute: boolean().default(false),
|
|
3647
|
+
/** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
|
|
3648
|
+
* not open a gate the operator did not ask to open. */
|
|
3649
|
+
runActions: boolean().default(false),
|
|
3650
|
+
/** Where the still comes from. `live` takes a snapshot of the camera NOW;
|
|
3651
|
+
* `none` sends the notification without one. */
|
|
3652
|
+
still: _enum(["live", "none"]).default("live")
|
|
3653
|
+
});
|
|
3654
|
+
/** Per-rule verdict, as the tester reports it. */
|
|
3655
|
+
var NcTestEventRuleOutcomeSchema = object({
|
|
3656
|
+
ruleId: string(),
|
|
3657
|
+
ruleName: string(),
|
|
3658
|
+
matched: boolean(),
|
|
3659
|
+
failedCondition: string().optional(),
|
|
3660
|
+
matchedOn: array(string()).optional(),
|
|
3661
|
+
cooldown: _enum([
|
|
3662
|
+
"n/a",
|
|
3663
|
+
"clear",
|
|
3664
|
+
"suppressed",
|
|
3665
|
+
"bypassed"
|
|
3666
|
+
]),
|
|
3667
|
+
enqueued: number().int(),
|
|
3668
|
+
targetIds: array(string())
|
|
3669
|
+
});
|
|
3670
|
+
var NcTestEventResultSchema = object({
|
|
3671
|
+
/** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
|
|
3672
|
+
* for in the logs, in the outbox and in the delivery history. */
|
|
3673
|
+
recordId: string(),
|
|
3674
|
+
/** Always `synthetic` — the marker the whole path carried. */
|
|
3675
|
+
origin: literal("synthetic"),
|
|
3676
|
+
/** The delivery the chosen trigger rides. A rule declaring anything else was
|
|
3677
|
+
* never a candidate, which is the answer to most "it did not fire". */
|
|
3678
|
+
delivery: string(),
|
|
3679
|
+
candidates: number().int(),
|
|
3680
|
+
outcomes: array(NcTestEventRuleOutcomeSchema),
|
|
3681
|
+
/** What the still ended up being. `unattachable` means the chosen TRIGGER
|
|
3682
|
+
* cannot carry one at all — a device-event notification has no media owner,
|
|
3683
|
+
* live or synthetic — and that is a property of production, not of the test. */
|
|
3684
|
+
still: _enum([
|
|
3685
|
+
"live",
|
|
3686
|
+
"none",
|
|
3687
|
+
"unavailable",
|
|
3688
|
+
"unattachable"
|
|
3689
|
+
]),
|
|
3690
|
+
/** Everything the operator has to know to read the result honestly. */
|
|
3691
|
+
notes: array(string()),
|
|
3692
|
+
droppedBy: string().optional()
|
|
3693
|
+
});
|
|
3694
|
+
/** The id shape every synthetic record carries. One prefix, everywhere. */
|
|
3695
|
+
var SYNTHETIC_ID_PREFIX = "synthetic:";
|
|
3696
|
+
/** True for a record id this module minted. Used by the media shim. */
|
|
3697
|
+
function isSyntheticId(id) {
|
|
3698
|
+
return id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
3699
|
+
}
|
|
3700
|
+
/**
|
|
3701
|
+
* Whether a subject of this kind can carry a still AT ALL, in production.
|
|
3702
|
+
*
|
|
3703
|
+
* The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
|
|
3704
|
+
* owner onto the payload only for the kinds that have one: an `eventId` for
|
|
3705
|
+
* object/package events, a `trackId` for track-scoped ones. A device-event, an
|
|
3706
|
+
* audio episode and an occupancy edge freeze neither — so those notifications
|
|
3707
|
+
* ship text-only today, and a tester that attached an image to them would be
|
|
3708
|
+
* advertising a feature that does not exist.
|
|
3709
|
+
*/
|
|
3710
|
+
function triggerCanCarryStill(kind) {
|
|
3711
|
+
return kind === "object-event" || kind === "package-event" || kind === "track-end";
|
|
3712
|
+
}
|
|
3713
|
+
/**
|
|
3714
|
+
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
3715
|
+
* event, so the red-green test can assert that this subject and a
|
|
3716
|
+
* producer-built one are indistinguishable.
|
|
3717
|
+
*/
|
|
3718
|
+
function buildSyntheticEvent(input, recordId, now) {
|
|
3719
|
+
const kind = input.trigger;
|
|
3720
|
+
const timestamp = input.timestamp ?? now;
|
|
3721
|
+
const trackScoped = kind === "object-event" || kind === "track-end";
|
|
3722
|
+
return {
|
|
3723
|
+
subject: {
|
|
3724
|
+
kind,
|
|
3725
|
+
recordId,
|
|
3726
|
+
deviceId: input.deviceId,
|
|
3727
|
+
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
3728
|
+
timestamp,
|
|
3729
|
+
classNames: input.classNames,
|
|
3730
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
3731
|
+
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
3732
|
+
zones: input.zones,
|
|
3733
|
+
...trackScoped ? { trackId: recordId } : {},
|
|
3734
|
+
source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
|
|
3735
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
3736
|
+
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
3737
|
+
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
3738
|
+
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
3739
|
+
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
3740
|
+
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
3741
|
+
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
3742
|
+
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
3743
|
+
...input.occupancy !== void 0 ? { occupancy: {
|
|
3744
|
+
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
3745
|
+
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
3746
|
+
...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
|
|
3747
|
+
count: input.occupancy.count,
|
|
3748
|
+
previousCount: input.occupancy.previousCount,
|
|
3749
|
+
occupied: input.occupancy.occupied,
|
|
3750
|
+
threshold: input.occupancy.threshold
|
|
3751
|
+
} } : {}
|
|
3752
|
+
},
|
|
3753
|
+
kind,
|
|
3754
|
+
origin: "synthetic",
|
|
3755
|
+
controls: {
|
|
3756
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
|
|
3757
|
+
bypassCooldown: input.bypassCooldown,
|
|
3758
|
+
bypassMute: input.bypassMute,
|
|
3759
|
+
runActions: input.runActions
|
|
3760
|
+
},
|
|
3761
|
+
log: () => ({
|
|
3762
|
+
tags: { deviceId: input.deviceId },
|
|
3763
|
+
meta: {
|
|
3764
|
+
eventId: recordId,
|
|
3765
|
+
synthetic: true,
|
|
3766
|
+
trigger: input.trigger,
|
|
3767
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
3768
|
+
}
|
|
3769
|
+
})
|
|
3770
|
+
};
|
|
3771
|
+
}
|
|
3772
|
+
/**
|
|
3773
|
+
* Turn one live JPEG into the media set the dispatcher's ladders look for.
|
|
3774
|
+
*
|
|
3775
|
+
* Every entry is the SAME photograph, differing only in how it was cut — which
|
|
3776
|
+
* is exactly what the ladders mean by their kind names, so nothing here claims
|
|
3777
|
+
* a frame it does not have:
|
|
3778
|
+
*
|
|
3779
|
+
* - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
|
|
3780
|
+
* - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
|
|
3781
|
+
* - `crop` / `thumbnail` — that bbox, cut out.
|
|
3782
|
+
*
|
|
3783
|
+
* With NO bbox only the clean-scene kinds are produced, so a rule asking for
|
|
3784
|
+
* `frame: 'cropped'` correctly finds nothing rather than being handed a whole
|
|
3785
|
+
* scene it did not ask for. sharp failures degrade the same way: the clean
|
|
3786
|
+
* scene always survives.
|
|
3787
|
+
*/
|
|
3788
|
+
async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
3789
|
+
const base64 = Buffer.from(jpeg).toString("base64");
|
|
3790
|
+
const clean = (kind) => ({
|
|
3791
|
+
key: `${SYNTHETIC_ID_PREFIX}${kind}`,
|
|
3792
|
+
kind,
|
|
3793
|
+
base64,
|
|
3794
|
+
sizeBytes: jpeg.byteLength,
|
|
3795
|
+
timestamp
|
|
3796
|
+
});
|
|
3797
|
+
const files = [
|
|
3798
|
+
clean("fullFrame"),
|
|
3799
|
+
clean("keyFrame"),
|
|
3800
|
+
clean("firstFrame")
|
|
3801
|
+
];
|
|
3802
|
+
if (bbox === void 0) return files;
|
|
3803
|
+
try {
|
|
3804
|
+
const { default: sharp } = await import("sharp");
|
|
3805
|
+
const meta = await sharp(Buffer.from(jpeg)).metadata();
|
|
3806
|
+
const W = meta.width ?? 0;
|
|
3807
|
+
const H = meta.height ?? 0;
|
|
3808
|
+
if (W === 0 || H === 0) return files;
|
|
3809
|
+
const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
|
|
3810
|
+
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
3811
|
+
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
3812
|
+
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
3813
|
+
const cropBuf = await sharp(Buffer.from(jpeg)).extract({
|
|
3814
|
+
left,
|
|
3815
|
+
top,
|
|
3816
|
+
width,
|
|
3817
|
+
height
|
|
3818
|
+
}).jpeg({ quality: 85 }).toBuffer();
|
|
3819
|
+
const cropB64 = cropBuf.toString("base64");
|
|
3820
|
+
files.push({
|
|
3821
|
+
key: `${SYNTHETIC_ID_PREFIX}crop`,
|
|
3822
|
+
kind: "crop",
|
|
3823
|
+
base64: cropB64,
|
|
3824
|
+
sizeBytes: cropBuf.byteLength,
|
|
3825
|
+
timestamp
|
|
3826
|
+
}, {
|
|
3827
|
+
key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
|
|
3828
|
+
kind: "thumbnail",
|
|
3829
|
+
base64: cropB64,
|
|
3830
|
+
sizeBytes: cropBuf.byteLength,
|
|
3831
|
+
timestamp
|
|
3832
|
+
});
|
|
3833
|
+
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
3834
|
+
const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
|
|
3835
|
+
const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
|
|
3836
|
+
input: overlay,
|
|
3837
|
+
top: 0,
|
|
3838
|
+
left: 0
|
|
3839
|
+
}]).jpeg({ quality: 85 }).toBuffer();
|
|
3840
|
+
files.push({
|
|
3841
|
+
key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
|
|
3842
|
+
kind: "fullFrameBoxed",
|
|
3843
|
+
base64: boxedBuf.toString("base64"),
|
|
3844
|
+
sizeBytes: boxedBuf.byteLength,
|
|
3845
|
+
timestamp
|
|
3846
|
+
});
|
|
3847
|
+
} catch {
|
|
3848
|
+
return files;
|
|
3849
|
+
}
|
|
3850
|
+
return files;
|
|
3851
|
+
}
|
|
3852
|
+
//#endregion
|
|
4005
3853
|
//#region src/notification-center/action-buttons.ts
|
|
4006
3854
|
/** Sequence names the rule actually declares. A button may only name one. */
|
|
4007
3855
|
function declaredSequences(actions) {
|
|
@@ -5303,6 +5151,16 @@ var WATERMARK_EVERY_TICKS = 15;
|
|
|
5303
5151
|
var OUTBOX_RETENTION_MS = 168 * 36e5;
|
|
5304
5152
|
var TEST_RULE_MAX_RESULTS = 200;
|
|
5305
5153
|
/**
|
|
5154
|
+
* How long a synthetic injection's live snapshot stays in RAM.
|
|
5155
|
+
*
|
|
5156
|
+
* Long enough to cover the drain tick plus the first handful of outbox
|
|
5157
|
+
* backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
|
|
5158
|
+
* cannot hold a JPEG for the life of the process. A row still retrying after
|
|
5159
|
+
* this loses its picture and ships text-only — the same degradation a real
|
|
5160
|
+
* event gets when its media never landed.
|
|
5161
|
+
*/
|
|
5162
|
+
var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
|
|
5163
|
+
/**
|
|
5306
5164
|
* The className every durable package event carries (mirrors
|
|
5307
5165
|
* `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
|
|
5308
5166
|
* stays free of a cross-module import into the pipeline). Delivery events use
|
|
@@ -5416,6 +5274,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5416
5274
|
alarmPanel = null;
|
|
5417
5275
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
5418
5276
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
5277
|
+
/**
|
|
5278
|
+
* The live snapshot a synthetic injection took, held for as long as the
|
|
5279
|
+
* outbox may still be retrying its rows. In RAM and only in RAM: a test that
|
|
5280
|
+
* left a media blob behind would be a test that persisted something, which
|
|
5281
|
+
* is the one thing the synthetic producer promises not to do.
|
|
5282
|
+
*/
|
|
5283
|
+
syntheticMedia = /* @__PURE__ */ new Map();
|
|
5419
5284
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
5420
5285
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
5421
5286
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -5573,6 +5438,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5573
5438
|
this.dispatcher = new NcDispatcher({
|
|
5574
5439
|
...deps.dispatcher,
|
|
5575
5440
|
logger: this.logger.child("dispatch"),
|
|
5441
|
+
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
5442
|
+
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
5443
|
+
if (held !== void 0) return held.files;
|
|
5444
|
+
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
5445
|
+
},
|
|
5576
5446
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
5577
5447
|
isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
|
|
5578
5448
|
snoozeGate: (input) => this.snoozeGate(input)
|
|
@@ -5651,19 +5521,157 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5651
5521
|
this.evaluationActive = false;
|
|
5652
5522
|
}
|
|
5653
5523
|
/**
|
|
5524
|
+
* Consume ONE event. THE entry point of the notification path — every
|
|
5525
|
+
* producer arrives here and nothing else does.
|
|
5526
|
+
*
|
|
5527
|
+
* Fire-and-forget: the frame path must never await a notification. The
|
|
5528
|
+
* evaluation is appended to the serialized chain, so consumption order is
|
|
5529
|
+
* persist order (see {@link evalChain}).
|
|
5530
|
+
*
|
|
5531
|
+
* It persists nothing about the event itself. Whether a durable row exists is
|
|
5532
|
+
* the PRODUCER's business — the tracker writes its tracks, the sensor ingest
|
|
5533
|
+
* its rows, the synthetic tester nothing at all — and the only thing written
|
|
5534
|
+
* from here on is the outbox row, which is the notification's own delivery
|
|
5535
|
+
* queue and not a record of the event.
|
|
5536
|
+
*/
|
|
5537
|
+
consumeEvent(event) {
|
|
5538
|
+
if (!this.evaluationActive) return;
|
|
5539
|
+
this.scheduleEvaluation(event);
|
|
5540
|
+
}
|
|
5541
|
+
/**
|
|
5542
|
+
* The same consumption, awaited, with the verdict returned. Identical code
|
|
5543
|
+
* path — {@link consumeEvent} is this function with the report discarded — so
|
|
5544
|
+
* a producer that reads the report is testing what the live producers do, not
|
|
5545
|
+
* a parallel implementation of it.
|
|
5546
|
+
*/
|
|
5547
|
+
async consumeEventAndReport(event) {
|
|
5548
|
+
if (!this.evaluationActive) return {
|
|
5549
|
+
recordId: event.subject.recordId,
|
|
5550
|
+
kind: event.kind,
|
|
5551
|
+
delivery: deliveryForKind(event.kind),
|
|
5552
|
+
origin: event.origin,
|
|
5553
|
+
candidates: 0,
|
|
5554
|
+
outcomes: [],
|
|
5555
|
+
droppedBy: "evaluation-inactive"
|
|
5556
|
+
};
|
|
5557
|
+
const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
|
|
5558
|
+
this.evalChain = result.then(() => void 0, () => void 0);
|
|
5559
|
+
return result;
|
|
5560
|
+
}
|
|
5561
|
+
/**
|
|
5562
|
+
* The SYNTHETIC producer — an operator-driven event, through the whole path.
|
|
5563
|
+
*
|
|
5564
|
+
* It builds an envelope and calls {@link consumeEventAndReport}. That is the
|
|
5565
|
+
* entire integration: no bypass, no second matcher, no shortcut into the
|
|
5566
|
+
* dispatcher. What the operator receives on their phone was matched by
|
|
5567
|
+
* `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
|
|
5568
|
+
* tick, given media by the ordinary ladders, degraded by the ordinary engine
|
|
5569
|
+
* and delivered by the ordinary notifier.
|
|
5570
|
+
*
|
|
5571
|
+
* What it does NOT do is persist the event. No track row, no object-event
|
|
5572
|
+
* row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
|
|
5573
|
+
* expiry. The outbox row is the exception that proves the rule: it is the
|
|
5574
|
+
* notification's delivery queue, not a record of the event, and it carries
|
|
5575
|
+
* `synthetic: true` so it can never be mistaken for one.
|
|
5576
|
+
*/
|
|
5577
|
+
async injectTestEvent(input, deps = {}) {
|
|
5578
|
+
const now = this.now();
|
|
5579
|
+
this.pruneSyntheticMedia(now);
|
|
5580
|
+
const recordId = `${SYNTHETIC_ID_PREFIX}${randomUUID()}`;
|
|
5581
|
+
const event = buildSyntheticEvent(input, recordId, now);
|
|
5582
|
+
const notes = [];
|
|
5583
|
+
let still = "none";
|
|
5584
|
+
if (!triggerCanCarryStill(event.kind)) {
|
|
5585
|
+
still = "unattachable";
|
|
5586
|
+
notes.push(`a ${event.kind} notification carries no still in production either — the payload freezes no media owner for this trigger, so nothing to attach is the honest result, not a limitation of the test`);
|
|
5587
|
+
} else if (input.still === "live") {
|
|
5588
|
+
const jpeg = await this.captureLiveStill(input.deviceId, deps);
|
|
5589
|
+
if (jpeg === null) {
|
|
5590
|
+
still = "unavailable";
|
|
5591
|
+
notes.push("the camera returned no snapshot — the notification ships without a still");
|
|
5592
|
+
} else {
|
|
5593
|
+
const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
|
|
5594
|
+
this.syntheticMedia.set(recordId, {
|
|
5595
|
+
files,
|
|
5596
|
+
expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
|
|
5597
|
+
});
|
|
5598
|
+
still = "live";
|
|
5599
|
+
if (input.bbox === void 0) notes.push("no bbox was given, so only the clean scene exists — a rule asking for the subject crop finds nothing, exactly as it would for a real event with no crop stored");
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
|
|
5603
|
+
if (input.bypassMute) notes.push("per-camera notification mute bypassed");
|
|
5604
|
+
if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
|
|
5605
|
+
this.logger.info("synthetic event injected", {
|
|
5606
|
+
tags: { deviceId: input.deviceId },
|
|
5607
|
+
meta: {
|
|
5608
|
+
eventId: recordId,
|
|
5609
|
+
synthetic: true,
|
|
5610
|
+
trigger: input.trigger,
|
|
5611
|
+
classes: input.classNames,
|
|
5612
|
+
zones: input.zones,
|
|
5613
|
+
still,
|
|
5614
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
5615
|
+
}
|
|
5616
|
+
});
|
|
5617
|
+
const report = await this.consumeEventAndReport(event);
|
|
5618
|
+
if (report.droppedBy === "no-candidate-rule") notes.push(input.onlyRuleId !== void 0 ? "that rule is not enabled, or its delivery does not match this trigger" : `no enabled rule declares delivery "${report.delivery}"`);
|
|
5619
|
+
if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
|
|
5620
|
+
if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
|
|
5621
|
+
if (report.outcomes.every((o) => o.enqueued === 0) && report.outcomes.some((o) => o.matched)) notes.push("the rule matched but no row was enqueued — every target is opted out of this rule, or the rule addresses none");
|
|
5622
|
+
return {
|
|
5623
|
+
recordId,
|
|
5624
|
+
origin: "synthetic",
|
|
5625
|
+
delivery: report.delivery,
|
|
5626
|
+
candidates: report.candidates,
|
|
5627
|
+
outcomes: report.outcomes.map((o) => ({
|
|
5628
|
+
ruleId: o.ruleId,
|
|
5629
|
+
ruleName: o.ruleName,
|
|
5630
|
+
matched: o.matched,
|
|
5631
|
+
...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
|
|
5632
|
+
...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
|
|
5633
|
+
cooldown: o.cooldown,
|
|
5634
|
+
enqueued: o.enqueued,
|
|
5635
|
+
targetIds: [...o.targetIds]
|
|
5636
|
+
})),
|
|
5637
|
+
still,
|
|
5638
|
+
notes,
|
|
5639
|
+
...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
|
|
5640
|
+
};
|
|
5641
|
+
}
|
|
5642
|
+
/** One live frame, or null. Never throws into an injection. */
|
|
5643
|
+
async captureLiveStill(deviceId, deps) {
|
|
5644
|
+
const capture = deps.getSnapshot;
|
|
5645
|
+
if (capture === void 0) return null;
|
|
5646
|
+
try {
|
|
5647
|
+
const shot = await capture(deviceId);
|
|
5648
|
+
if (shot === null || shot.base64.length === 0) return null;
|
|
5649
|
+
const buf = Buffer.from(shot.base64, "base64");
|
|
5650
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
5651
|
+
bytes.set(buf);
|
|
5652
|
+
return bytes;
|
|
5653
|
+
} catch (err) {
|
|
5654
|
+
this.logger.warn("synthetic injection could not photograph the camera", {
|
|
5655
|
+
tags: { deviceId },
|
|
5656
|
+
meta: { error: String(err) }
|
|
5657
|
+
});
|
|
5658
|
+
return null;
|
|
5659
|
+
}
|
|
5660
|
+
}
|
|
5661
|
+
/** Drop expired synthetic stills. Called on every injection — there is no
|
|
5662
|
+
* timer, because the map is empty except while a test is in flight. */
|
|
5663
|
+
pruneSyntheticMedia(now) {
|
|
5664
|
+
for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
|
|
5665
|
+
}
|
|
5666
|
+
/**
|
|
5654
5667
|
* Called at the object-event persist site, in the SAME moment as the
|
|
5655
5668
|
* durable insert. Fire-and-forget from the frame path (never blocks the
|
|
5656
5669
|
* pipeline); the boot reconcile covers a crash between persist and
|
|
5657
5670
|
* outbox insert.
|
|
5658
5671
|
*/
|
|
5659
5672
|
onObjectEventPersisted(event, _track) {
|
|
5660
|
-
if (!this.evaluationActive) return;
|
|
5661
5673
|
if (packagePhaseOf(event) !== null) return;
|
|
5662
|
-
|
|
5663
|
-
this.scheduleEvaluation(subject, "object-event", () => ({
|
|
5664
|
-
tags: { deviceId: event.deviceId },
|
|
5665
|
-
meta: { eventId: event.id }
|
|
5666
|
-
}));
|
|
5674
|
+
this.consumeEvent(incomingFromObjectEvent(event));
|
|
5667
5675
|
}
|
|
5668
5676
|
/**
|
|
5669
5677
|
* Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
|
|
@@ -5672,12 +5680,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5672
5680
|
* post-close promotions are picked up).
|
|
5673
5681
|
*/
|
|
5674
5682
|
onTrackClosed(track, _ownedMedia, info) {
|
|
5675
|
-
|
|
5676
|
-
const subject = subjectFromTrack(track, info);
|
|
5677
|
-
this.scheduleEvaluation(subject, "track-end", () => ({
|
|
5678
|
-
tags: { deviceId: track.deviceId },
|
|
5679
|
-
meta: { trackId: track.trackId }
|
|
5680
|
-
}));
|
|
5683
|
+
this.consumeEvent(incomingFromTrack(track, info));
|
|
5681
5684
|
}
|
|
5682
5685
|
/**
|
|
5683
5686
|
* Called at the SensorEvent persist site (`ingestSensorStateChange` — one row
|
|
@@ -5693,15 +5696,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5693
5696
|
* before it.
|
|
5694
5697
|
*/
|
|
5695
5698
|
onSensorEventPersisted(event) {
|
|
5696
|
-
|
|
5697
|
-
const subject = subjectFromSensorEvent(event);
|
|
5698
|
-
this.scheduleEvaluation(subject, "device-event", () => ({
|
|
5699
|
-
tags: { deviceId: event.deviceId },
|
|
5700
|
-
meta: {
|
|
5701
|
-
sensorEventId: event.id,
|
|
5702
|
-
kind: event.kind
|
|
5703
|
-
}
|
|
5704
|
-
}));
|
|
5699
|
+
this.consumeEvent(incomingFromSensorEvent(event));
|
|
5705
5700
|
}
|
|
5706
5701
|
/**
|
|
5707
5702
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -5716,15 +5711,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5716
5711
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
5717
5712
|
*/
|
|
5718
5713
|
onAudioEventPersisted(event) {
|
|
5719
|
-
|
|
5720
|
-
const subject = subjectFromAudioEvent(event);
|
|
5721
|
-
this.scheduleEvaluation(subject, "audio-event", () => ({
|
|
5722
|
-
tags: { deviceId: event.deviceId },
|
|
5723
|
-
meta: {
|
|
5724
|
-
audioEventId: event.id,
|
|
5725
|
-
class: event.classification?.className
|
|
5726
|
-
}
|
|
5727
|
-
}));
|
|
5714
|
+
this.consumeEvent(incomingFromAudioEvent(event));
|
|
5728
5715
|
}
|
|
5729
5716
|
/**
|
|
5730
5717
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -5733,15 +5720,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5733
5720
|
* crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
|
|
5734
5721
|
*/
|
|
5735
5722
|
onPackageEventPersisted(event, phase) {
|
|
5736
|
-
|
|
5737
|
-
const subject = subjectFromPackageEvent(event, phase);
|
|
5738
|
-
this.scheduleEvaluation(subject, "package-event", () => ({
|
|
5739
|
-
tags: { deviceId: event.deviceId },
|
|
5740
|
-
meta: {
|
|
5741
|
-
eventId: event.id,
|
|
5742
|
-
phase
|
|
5743
|
-
}
|
|
5744
|
-
}));
|
|
5723
|
+
this.consumeEvent(incomingFromPackageEvent(event, phase));
|
|
5745
5724
|
}
|
|
5746
5725
|
/**
|
|
5747
5726
|
* Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
|
|
@@ -5777,18 +5756,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5777
5756
|
* Public so a test / a future out-of-band edge source can drive it directly.
|
|
5778
5757
|
*/
|
|
5779
5758
|
onOccupancyEdge(edge) {
|
|
5780
|
-
|
|
5781
|
-
const subject = subjectFromOccupancyEvent(edge);
|
|
5782
|
-
this.scheduleEvaluation(subject, "occupancy-event", () => ({
|
|
5783
|
-
tags: { deviceId: edge.deviceId },
|
|
5784
|
-
meta: {
|
|
5785
|
-
zoneId: edge.zoneId ?? "@frame",
|
|
5786
|
-
className: edge.className ?? "@all",
|
|
5787
|
-
count: edge.count,
|
|
5788
|
-
threshold: edge.threshold,
|
|
5789
|
-
occupied: edge.occupied
|
|
5790
|
-
}
|
|
5791
|
-
}));
|
|
5759
|
+
this.consumeEvent(incomingFromOccupancyEdge(edge));
|
|
5792
5760
|
}
|
|
5793
5761
|
buildProvider() {
|
|
5794
5762
|
return {
|
|
@@ -5963,40 +5931,55 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5963
5931
|
});
|
|
5964
5932
|
}
|
|
5965
5933
|
/** Append one evaluation to the serialized chain (see {@link evalChain}). */
|
|
5966
|
-
scheduleEvaluation(
|
|
5934
|
+
scheduleEvaluation(event) {
|
|
5967
5935
|
this.evalChain = this.evalChain.then(async () => {
|
|
5968
5936
|
try {
|
|
5969
|
-
await this.evaluateAndEnqueue(
|
|
5937
|
+
await this.evaluateAndEnqueue(event);
|
|
5970
5938
|
} catch (err) {
|
|
5971
|
-
const ctx =
|
|
5939
|
+
const ctx = event.log();
|
|
5972
5940
|
this.logger.warn("rule evaluation failed", {
|
|
5973
5941
|
tags: ctx.tags,
|
|
5974
5942
|
meta: {
|
|
5975
5943
|
...ctx.meta,
|
|
5976
|
-
kind,
|
|
5944
|
+
kind: event.kind,
|
|
5945
|
+
origin: event.origin,
|
|
5977
5946
|
error: String(err)
|
|
5978
5947
|
}
|
|
5979
5948
|
});
|
|
5980
5949
|
}
|
|
5981
5950
|
});
|
|
5982
5951
|
}
|
|
5983
|
-
async evaluateAndEnqueue(
|
|
5984
|
-
|
|
5952
|
+
async evaluateAndEnqueue(event) {
|
|
5953
|
+
const { subject, kind, origin } = event;
|
|
5954
|
+
const controls = event.controls ?? {};
|
|
5955
|
+
const outcomes = [];
|
|
5956
|
+
const report = (extra = {}) => ({
|
|
5957
|
+
recordId: subject.recordId,
|
|
5958
|
+
kind,
|
|
5959
|
+
delivery: deliveryForKind(kind),
|
|
5960
|
+
origin,
|
|
5961
|
+
candidates: 0,
|
|
5962
|
+
outcomes,
|
|
5963
|
+
...extra
|
|
5964
|
+
});
|
|
5965
|
+
if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
|
|
5985
5966
|
this.reportMutedDrop(subject, kind, this.now());
|
|
5986
|
-
return;
|
|
5967
|
+
return report({ droppedBy: "muted" });
|
|
5987
5968
|
}
|
|
5988
|
-
const delivery = kind
|
|
5989
|
-
const
|
|
5969
|
+
const delivery = deliveryForKind(kind);
|
|
5970
|
+
const enabled = this.rules.listEnabled(delivery);
|
|
5971
|
+
const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
|
|
5990
5972
|
if (candidates.length === 0) {
|
|
5991
5973
|
this.logger.debug("no enabled rule for this trigger", {
|
|
5992
5974
|
tags: { deviceId: subject.deviceId },
|
|
5993
5975
|
meta: {
|
|
5994
5976
|
delivery,
|
|
5995
5977
|
kind,
|
|
5978
|
+
origin,
|
|
5996
5979
|
rulesLoaded: this.rules.list().length
|
|
5997
5980
|
}
|
|
5998
5981
|
});
|
|
5999
|
-
return;
|
|
5982
|
+
return report({ droppedBy: "no-candidate-rule" });
|
|
6000
5983
|
}
|
|
6001
5984
|
const now = this.now();
|
|
6002
5985
|
let anyMatched = false;
|
|
@@ -6005,6 +5988,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6005
5988
|
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
|
|
6006
5989
|
if (!evaluation.matched) {
|
|
6007
5990
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
5991
|
+
outcomes.push({
|
|
5992
|
+
ruleId: rule.id,
|
|
5993
|
+
ruleName: rule.name,
|
|
5994
|
+
matched: false,
|
|
5995
|
+
...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
|
|
5996
|
+
cooldown: "n/a",
|
|
5997
|
+
enqueued: 0,
|
|
5998
|
+
targetIds: []
|
|
5999
|
+
});
|
|
6008
6000
|
this.logger.debug("rule did not match", {
|
|
6009
6001
|
tags: { deviceId: subject.deviceId },
|
|
6010
6002
|
meta: {
|
|
@@ -6020,7 +6012,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6020
6012
|
continue;
|
|
6021
6013
|
}
|
|
6022
6014
|
const key = cooldownKey(rule, subject);
|
|
6023
|
-
|
|
6015
|
+
const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
|
|
6016
|
+
if (cooling && controls.bypassCooldown !== true) {
|
|
6024
6017
|
this.logger.debug("rule matched but is cooling down", {
|
|
6025
6018
|
tags: { deviceId: subject.deviceId },
|
|
6026
6019
|
meta: {
|
|
@@ -6031,6 +6024,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6031
6024
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6032
6025
|
}
|
|
6033
6026
|
});
|
|
6027
|
+
outcomes.push({
|
|
6028
|
+
ruleId: rule.id,
|
|
6029
|
+
ruleName: rule.name,
|
|
6030
|
+
matched: true,
|
|
6031
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6032
|
+
cooldown: "suppressed",
|
|
6033
|
+
enqueued: 0,
|
|
6034
|
+
targetIds: []
|
|
6035
|
+
});
|
|
6034
6036
|
continue;
|
|
6035
6037
|
}
|
|
6036
6038
|
this.logger.info("rule matched — enqueueing", {
|
|
@@ -6039,6 +6041,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6039
6041
|
ruleId: rule.id,
|
|
6040
6042
|
rule: rule.name,
|
|
6041
6043
|
kind,
|
|
6044
|
+
origin,
|
|
6042
6045
|
targets: rule.targets.length,
|
|
6043
6046
|
eventId: subject.recordId,
|
|
6044
6047
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
@@ -6047,7 +6050,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6047
6050
|
}
|
|
6048
6051
|
});
|
|
6049
6052
|
const sequences = rule.actions?.onTrigger;
|
|
6050
|
-
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
|
|
6053
|
+
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
|
|
6051
6054
|
ruleId: rule.id,
|
|
6052
6055
|
ruleName: rule.name,
|
|
6053
6056
|
deviceId: subject.deviceId,
|
|
@@ -6063,10 +6066,22 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6063
6066
|
}
|
|
6064
6067
|
});
|
|
6065
6068
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6066
|
-
|
|
6069
|
+
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
|
|
6070
|
+
const inserted = await this.outbox.enqueue(entries);
|
|
6071
|
+
if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
|
|
6072
|
+
outcomes.push({
|
|
6073
|
+
ruleId: rule.id,
|
|
6074
|
+
ruleName: rule.name,
|
|
6075
|
+
matched: true,
|
|
6076
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6077
|
+
cooldown: cooling ? "bypassed" : "clear",
|
|
6078
|
+
enqueued: inserted,
|
|
6079
|
+
targetIds: entries.map((e) => e.targetId)
|
|
6080
|
+
});
|
|
6067
6081
|
anyMatched = true;
|
|
6068
6082
|
}
|
|
6069
6083
|
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
6084
|
+
return report({ candidates: candidates.length });
|
|
6070
6085
|
}
|
|
6071
6086
|
/**
|
|
6072
6087
|
* One INFO line when a MUTED camera's event is dropped, bounded to one per
|
|
@@ -6118,7 +6133,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6118
6133
|
}
|
|
6119
6134
|
});
|
|
6120
6135
|
}
|
|
6121
|
-
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6136
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
|
|
6122
6137
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
6123
6138
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
6124
6139
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
@@ -6126,6 +6141,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6126
6141
|
const payload = {
|
|
6127
6142
|
ruleName: rule.name,
|
|
6128
6143
|
delivery: rule.delivery,
|
|
6144
|
+
...origin === "synthetic" ? { synthetic: true } : {},
|
|
6129
6145
|
priority: rule.priority,
|
|
6130
6146
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
6131
6147
|
media: rule.media.attach,
|
|
@@ -6309,21 +6325,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6309
6325
|
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
6310
6326
|
for (const event of ordered) {
|
|
6311
6327
|
const phase = packagePhaseOf(event);
|
|
6312
|
-
|
|
6313
|
-
tags: { deviceId: event.deviceId },
|
|
6314
|
-
meta: {
|
|
6315
|
-
eventId: event.id,
|
|
6316
|
-
phase,
|
|
6317
|
-
reconcile: true
|
|
6318
|
-
}
|
|
6319
|
-
}));
|
|
6320
|
-
else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
|
|
6321
|
-
tags: { deviceId: event.deviceId },
|
|
6322
|
-
meta: {
|
|
6323
|
-
eventId: event.id,
|
|
6324
|
-
reconcile: true
|
|
6325
|
-
}
|
|
6326
|
-
}));
|
|
6328
|
+
this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
|
|
6327
6329
|
}
|
|
6328
6330
|
await this.evalChain;
|
|
6329
6331
|
if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
|
|
@@ -6491,7 +6493,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6491
6493
|
}
|
|
6492
6494
|
} else if (rule.delivery === "track-end") {
|
|
6493
6495
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
6494
|
-
for (const track of tracks) subjects.push(
|
|
6496
|
+
for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
|
|
6495
6497
|
}
|
|
6496
6498
|
let lookup = (id) => this.deviceStates.get(id);
|
|
6497
6499
|
const gated = rule.conditions.deviceState;
|
|
@@ -6594,6 +6596,24 @@ var ncActions = defineCustomActions({
|
|
|
6594
6596
|
"nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
|
|
6595
6597
|
kind: "mutation",
|
|
6596
6598
|
caller: "required"
|
|
6599
|
+
}),
|
|
6600
|
+
/**
|
|
6601
|
+
* Inject a SYNTHETIC event through the whole notification path — match,
|
|
6602
|
+
* media, delivery — without producing a track, an event row or anything else
|
|
6603
|
+
* durable. See `test-event.ts` for what it does and does not do.
|
|
6604
|
+
*
|
|
6605
|
+
* A bridge action rather than a cap method, deliberately: it needs no
|
|
6606
|
+
* codegen, no `@camstack/system` republish and no place on a release train,
|
|
6607
|
+
* so the tester can ship and change at the speed of the addon it tests.
|
|
6608
|
+
*
|
|
6609
|
+
* `auth: 'admin'` because an injection DELIVERS: it reaches every target the
|
|
6610
|
+
* matched rule addresses, on other people's phones. `caller: 'required'` so
|
|
6611
|
+
* the log line names who pressed it.
|
|
6612
|
+
*/
|
|
6613
|
+
"nc.injectTestEvent": customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
|
|
6614
|
+
kind: "mutation",
|
|
6615
|
+
auth: "admin",
|
|
6616
|
+
caller: "required"
|
|
6597
6617
|
})
|
|
6598
6618
|
});
|
|
6599
6619
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
@@ -6708,6 +6728,22 @@ function makeNcActionHandlers(deps) {
|
|
|
6708
6728
|
await deps.snooze.cancel(input.snoozeId, c);
|
|
6709
6729
|
return { success: true };
|
|
6710
6730
|
},
|
|
6731
|
+
"nc.injectTestEvent": async (input, caller) => {
|
|
6732
|
+
const c = requireCaller(caller);
|
|
6733
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
|
|
6734
|
+
const inject = deps.injectTestEvent;
|
|
6735
|
+
if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
|
|
6736
|
+
const parsed = NcTestEventInputSchema.parse(input);
|
|
6737
|
+
deps.logger.info("synthetic notification test requested", {
|
|
6738
|
+
tags: { deviceId: parsed.deviceId },
|
|
6739
|
+
meta: {
|
|
6740
|
+
by: c.userId,
|
|
6741
|
+
trigger: parsed.trigger,
|
|
6742
|
+
...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
|
|
6743
|
+
}
|
|
6744
|
+
});
|
|
6745
|
+
return inject(parsed);
|
|
6746
|
+
},
|
|
6711
6747
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6712
6748
|
const c = requireCaller(caller);
|
|
6713
6749
|
assertRuleVisible(input.ruleId, c);
|
|
@@ -10985,6 +11021,214 @@ function createVideoclipsProvider(deps) {
|
|
|
10985
11021
|
}
|
|
10986
11022
|
};
|
|
10987
11023
|
}
|
|
11024
|
+
/** Real time. See {@link EventMediaRequest.speed}. */
|
|
11025
|
+
var DEFAULT_SPEED = 1;
|
|
11026
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
11027
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
11028
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
11029
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
11030
|
+
var RING_MP4_FPS = 15;
|
|
11031
|
+
var NO_MEDIA = {
|
|
11032
|
+
mp4: null,
|
|
11033
|
+
gif: null,
|
|
11034
|
+
source: "none",
|
|
11035
|
+
startOffsetMs: null,
|
|
11036
|
+
endOffsetMs: null,
|
|
11037
|
+
profile: null,
|
|
11038
|
+
video: null
|
|
11039
|
+
};
|
|
11040
|
+
var EventMediaService = class {
|
|
11041
|
+
deps;
|
|
11042
|
+
constructor(deps) {
|
|
11043
|
+
this.deps = deps;
|
|
11044
|
+
}
|
|
11045
|
+
/**
|
|
11046
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
11047
|
+
* a notification, and every branch that drops it logs why.
|
|
11048
|
+
*/
|
|
11049
|
+
async cut(request) {
|
|
11050
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
11051
|
+
const produced = await this.produce(request);
|
|
11052
|
+
if (produced !== null) return produced;
|
|
11053
|
+
return this.fallbackToRing(request);
|
|
11054
|
+
}
|
|
11055
|
+
async produce(request) {
|
|
11056
|
+
const deviceId = request.deviceId;
|
|
11057
|
+
const log = this.deps.logger;
|
|
11058
|
+
const kinds = [];
|
|
11059
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
11060
|
+
if (request.wantGif) kinds.push("gif");
|
|
11061
|
+
let production;
|
|
11062
|
+
try {
|
|
11063
|
+
production = await this.deps.produce({
|
|
11064
|
+
deviceId,
|
|
11065
|
+
aroundMs: request.aroundMs,
|
|
11066
|
+
preSeconds: request.preRollSec,
|
|
11067
|
+
postSeconds: request.postRollSec,
|
|
11068
|
+
kinds,
|
|
11069
|
+
gifMaxWidth: 640,
|
|
11070
|
+
gifFps: 8,
|
|
11071
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
11072
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
11073
|
+
});
|
|
11074
|
+
} catch (err) {
|
|
11075
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
11076
|
+
tags: { deviceId },
|
|
11077
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
11078
|
+
});
|
|
11079
|
+
return null;
|
|
11080
|
+
}
|
|
11081
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
11082
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
11083
|
+
if (mp4 === null && gif === null) {
|
|
11084
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
11085
|
+
tags: { deviceId },
|
|
11086
|
+
meta: {
|
|
11087
|
+
kinds: kinds.join(","),
|
|
11088
|
+
profile: production.profile
|
|
11089
|
+
}
|
|
11090
|
+
});
|
|
11091
|
+
return null;
|
|
11092
|
+
}
|
|
11093
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
11094
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
11095
|
+
log.info("nc media: one production, every attachment", {
|
|
11096
|
+
tags: { deviceId },
|
|
11097
|
+
meta: {
|
|
11098
|
+
profile: production.profile,
|
|
11099
|
+
video: production.video,
|
|
11100
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
11101
|
+
gifBytes: gif?.byteLength ?? null,
|
|
11102
|
+
startOffsetMs,
|
|
11103
|
+
endOffsetMs,
|
|
11104
|
+
sharedWindow: true
|
|
11105
|
+
}
|
|
11106
|
+
});
|
|
11107
|
+
return {
|
|
11108
|
+
mp4,
|
|
11109
|
+
gif,
|
|
11110
|
+
source: "produced",
|
|
11111
|
+
startOffsetMs,
|
|
11112
|
+
endOffsetMs,
|
|
11113
|
+
profile: production.profile,
|
|
11114
|
+
video: production.video
|
|
11115
|
+
};
|
|
11116
|
+
}
|
|
11117
|
+
/**
|
|
11118
|
+
* Redeem one artifact handle at the node that produced it.
|
|
11119
|
+
*
|
|
11120
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
11121
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
11122
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
11123
|
+
* existed and did not arrive.
|
|
11124
|
+
*/
|
|
11125
|
+
async redeem(deviceId, media, kind) {
|
|
11126
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
11127
|
+
if (artifact === void 0) return null;
|
|
11128
|
+
try {
|
|
11129
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
11130
|
+
if (res === null) {
|
|
11131
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
11132
|
+
tags: { deviceId },
|
|
11133
|
+
meta: {
|
|
11134
|
+
kind,
|
|
11135
|
+
handle: artifact.handle,
|
|
11136
|
+
nodeId: artifact.nodeId
|
|
11137
|
+
}
|
|
11138
|
+
});
|
|
11139
|
+
return null;
|
|
11140
|
+
}
|
|
11141
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
11142
|
+
if (buf.byteLength === 0) return null;
|
|
11143
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
11144
|
+
bytes.set(buf);
|
|
11145
|
+
return bytes;
|
|
11146
|
+
} catch (err) {
|
|
11147
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
11148
|
+
tags: { deviceId },
|
|
11149
|
+
meta: {
|
|
11150
|
+
kind,
|
|
11151
|
+
handle: artifact.handle,
|
|
11152
|
+
nodeId: artifact.nodeId,
|
|
11153
|
+
error: err instanceof Error ? err.message : String(err)
|
|
11154
|
+
}
|
|
11155
|
+
});
|
|
11156
|
+
return null;
|
|
11157
|
+
}
|
|
11158
|
+
}
|
|
11159
|
+
/**
|
|
11160
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
11161
|
+
*
|
|
11162
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
11163
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
11164
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
11165
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
11166
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
11167
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
11168
|
+
* used and handed to the second.
|
|
11169
|
+
*
|
|
11170
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
11171
|
+
* production method must not stop attaching footage, and three of the four
|
|
11172
|
+
* live rules on this install ask for a gif and nothing else.
|
|
11173
|
+
*/
|
|
11174
|
+
async fallbackToRing(request) {
|
|
11175
|
+
const deviceId = request.deviceId;
|
|
11176
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
11177
|
+
const window = {
|
|
11178
|
+
deviceId,
|
|
11179
|
+
aroundMs: request.aroundMs,
|
|
11180
|
+
preRollSec: request.preRollSec,
|
|
11181
|
+
postRollSec: request.postRollSec,
|
|
11182
|
+
speed,
|
|
11183
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
11184
|
+
};
|
|
11185
|
+
try {
|
|
11186
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
11187
|
+
...window,
|
|
11188
|
+
format: "mp4",
|
|
11189
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
11190
|
+
fps: RING_MP4_FPS
|
|
11191
|
+
}) : null;
|
|
11192
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
11193
|
+
...window,
|
|
11194
|
+
format: "gif",
|
|
11195
|
+
maxWidth: 640,
|
|
11196
|
+
fps: 8
|
|
11197
|
+
}) : null;
|
|
11198
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
11199
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
11200
|
+
tags: { deviceId },
|
|
11201
|
+
meta: { aroundMs: request.aroundMs }
|
|
11202
|
+
});
|
|
11203
|
+
return NO_MEDIA;
|
|
11204
|
+
}
|
|
11205
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
11206
|
+
tags: { deviceId },
|
|
11207
|
+
meta: {
|
|
11208
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
11209
|
+
gifBytes: gif?.byteLength ?? null,
|
|
11210
|
+
profile: request.profile ?? null,
|
|
11211
|
+
sharedWindow: "by-parameters"
|
|
11212
|
+
}
|
|
11213
|
+
});
|
|
11214
|
+
return {
|
|
11215
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
11216
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
11217
|
+
source: "ring-fallback",
|
|
11218
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
11219
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
11220
|
+
profile: request.profile ?? null,
|
|
11221
|
+
video: null
|
|
11222
|
+
};
|
|
11223
|
+
} catch (err) {
|
|
11224
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
11225
|
+
tags: { deviceId },
|
|
11226
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
11227
|
+
});
|
|
11228
|
+
return NO_MEDIA;
|
|
11229
|
+
}
|
|
11230
|
+
}
|
|
11231
|
+
};
|
|
10988
11232
|
//#endregion
|
|
10989
11233
|
//#region src/shared/frame/crop-extractor.ts
|
|
10990
11234
|
/**
|
|
@@ -25374,6 +25618,31 @@ var LinkedCamerasCache = class {
|
|
|
25374
25618
|
}
|
|
25375
25619
|
};
|
|
25376
25620
|
/**
|
|
25621
|
+
* THE camera-set derivation for a sensor-cap state change — one function, so
|
|
25622
|
+
* every consumer of a device-event attributes it to the same cameras.
|
|
25623
|
+
*
|
|
25624
|
+
* It is shared on purpose. The self-camera half (a doorbell wrapper bound on
|
|
25625
|
+
* camera 615 makes 615 both source and subject) was added to the history-row
|
|
25626
|
+
* ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
|
|
25627
|
+
* which kept resolving `camerasFor` alone. The result: camera 615's real
|
|
25628
|
+
* doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
|
|
25629
|
+
* timeline marker, for weeks, with the divergence invisible in both codepaths.
|
|
25630
|
+
* Two derivations of "which cameras" is one too many.
|
|
25631
|
+
*/
|
|
25632
|
+
async function resolveSensorEventCameras(cache, data) {
|
|
25633
|
+
const linkedCameraIds = await cache.camerasFor(data.deviceId);
|
|
25634
|
+
const isCamera = await cache.isCamera(data.deviceId);
|
|
25635
|
+
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
25636
|
+
if (cameraIds.length === 0) return {
|
|
25637
|
+
cameraIds,
|
|
25638
|
+
dropReason: isCamera ? "camera-native-cap" : "no-camera"
|
|
25639
|
+
};
|
|
25640
|
+
return {
|
|
25641
|
+
cameraIds,
|
|
25642
|
+
dropReason: null
|
|
25643
|
+
};
|
|
25644
|
+
}
|
|
25645
|
+
/**
|
|
25377
25646
|
* One `DeviceStateChanged` → N history rows, one per camera the event belongs
|
|
25378
25647
|
* to: the emitting device's LINKED cameras, plus the emitting device ITSELF
|
|
25379
25648
|
* when it is a camera carrying the sensor cap (a doorbell wrapper bound on
|
|
@@ -25386,11 +25655,9 @@ var LinkedCamerasCache = class {
|
|
|
25386
25655
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
25387
25656
|
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
25388
25657
|
if (descriptor === void 0) return 0;
|
|
25389
|
-
const
|
|
25390
|
-
|
|
25391
|
-
|
|
25392
|
-
if (cameraIds.length === 0) {
|
|
25393
|
-
deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
|
|
25658
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
25659
|
+
if (dropReason !== null) {
|
|
25660
|
+
deps.onDropped?.(dropReason, descriptor.kind);
|
|
25394
25661
|
return 0;
|
|
25395
25662
|
}
|
|
25396
25663
|
const slice = data.slice;
|
|
@@ -26129,6 +26396,103 @@ var EventMediaDispatcher = class {
|
|
|
26129
26396
|
}
|
|
26130
26397
|
}
|
|
26131
26398
|
};
|
|
26399
|
+
//#endregion
|
|
26400
|
+
//#region src/pipeline-analytics/services/sensor-marker-projector.ts
|
|
26401
|
+
/**
|
|
26402
|
+
* Sensor markers — the gate between "a mapped sensor/control cap changed
|
|
26403
|
+
* state" and "a synthetic track exists on the camera's timeline"
|
|
26404
|
+
* (event-kinds-taxonomy §8).
|
|
26405
|
+
*
|
|
26406
|
+
* It is the sibling of {@link ../services/audio-marker-projector.ts} and it
|
|
26407
|
+
* exists for the same reason: the projection has gates, and a gate that drops
|
|
26408
|
+
* work without saying so is indistinguishable from a feature that was never
|
|
26409
|
+
* wired.
|
|
26410
|
+
*
|
|
26411
|
+
* **What this fixes.** The projection used to resolve its cameras with
|
|
26412
|
+
* `camerasFor` alone — LINKED cameras only — while the history-row ingest
|
|
26413
|
+
* beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
|
|
26414
|
+
* is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
|
|
26415
|
+
* slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
|
|
26416
|
+
* projection returned on a `debug` line nobody runs at. Camera 615's real
|
|
26417
|
+
* press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
|
|
26418
|
+
* ring, an Alexa post and a matched notification rule — and no track. Both
|
|
26419
|
+
* halves now derive their cameras from {@link resolveSensorEventCameras}.
|
|
26420
|
+
*
|
|
26421
|
+
* **The asymmetry that used to be here was defended by a wrong premise.** The
|
|
26422
|
+
* old comment argued a self-projected track would feed the NC `track-end`
|
|
26423
|
+
* trigger and make every press evaluate the camera's object rules. It does
|
|
26424
|
+
* not: `persistSyntheticTrack` writes the row directly and the NC track-end
|
|
26425
|
+
* hook lives in the LIVE tracker's closer, which no synthetic track ever
|
|
26426
|
+
* enters. A linked contact sensor has been projecting onto its camera all
|
|
26427
|
+
* along under exactly the same mechanism.
|
|
26428
|
+
*
|
|
26429
|
+
* **Nothing here is silent.** A marker that lands emits one `info` line; every
|
|
26430
|
+
* branch that lands none emits one line carrying the reason. Both carry
|
|
26431
|
+
* `tags: { deviceId }` with the CAMERA id, because the question always arrives
|
|
26432
|
+
* as "why did 617 get a marker and 615 not".
|
|
26433
|
+
*/
|
|
26434
|
+
/**
|
|
26435
|
+
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
26436
|
+
* synthetic track per camera the event belongs to. Returns the number of
|
|
26437
|
+
* markers that landed. Best-effort (telemetry, D8) — a materializer that
|
|
26438
|
+
* throws propagates to the caller, which logs it.
|
|
26439
|
+
*/
|
|
26440
|
+
async function projectSensorMarkers(deps, data, timestamp) {
|
|
26441
|
+
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
26442
|
+
if (descriptor === void 0) {
|
|
26443
|
+
deps.onSkipped({
|
|
26444
|
+
cameraId: null,
|
|
26445
|
+
sourceDeviceId: data.deviceId,
|
|
26446
|
+
capName: data.capName,
|
|
26447
|
+
kind: null,
|
|
26448
|
+
reason: "unmapped-cap"
|
|
26449
|
+
});
|
|
26450
|
+
return 0;
|
|
26451
|
+
}
|
|
26452
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
26453
|
+
if (dropReason !== null) {
|
|
26454
|
+
deps.onSkipped({
|
|
26455
|
+
cameraId: null,
|
|
26456
|
+
sourceDeviceId: data.deviceId,
|
|
26457
|
+
capName: data.capName,
|
|
26458
|
+
kind: descriptor.kind,
|
|
26459
|
+
reason: dropReason
|
|
26460
|
+
});
|
|
26461
|
+
return 0;
|
|
26462
|
+
}
|
|
26463
|
+
const producingDeviceName = await deps.producingDeviceName(data.deviceId);
|
|
26464
|
+
let landed = 0;
|
|
26465
|
+
for (const cameraId of cameraIds) {
|
|
26466
|
+
const track = await deps.markers.materialize({
|
|
26467
|
+
cameraId,
|
|
26468
|
+
sourceDeviceId: data.deviceId,
|
|
26469
|
+
source: "sensor",
|
|
26470
|
+
kind: descriptor.kind,
|
|
26471
|
+
timestamp,
|
|
26472
|
+
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
26473
|
+
});
|
|
26474
|
+
if (track === null) {
|
|
26475
|
+
deps.onSkipped({
|
|
26476
|
+
cameraId,
|
|
26477
|
+
sourceDeviceId: data.deviceId,
|
|
26478
|
+
capName: data.capName,
|
|
26479
|
+
kind: descriptor.kind,
|
|
26480
|
+
reason: "not-materialized"
|
|
26481
|
+
});
|
|
26482
|
+
continue;
|
|
26483
|
+
}
|
|
26484
|
+
landed++;
|
|
26485
|
+
deps.onProjected({
|
|
26486
|
+
cameraId,
|
|
26487
|
+
sourceDeviceId: data.deviceId,
|
|
26488
|
+
capName: data.capName,
|
|
26489
|
+
kind: descriptor.kind,
|
|
26490
|
+
trackId: track.trackId,
|
|
26491
|
+
selfCamera: cameraId === data.deviceId
|
|
26492
|
+
});
|
|
26493
|
+
}
|
|
26494
|
+
return landed;
|
|
26495
|
+
}
|
|
26132
26496
|
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
26133
26497
|
var FULL_FRAME_QUALITY = 80;
|
|
26134
26498
|
/**
|
|
@@ -29100,15 +29464,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
29100
29464
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
29101
29465
|
* narrowing the old raw read applied inline. */
|
|
29102
29466
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
29103
|
-
/**
|
|
29104
|
-
* Footage-attachment window + geometry, used when the rule states none. The
|
|
29105
|
-
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
29106
|
-
* followed rather than one side of it.
|
|
29107
|
-
*/
|
|
29108
|
-
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
29109
|
-
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
29110
|
-
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
29111
|
-
var NC_FOOTAGE_FPS = 5;
|
|
29112
29467
|
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
29113
29468
|
var NcArtifactSecretSchema = string();
|
|
29114
29469
|
var EmbeddingEnabledSchema = boolean();
|
|
@@ -29282,38 +29637,82 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29282
29637
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
29283
29638
|
ncArtifactPlane = null;
|
|
29284
29639
|
ncArtifactDataPlane = null;
|
|
29285
|
-
/** Roadmap 4b: notification clips cut from the live fMP4 fragment seam.
|
|
29286
|
-
* Built on first use — a hub whose rules never ask for an mp4 never
|
|
29287
|
-
* constructs it, and constructing it costs nothing until `renderClip`. */
|
|
29288
|
-
ncSeamClips = null;
|
|
29289
29640
|
/**
|
|
29290
|
-
*
|
|
29291
|
-
* the REASON is always logged with `tags: { deviceId }`.
|
|
29641
|
+
* Roadmap 4b: ONE cut of an event, in every container the rule asked for.
|
|
29292
29642
|
*
|
|
29293
|
-
*
|
|
29294
|
-
*
|
|
29295
|
-
* precisely the thing an operator cannot diagnose from a phone.
|
|
29643
|
+
* Built on first use — a hub whose rules never attach footage never
|
|
29644
|
+
* constructs it, and constructing it costs nothing until `cut`.
|
|
29296
29645
|
*/
|
|
29297
|
-
|
|
29298
|
-
|
|
29646
|
+
ncEventMedia = null;
|
|
29647
|
+
/**
|
|
29648
|
+
* The dispatcher's single media dep.
|
|
29649
|
+
*
|
|
29650
|
+
* ONE call for the gif and the mp4, deliberately: rendering them separately
|
|
29651
|
+
* is what produced a gif around the event and a video seven seconds after it
|
|
29652
|
+
* (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
|
|
29653
|
+
* slot, asks the broker's own prebuffer ring for the window at that slot's
|
|
29654
|
+
* own width in real time — which is what makes the broker COPY rather than
|
|
29655
|
+
* re-encode — and derives the gif from those same bytes.
|
|
29656
|
+
*/
|
|
29657
|
+
async renderEventMedia(req) {
|
|
29299
29658
|
const api = this.ctx.api;
|
|
29300
|
-
this.
|
|
29301
|
-
logger:
|
|
29302
|
-
|
|
29659
|
+
this.ncEventMedia ??= new EventMediaService({
|
|
29660
|
+
logger: this.ctx.logger.child("nc-event-media"),
|
|
29661
|
+
produce: (input) => api.streamBroker.produceEventMedia.mutate({
|
|
29662
|
+
deviceId: input.deviceId,
|
|
29663
|
+
aroundMs: input.aroundMs,
|
|
29664
|
+
preSeconds: input.preSeconds,
|
|
29665
|
+
postSeconds: input.postSeconds,
|
|
29666
|
+
kinds: [...input.kinds],
|
|
29667
|
+
gifMaxWidth: input.gifMaxWidth,
|
|
29668
|
+
gifFps: input.gifFps,
|
|
29669
|
+
speed: input.speed,
|
|
29670
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
29671
|
+
}),
|
|
29672
|
+
fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? nodePin(nodeId) : void 0),
|
|
29673
|
+
renderRingClip: async (input) => {
|
|
29674
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
29675
|
+
deviceId: input.deviceId,
|
|
29676
|
+
aroundMs: input.aroundMs,
|
|
29677
|
+
format: input.format,
|
|
29678
|
+
preRollSec: input.preRollSec,
|
|
29679
|
+
postRollSec: input.postRollSec,
|
|
29680
|
+
maxWidth: input.maxWidth,
|
|
29681
|
+
fps: input.fps,
|
|
29682
|
+
speed: input.speed,
|
|
29683
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
29684
|
+
});
|
|
29685
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
29686
|
+
if (buf.byteLength === 0) return null;
|
|
29687
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
29688
|
+
bytes.set(buf);
|
|
29689
|
+
return bytes;
|
|
29690
|
+
}
|
|
29303
29691
|
});
|
|
29304
29692
|
try {
|
|
29305
|
-
const
|
|
29693
|
+
const media = await this.ncEventMedia.cut({
|
|
29306
29694
|
deviceId: req.deviceId,
|
|
29307
29695
|
aroundMs: req.aroundMs,
|
|
29308
|
-
|
|
29696
|
+
wantMp4: req.wantMp4,
|
|
29697
|
+
wantGif: req.wantGif,
|
|
29698
|
+
preRollSec: req.preRollSec,
|
|
29699
|
+
postRollSec: req.postRollSec,
|
|
29700
|
+
...req.profile !== void 0 ? { profile: req.profile } : {},
|
|
29701
|
+
...req.speed !== void 0 ? { speed: req.speed } : {}
|
|
29309
29702
|
});
|
|
29310
|
-
return
|
|
29703
|
+
return {
|
|
29704
|
+
mp4: media.mp4,
|
|
29705
|
+
gif: media.gif
|
|
29706
|
+
};
|
|
29311
29707
|
} catch (err) {
|
|
29312
|
-
|
|
29708
|
+
this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
|
|
29313
29709
|
tags: { deviceId: req.deviceId },
|
|
29314
29710
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
29315
29711
|
});
|
|
29316
|
-
return
|
|
29712
|
+
return {
|
|
29713
|
+
mp4: null,
|
|
29714
|
+
gif: null
|
|
29715
|
+
};
|
|
29317
29716
|
}
|
|
29318
29717
|
}
|
|
29319
29718
|
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
@@ -29818,7 +30217,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29818
30217
|
},
|
|
29819
30218
|
listCallerTargetIds: async (userId) => {
|
|
29820
30219
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
29821
|
-
}
|
|
30220
|
+
},
|
|
30221
|
+
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
|
|
29822
30222
|
});
|
|
29823
30223
|
}
|
|
29824
30224
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
@@ -29952,7 +30352,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29952
30352
|
let storage = this.ctx.kernel.storage;
|
|
29953
30353
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
29954
30354
|
if (mediaRoot) {
|
|
29955
|
-
const { FilesystemStorageProvider } = await import("../node-
|
|
30355
|
+
const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
|
|
29956
30356
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
29957
30357
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
29958
30358
|
}
|
|
@@ -30410,28 +30810,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30410
30810
|
y: pt.y
|
|
30411
30811
|
})));
|
|
30412
30812
|
},
|
|
30413
|
-
|
|
30414
|
-
if (req.format === "mp4") {
|
|
30415
|
-
const seam = await this.renderSeamClip(req);
|
|
30416
|
-
if (seam !== null) return seam;
|
|
30417
|
-
}
|
|
30418
|
-
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
30419
|
-
deviceId: req.deviceId,
|
|
30420
|
-
aroundMs: req.aroundMs,
|
|
30421
|
-
format: req.format,
|
|
30422
|
-
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
30423
|
-
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
30424
|
-
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
30425
|
-
fps: NC_FOOTAGE_FPS,
|
|
30426
|
-
...req.speed !== void 0 ? { speed: req.speed } : {},
|
|
30427
|
-
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
30428
|
-
});
|
|
30429
|
-
const buf = Buffer.from(res.base64, "base64");
|
|
30430
|
-
if (buf.byteLength === 0) return null;
|
|
30431
|
-
const bytes = new Uint8Array(buf.byteLength);
|
|
30432
|
-
bytes.set(buf);
|
|
30433
|
-
return bytes;
|
|
30434
|
-
},
|
|
30813
|
+
renderEventMedia: (req) => this.renderEventMedia(req),
|
|
30435
30814
|
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
30436
30815
|
listTargets: async () => {
|
|
30437
30816
|
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
@@ -33376,29 +33755,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
33376
33755
|
});
|
|
33377
33756
|
}
|
|
33378
33757
|
const materializer = this.syntheticTracks;
|
|
33379
|
-
|
|
33380
|
-
if (materializer === null || descriptor === void 0) return;
|
|
33758
|
+
if (materializer === null) return;
|
|
33381
33759
|
try {
|
|
33382
|
-
|
|
33383
|
-
|
|
33384
|
-
|
|
33385
|
-
|
|
33760
|
+
await projectSensorMarkers({
|
|
33761
|
+
cache,
|
|
33762
|
+
markers: materializer,
|
|
33763
|
+
producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
|
|
33764
|
+
onProjected: (r) => this.ctx.logger.info("synthetic sensor-track projected", {
|
|
33765
|
+
tags: { deviceId: r.cameraId },
|
|
33386
33766
|
meta: {
|
|
33387
|
-
capName:
|
|
33388
|
-
kind:
|
|
33767
|
+
capName: r.capName,
|
|
33768
|
+
kind: r.kind,
|
|
33769
|
+
trackId: r.trackId,
|
|
33770
|
+
sourceDeviceId: r.sourceDeviceId,
|
|
33771
|
+
selfCamera: r.selfCamera
|
|
33389
33772
|
}
|
|
33390
|
-
})
|
|
33391
|
-
|
|
33392
|
-
|
|
33393
|
-
|
|
33394
|
-
|
|
33395
|
-
|
|
33396
|
-
|
|
33397
|
-
|
|
33398
|
-
|
|
33399
|
-
|
|
33400
|
-
|
|
33401
|
-
});
|
|
33773
|
+
}),
|
|
33774
|
+
onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
33775
|
+
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
33776
|
+
meta: {
|
|
33777
|
+
capName: r.capName,
|
|
33778
|
+
kind: r.kind,
|
|
33779
|
+
reason: r.reason,
|
|
33780
|
+
sourceDeviceId: r.sourceDeviceId
|
|
33781
|
+
}
|
|
33782
|
+
})
|
|
33783
|
+
}, data, timestamp);
|
|
33402
33784
|
} catch (err) {
|
|
33403
33785
|
this.ctx.logger.warn("synthetic sensor-track materialize failed", {
|
|
33404
33786
|
tags: { deviceId: data.deviceId },
|