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