@camstack/addon-post-analysis 1.2.51 → 1.2.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{dist-CnSzKo7x.js → dist-DGdY1GSf.js} +1466 -1655
- package/dist/{dist-DTeVOZBt.mjs → dist-Da97q0x6.mjs} +1479 -1657
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/node-Co0YpEFr.mjs +148 -0
- package/dist/node-CpXZkQ6V.js +151 -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-tCAuhv5m.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-DcqnDg2z.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-DPOdauxs.mjs → hostInit-CyLkuQ7j.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2509 -1093
- package/dist/pipeline-analytics/index.mjs +2499 -1091
- 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-DGdY1GSf.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
|
|
@@ -699,77 +698,34 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
699
698
|
}
|
|
700
699
|
};
|
|
701
700
|
//#endregion
|
|
702
|
-
//#region src/notification-center/alarm/
|
|
701
|
+
//#region src/notification-center/alarm/alarm-panel-declaration.ts
|
|
703
702
|
/**
|
|
704
|
-
*
|
|
703
|
+
* The Notification Center's alarm panel, as a DECLARATION.
|
|
705
704
|
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
705
|
+
* There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
|
|
706
|
+
* re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
|
|
707
|
+
* to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
|
|
708
|
+
* addon declares. What is left here is the *declaration*: the stable id, the
|
|
709
|
+
* seed name, the type and the class. Data, not procedure.
|
|
709
710
|
*
|
|
710
|
-
* The
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
711
|
+
* The bug the hand-rolled version carried died in that move: it read the device
|
|
712
|
+
* index once, so a transiently empty answer took the create branch and
|
|
713
|
+
* re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
|
|
714
|
+
* second read to agree before it does anything destructive (D49).
|
|
714
715
|
*/
|
|
715
716
|
/** Stable id of the one panel. Never derived from a name — a rename must not
|
|
716
717
|
* mint a second device. */
|
|
717
718
|
var NC_ALARM_STABLE_ID = "nc-alarm-panel";
|
|
719
|
+
/** Seeded ONCE, at create. A later boot never re-stamps it. */
|
|
718
720
|
var NC_ALARM_DEVICE_NAME = "Alarm";
|
|
719
|
-
/**
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const existingIntegration = await deps.getIntegration(deps.addonId);
|
|
728
|
-
if (existingIntegration !== null && !existingIntegration.fixed) {
|
|
729
|
-
await deps.markFixed(existingIntegration.id);
|
|
730
|
-
deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
|
|
731
|
-
}
|
|
732
|
-
const integration = existingIntegration ?? await deps.createIntegration({
|
|
733
|
-
addonId: deps.addonId,
|
|
734
|
-
name: deps.integrationName,
|
|
735
|
-
info: { fixed: true }
|
|
736
|
-
});
|
|
737
|
-
if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
|
|
738
|
-
const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
|
|
739
|
-
deps.logger.info("alarm panel adopted", {
|
|
740
|
-
tags: { deviceId: adopted.id },
|
|
741
|
-
meta: {
|
|
742
|
-
integrationId: integration.id,
|
|
743
|
-
stableId: NC_ALARM_STABLE_ID
|
|
744
|
-
}
|
|
745
|
-
});
|
|
746
|
-
return {
|
|
747
|
-
integrationId: integration.id,
|
|
748
|
-
deviceId: adopted.id,
|
|
749
|
-
created: false
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
const device = await deps.createDevice({
|
|
753
|
-
stableId: NC_ALARM_STABLE_ID,
|
|
754
|
-
integrationId: integration.id,
|
|
755
|
-
name: NC_ALARM_DEVICE_NAME
|
|
756
|
-
});
|
|
757
|
-
deps.logger.info("alarm panel created", {
|
|
758
|
-
tags: { deviceId: device.id },
|
|
759
|
-
meta: {
|
|
760
|
-
integrationId: integration.id,
|
|
761
|
-
stableId: NC_ALARM_STABLE_ID
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
return {
|
|
765
|
-
integrationId: integration.id,
|
|
766
|
-
deviceId: device.id,
|
|
767
|
-
created: true
|
|
768
|
-
};
|
|
769
|
-
}
|
|
770
|
-
/** The device class + type the caller passes to `kernel.devices.create`. */
|
|
771
|
-
var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
|
|
772
|
-
var NC_ALARM_DEVICE_TYPE = require_dist.DeviceType.AlarmPanel;
|
|
721
|
+
/** Display name of the FIXED integration the panel hangs from. */
|
|
722
|
+
var NC_INTEGRATION_NAME = "Notification Center";
|
|
723
|
+
var NC_ALARM_DECLARATION = {
|
|
724
|
+
stableId: NC_ALARM_STABLE_ID,
|
|
725
|
+
name: NC_ALARM_DEVICE_NAME,
|
|
726
|
+
type: require_dist.DeviceType.AlarmPanel,
|
|
727
|
+
DeviceClass: NcAlarmPanelDevice
|
|
728
|
+
};
|
|
773
729
|
//#endregion
|
|
774
730
|
//#region src/notification-center/artifact-url.ts
|
|
775
731
|
/**
|
|
@@ -1109,556 +1065,219 @@ function mimeFromExtension(file) {
|
|
|
1109
1065
|
return "application/octet-stream";
|
|
1110
1066
|
}
|
|
1111
1067
|
//#endregion
|
|
1112
|
-
//#region src/notification-center/
|
|
1113
|
-
/**
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
}
|
|
1068
|
+
//#region src/notification-center/action-buttons.ts
|
|
1069
|
+
/** Sequence names the rule actually declares. A button may only name one. */
|
|
1070
|
+
function declaredSequences(actions) {
|
|
1071
|
+
const names = /* @__PURE__ */ new Set();
|
|
1072
|
+
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
1073
|
+
return names;
|
|
1210
1074
|
}
|
|
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
1075
|
/**
|
|
1215
|
-
*
|
|
1076
|
+
* Build the notification's buttons.
|
|
1216
1077
|
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
* the subscription immediately afterwards.
|
|
1078
|
+
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
1079
|
+
* to set the field, and an empty array is the honest answer to "which buttons
|
|
1080
|
+
* survived", which `undefined` would conflate with "the rule declared none".
|
|
1221
1081
|
*/
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
if (
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1082
|
+
function buildActionButtons(input) {
|
|
1083
|
+
const actions = input.actions;
|
|
1084
|
+
if (actions === void 0) return [];
|
|
1085
|
+
const buttons = actions.buttons ?? [];
|
|
1086
|
+
if (buttons.length === 0) return [];
|
|
1087
|
+
const known = declaredSequences(actions);
|
|
1088
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1089
|
+
const out = [];
|
|
1090
|
+
for (const button of buttons) {
|
|
1091
|
+
if (!known.has(button.sequence)) continue;
|
|
1092
|
+
if (seen.has(button.id)) continue;
|
|
1093
|
+
seen.add(button.id);
|
|
1094
|
+
out.push({
|
|
1095
|
+
id: button.id,
|
|
1096
|
+
label: button.label,
|
|
1097
|
+
url: input.mintUrl({
|
|
1098
|
+
sequence: button.sequence,
|
|
1099
|
+
actionId: button.id
|
|
1100
|
+
}),
|
|
1101
|
+
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
1102
|
+
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
1103
|
+
});
|
|
1240
1104
|
}
|
|
1241
1105
|
return out;
|
|
1242
1106
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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, "");
|
|
1107
|
+
/**
|
|
1108
|
+
* Which of a rule's buttons name a sequence it does not have.
|
|
1109
|
+
*
|
|
1110
|
+
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
1111
|
+
* notification is the exact shape of failure this repo keeps paying for — the
|
|
1112
|
+
* operator authored it, it never appeared, and nothing said why.
|
|
1113
|
+
*/
|
|
1114
|
+
function unresolvableButtons(actions) {
|
|
1115
|
+
if (actions === void 0) return [];
|
|
1116
|
+
const buttons = actions.buttons ?? [];
|
|
1117
|
+
if (buttons.length === 0) return [];
|
|
1118
|
+
const known = declaredSequences(actions);
|
|
1119
|
+
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
1304
1120
|
}
|
|
1305
1121
|
//#endregion
|
|
1306
|
-
//#region src/notification-center/
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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;
|
|
1122
|
+
//#region src/notification-center/device-mute-store.ts
|
|
1123
|
+
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
1124
|
+
var NC_DEVICE_MUTES_COLUMNS = [
|
|
1125
|
+
(
|
|
1126
|
+
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
1127
|
+
{
|
|
1128
|
+
name: "id",
|
|
1129
|
+
type: "TEXT",
|
|
1130
|
+
primaryKey: true,
|
|
1131
|
+
notNull: true
|
|
1132
|
+
}),
|
|
1133
|
+
{
|
|
1134
|
+
name: "deviceId",
|
|
1135
|
+
type: "INTEGER",
|
|
1136
|
+
notNull: true
|
|
1137
|
+
},
|
|
1138
|
+
(
|
|
1139
|
+
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
1140
|
+
{
|
|
1141
|
+
name: "mutedAt",
|
|
1142
|
+
type: "INTEGER",
|
|
1143
|
+
notNull: true
|
|
1144
|
+
})
|
|
1145
|
+
];
|
|
1146
|
+
/**
|
|
1147
|
+
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
1148
|
+
* the table proportional to what the operator actually silenced, and makes the
|
|
1149
|
+
* hot-path read a `Set.has`.
|
|
1150
|
+
*/
|
|
1151
|
+
var NcDeviceMuteStore = class {
|
|
1152
|
+
muted = /* @__PURE__ */ new Set();
|
|
1153
|
+
store;
|
|
1154
|
+
logger;
|
|
1155
|
+
now;
|
|
1427
1156
|
constructor(deps) {
|
|
1428
|
-
this.
|
|
1157
|
+
this.store = deps.store;
|
|
1158
|
+
this.logger = deps.logger;
|
|
1159
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1429
1160
|
}
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
-
}
|
|
1161
|
+
static async declare(store) {
|
|
1162
|
+
await store.declareCollection.mutate({
|
|
1163
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1164
|
+
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
1448
1165
|
});
|
|
1449
|
-
return result;
|
|
1450
1166
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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();
|
|
1167
|
+
/**
|
|
1168
|
+
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
1169
|
+
* clearing it: dropping every mute on a transient store error would start
|
|
1170
|
+
* notifying for a camera the operator silenced, without them asking — the
|
|
1171
|
+
* same reasoning as `NcSnoozeStore.load`.
|
|
1172
|
+
*/
|
|
1173
|
+
async load() {
|
|
1503
1174
|
try {
|
|
1504
|
-
await
|
|
1175
|
+
const rows = await this.store.query.query({
|
|
1176
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1177
|
+
filter: { limit: 1e4 }
|
|
1178
|
+
});
|
|
1179
|
+
const next = /* @__PURE__ */ new Set();
|
|
1180
|
+
let skipped = 0;
|
|
1181
|
+
for (const row of rows) {
|
|
1182
|
+
const raw = row.data["deviceId"];
|
|
1183
|
+
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
1184
|
+
skipped += 1;
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
next.add(raw);
|
|
1188
|
+
}
|
|
1189
|
+
this.muted.clear();
|
|
1190
|
+
for (const id of next) this.muted.add(id);
|
|
1191
|
+
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
1505
1192
|
} catch (err) {
|
|
1506
|
-
this.
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
return {
|
|
1511
|
-
ok: false,
|
|
1512
|
-
code: "child-failed",
|
|
1513
|
-
reason: `ffmpeg produced no fMP4 initialisation segment: ${errText(err)}`
|
|
1514
|
-
};
|
|
1193
|
+
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
1194
|
+
error: String(err),
|
|
1195
|
+
muted: this.muted.size
|
|
1196
|
+
} });
|
|
1515
1197
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
|
1198
|
+
}
|
|
1199
|
+
/** Hot-path read, called once per evaluated subject. */
|
|
1200
|
+
isMuted(deviceId) {
|
|
1201
|
+
return this.muted.has(deviceId);
|
|
1202
|
+
}
|
|
1203
|
+
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
1204
|
+
listMuted() {
|
|
1205
|
+
return [...this.muted].toSorted((a, b) => a - b);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
1209
|
+
* updated only after the write lands, so a failed write cannot leave the
|
|
1210
|
+
* hot path believing a camera is silenced when the next boot will not.
|
|
1211
|
+
*/
|
|
1212
|
+
async setMuted(deviceId, muted) {
|
|
1213
|
+
const id = String(deviceId);
|
|
1214
|
+
if (muted) {
|
|
1215
|
+
await this.store.set.mutate({
|
|
1216
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1217
|
+
key: id,
|
|
1218
|
+
value: {
|
|
1219
|
+
id,
|
|
1220
|
+
deviceId,
|
|
1221
|
+
mutedAt: this.now()
|
|
1560
1222
|
}
|
|
1561
1223
|
});
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
plane.dispose();
|
|
1570
|
-
this.inFlight.delete(deviceId);
|
|
1224
|
+
this.muted.add(deviceId);
|
|
1225
|
+
} else {
|
|
1226
|
+
await this.store.delete.mutate({
|
|
1227
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1228
|
+
key: id
|
|
1229
|
+
});
|
|
1230
|
+
this.muted.delete(deviceId);
|
|
1571
1231
|
}
|
|
1572
1232
|
}
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
return this.transport;
|
|
1233
|
+
};
|
|
1234
|
+
var NcDeviceStateCache = class {
|
|
1235
|
+
deps;
|
|
1236
|
+
states = /* @__PURE__ */ new Map();
|
|
1237
|
+
lastOkAt = 0;
|
|
1238
|
+
ids = [];
|
|
1239
|
+
now;
|
|
1240
|
+
constructor(deps) {
|
|
1241
|
+
this.deps = deps;
|
|
1242
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1584
1243
|
}
|
|
1585
|
-
|
|
1244
|
+
/**
|
|
1245
|
+
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
1246
|
+
* with no gated rule does no work at all.
|
|
1247
|
+
*/
|
|
1248
|
+
setWatched(ids) {
|
|
1249
|
+
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
1250
|
+
}
|
|
1251
|
+
watched() {
|
|
1252
|
+
return this.ids;
|
|
1253
|
+
}
|
|
1254
|
+
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
1255
|
+
get(deviceId) {
|
|
1256
|
+
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
1257
|
+
return this.states.get(deviceId);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Re-read every watched state. Never throws: a gate that could fail the
|
|
1261
|
+
* evaluation tick would take the notification pipeline with it.
|
|
1262
|
+
*/
|
|
1263
|
+
async refresh() {
|
|
1264
|
+
if (this.ids.length === 0) {
|
|
1265
|
+
this.states = /* @__PURE__ */ new Map();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1586
1268
|
try {
|
|
1587
|
-
|
|
1269
|
+
const next = await this.deps.readStates(this.ids);
|
|
1270
|
+
this.states = new Map(next);
|
|
1271
|
+
this.lastOkAt = this.now();
|
|
1272
|
+
for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
|
|
1588
1273
|
} catch (err) {
|
|
1589
|
-
this.deps.logger.
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
});
|
|
1593
|
-
return null;
|
|
1274
|
+
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
1275
|
+
watched: this.ids.length,
|
|
1276
|
+
error: String(err)
|
|
1277
|
+
} });
|
|
1594
1278
|
}
|
|
1595
1279
|
}
|
|
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
1280
|
};
|
|
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
1281
|
//#endregion
|
|
1663
1282
|
//#region src/pipeline-analytics/pipeline/label-utils.ts
|
|
1664
1283
|
/**
|
|
@@ -2072,12 +1691,13 @@ function readSensorEventType(value) {
|
|
|
2072
1691
|
* what the operator named in the rule. Carrying only the camera is what made a
|
|
2073
1692
|
* sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
|
|
2074
1693
|
*/
|
|
2075
|
-
function subjectFromSensorEvent(ev) {
|
|
1694
|
+
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
2076
1695
|
const eventType = readSensorEventType(ev.value);
|
|
2077
1696
|
return {
|
|
2078
1697
|
kind: "device-event",
|
|
2079
1698
|
recordId: ev.id,
|
|
2080
1699
|
deviceId: ev.deviceId,
|
|
1700
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
|
|
2081
1701
|
...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
|
|
2082
1702
|
timestamp: ev.timestamp,
|
|
2083
1703
|
classNames: [],
|
|
@@ -2562,20 +2182,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
2562
2182
|
//#endregion
|
|
2563
2183
|
//#region src/notification-center/dispatcher.ts
|
|
2564
2184
|
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
2185
|
var NcDispatcher = class {
|
|
2580
2186
|
deps;
|
|
2581
2187
|
targetCache = null;
|
|
@@ -2958,32 +2564,43 @@ var NcDispatcher = class {
|
|
|
2958
2564
|
name: "zone.jpg"
|
|
2959
2565
|
} : still);
|
|
2960
2566
|
} else out.push(still);
|
|
2961
|
-
}
|
|
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
|
-
|
|
2567
|
+
} else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
|
|
2568
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
2569
|
+
meta: {
|
|
2570
|
+
recordKind: entry.recordKind,
|
|
2571
|
+
recordId: entry.recordId,
|
|
2572
|
+
policy: entry.payload.media,
|
|
2573
|
+
...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
|
|
2574
|
+
owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2577
|
+
const wantGif = entry.payload.mediaGif === true;
|
|
2578
|
+
const wantMp4 = entry.payload.mediaClip === true;
|
|
2579
|
+
if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
|
|
2580
|
+
const media = await this.deps.renderEventMedia({
|
|
2581
|
+
deviceId: entry.payload.subject.deviceId,
|
|
2582
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
2583
|
+
wantGif,
|
|
2584
|
+
wantMp4,
|
|
2585
|
+
preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
|
|
2586
|
+
postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
|
|
2587
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
|
|
2588
|
+
...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
|
|
2589
|
+
});
|
|
2590
|
+
if (media.gif !== null && media.gif.byteLength > 0) out.push({
|
|
2591
|
+
mediaType: "gif",
|
|
2592
|
+
bytes: copyBytes(media.gif),
|
|
2593
|
+
mime: "image/gif",
|
|
2594
|
+
name: "event.gif"
|
|
2595
|
+
});
|
|
2596
|
+
if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
|
|
2597
|
+
mediaType: "video",
|
|
2598
|
+
bytes: copyBytes(media.mp4),
|
|
2599
|
+
mime: "video/mp4",
|
|
2600
|
+
name: "event.mp4"
|
|
2601
|
+
});
|
|
2602
|
+
} catch (err) {
|
|
2603
|
+
this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
|
|
2987
2604
|
}
|
|
2988
2605
|
return out;
|
|
2989
2606
|
}
|
|
@@ -2994,8 +2611,8 @@ var NcDispatcher = class {
|
|
|
2994
2611
|
try {
|
|
2995
2612
|
const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
|
|
2996
2613
|
if (points.length === 0) return null;
|
|
2997
|
-
const { default: sharp$
|
|
2998
|
-
const img = sharp$
|
|
2614
|
+
const { default: sharp$8 } = await import("sharp");
|
|
2615
|
+
const img = sharp$8(Buffer.from(jpeg));
|
|
2999
2616
|
const meta = await img.metadata();
|
|
3000
2617
|
const W = meta.width ?? 0;
|
|
3001
2618
|
const H = meta.height ?? 0;
|
|
@@ -3152,6 +2769,135 @@ function paramNumber(params, key) {
|
|
|
3152
2769
|
function clampPriority(priority) {
|
|
3153
2770
|
return Math.max(1, Math.min(5, Math.round(priority)));
|
|
3154
2771
|
}
|
|
2772
|
+
/**
|
|
2773
|
+
* A copy backed by its own `ArrayBuffer`.
|
|
2774
|
+
*
|
|
2775
|
+
* The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
|
|
2776
|
+
* by an assembler may sit on a shared buffer. Copying is cheap next to the
|
|
2777
|
+
* encode that produced it and removes a whole class of aliasing question.
|
|
2778
|
+
*/
|
|
2779
|
+
function copyBytes(bytes) {
|
|
2780
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
2781
|
+
out.set(bytes);
|
|
2782
|
+
return out;
|
|
2783
|
+
}
|
|
2784
|
+
//#endregion
|
|
2785
|
+
//#region src/notification-center/event-intake.ts
|
|
2786
|
+
/** Object-event insert (movement-state transitions + zone crossings). */
|
|
2787
|
+
function incomingFromObjectEvent(event) {
|
|
2788
|
+
return {
|
|
2789
|
+
subject: subjectFromObjectEvent(event),
|
|
2790
|
+
kind: "object-event",
|
|
2791
|
+
origin: "pipeline",
|
|
2792
|
+
log: () => ({
|
|
2793
|
+
tags: { deviceId: event.deviceId },
|
|
2794
|
+
meta: { eventId: event.id }
|
|
2795
|
+
})
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
/** `TrackCloser.closeExpired` — the ONE close point. */
|
|
2799
|
+
function incomingFromTrack(track, info) {
|
|
2800
|
+
return {
|
|
2801
|
+
subject: subjectFromTrack(track, info),
|
|
2802
|
+
kind: "track-end",
|
|
2803
|
+
origin: "pipeline",
|
|
2804
|
+
log: () => ({
|
|
2805
|
+
tags: { deviceId: track.deviceId },
|
|
2806
|
+
meta: { trackId: track.trackId }
|
|
2807
|
+
})
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* Sensor-event persist (one row per linked camera).
|
|
2812
|
+
*
|
|
2813
|
+
* `markerTrackId` is the synthetic marker track the same state change
|
|
2814
|
+
* projected onto this camera — the notification's media owner, and the only
|
|
2815
|
+
* frame a device event has. Absent when no marker landed.
|
|
2816
|
+
*/
|
|
2817
|
+
function incomingFromSensorEvent(event, markerTrackId) {
|
|
2818
|
+
return {
|
|
2819
|
+
subject: subjectFromSensorEvent(event, markerTrackId),
|
|
2820
|
+
kind: "device-event",
|
|
2821
|
+
origin: "sensor",
|
|
2822
|
+
log: () => ({
|
|
2823
|
+
tags: { deviceId: event.deviceId },
|
|
2824
|
+
meta: {
|
|
2825
|
+
sensorEventId: event.id,
|
|
2826
|
+
kind: event.kind,
|
|
2827
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
|
|
2828
|
+
}
|
|
2829
|
+
})
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
/** Audio-event persist (classified episode). */
|
|
2833
|
+
function incomingFromAudioEvent(event) {
|
|
2834
|
+
return {
|
|
2835
|
+
subject: subjectFromAudioEvent(event),
|
|
2836
|
+
kind: "audio-event",
|
|
2837
|
+
origin: "pipeline",
|
|
2838
|
+
log: () => ({
|
|
2839
|
+
tags: { deviceId: event.deviceId },
|
|
2840
|
+
meta: {
|
|
2841
|
+
audioEventId: event.id,
|
|
2842
|
+
class: event.classification?.className
|
|
2843
|
+
}
|
|
2844
|
+
})
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
/** Package object-event persist (delivered / picked-up). */
|
|
2848
|
+
function incomingFromPackageEvent(event, phase) {
|
|
2849
|
+
return {
|
|
2850
|
+
subject: subjectFromPackageEvent(event, phase),
|
|
2851
|
+
kind: "package-event",
|
|
2852
|
+
origin: "pipeline",
|
|
2853
|
+
log: () => ({
|
|
2854
|
+
tags: { deviceId: event.deviceId },
|
|
2855
|
+
meta: {
|
|
2856
|
+
eventId: event.id,
|
|
2857
|
+
phase
|
|
2858
|
+
}
|
|
2859
|
+
})
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
/** A committed ZoneAnalytics occupancy edge. */
|
|
2863
|
+
function incomingFromOccupancyEdge(edge) {
|
|
2864
|
+
return {
|
|
2865
|
+
subject: subjectFromOccupancyEvent(edge),
|
|
2866
|
+
kind: "occupancy-event",
|
|
2867
|
+
origin: "pipeline",
|
|
2868
|
+
log: () => ({
|
|
2869
|
+
tags: { deviceId: edge.deviceId },
|
|
2870
|
+
meta: {
|
|
2871
|
+
zoneId: edge.zoneId ?? "@frame",
|
|
2872
|
+
className: edge.className ?? "@all",
|
|
2873
|
+
count: edge.count,
|
|
2874
|
+
threshold: edge.threshold,
|
|
2875
|
+
occupied: edge.occupied
|
|
2876
|
+
}
|
|
2877
|
+
})
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
2881
|
+
* record — same subject, honest origin in the log. */
|
|
2882
|
+
function asReconcile(event) {
|
|
2883
|
+
return {
|
|
2884
|
+
...event,
|
|
2885
|
+
origin: "reconcile"
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* The delivery an intake kind rides. `object-event` and `audio-event` both ride
|
|
2890
|
+
* `immediate` (classified audio has no delivery of its own — it is gated by the
|
|
2891
|
+
* audio-class opt-in instead); `occupancy-event` rides `device-event`.
|
|
2892
|
+
*
|
|
2893
|
+
* Extracted from the centre so the tester can tell an operator which rules a
|
|
2894
|
+
* given simulated trigger will even be considered against, without evaluating.
|
|
2895
|
+
*/
|
|
2896
|
+
function deliveryForKind(kind) {
|
|
2897
|
+
if (kind === "object-event" || kind === "audio-event") return "immediate";
|
|
2898
|
+
if (kind === "occupancy-event") return "device-event";
|
|
2899
|
+
return kind;
|
|
2900
|
+
}
|
|
3155
2901
|
//#endregion
|
|
3156
2902
|
//#region src/notification-center/occupancy-watcher.ts
|
|
3157
2903
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
@@ -4008,60 +3754,6 @@ function rowToEntry$1(id, data) {
|
|
|
4008
3754
|
};
|
|
4009
3755
|
}
|
|
4010
3756
|
//#endregion
|
|
4011
|
-
//#region src/notification-center/action-buttons.ts
|
|
4012
|
-
/** Sequence names the rule actually declares. A button may only name one. */
|
|
4013
|
-
function declaredSequences(actions) {
|
|
4014
|
-
const names = /* @__PURE__ */ new Set();
|
|
4015
|
-
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
4016
|
-
return names;
|
|
4017
|
-
}
|
|
4018
|
-
/**
|
|
4019
|
-
* Build the notification's buttons.
|
|
4020
|
-
*
|
|
4021
|
-
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
4022
|
-
* to set the field, and an empty array is the honest answer to "which buttons
|
|
4023
|
-
* survived", which `undefined` would conflate with "the rule declared none".
|
|
4024
|
-
*/
|
|
4025
|
-
function buildActionButtons(input) {
|
|
4026
|
-
const actions = input.actions;
|
|
4027
|
-
if (actions === void 0) return [];
|
|
4028
|
-
const buttons = actions.buttons ?? [];
|
|
4029
|
-
if (buttons.length === 0) return [];
|
|
4030
|
-
const known = declaredSequences(actions);
|
|
4031
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4032
|
-
const out = [];
|
|
4033
|
-
for (const button of buttons) {
|
|
4034
|
-
if (!known.has(button.sequence)) continue;
|
|
4035
|
-
if (seen.has(button.id)) continue;
|
|
4036
|
-
seen.add(button.id);
|
|
4037
|
-
out.push({
|
|
4038
|
-
id: button.id,
|
|
4039
|
-
label: button.label,
|
|
4040
|
-
url: input.mintUrl({
|
|
4041
|
-
sequence: button.sequence,
|
|
4042
|
-
actionId: button.id
|
|
4043
|
-
}),
|
|
4044
|
-
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
4045
|
-
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
4046
|
-
});
|
|
4047
|
-
}
|
|
4048
|
-
return out;
|
|
4049
|
-
}
|
|
4050
|
-
/**
|
|
4051
|
-
* Which of a rule's buttons name a sequence it does not have.
|
|
4052
|
-
*
|
|
4053
|
-
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
4054
|
-
* notification is the exact shape of failure this repo keeps paying for — the
|
|
4055
|
-
* operator authored it, it never appeared, and nothing said why.
|
|
4056
|
-
*/
|
|
4057
|
-
function unresolvableButtons(actions) {
|
|
4058
|
-
if (actions === void 0) return [];
|
|
4059
|
-
const buttons = actions.buttons ?? [];
|
|
4060
|
-
if (buttons.length === 0) return [];
|
|
4061
|
-
const known = declaredSequences(actions);
|
|
4062
|
-
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
4063
|
-
}
|
|
4064
|
-
//#endregion
|
|
4065
3757
|
//#region src/notification-center/rule-actions.ts
|
|
4066
3758
|
var NcRuleActionRunner = class {
|
|
4067
3759
|
deps;
|
|
@@ -4374,53 +4066,6 @@ var NcRuleStore = class {
|
|
|
4374
4066
|
});
|
|
4375
4067
|
}
|
|
4376
4068
|
};
|
|
4377
|
-
var NcDeviceStateCache = class {
|
|
4378
|
-
deps;
|
|
4379
|
-
states = /* @__PURE__ */ new Map();
|
|
4380
|
-
lastOkAt = 0;
|
|
4381
|
-
ids = [];
|
|
4382
|
-
now;
|
|
4383
|
-
constructor(deps) {
|
|
4384
|
-
this.deps = deps;
|
|
4385
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4386
|
-
}
|
|
4387
|
-
/**
|
|
4388
|
-
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
4389
|
-
* with no gated rule does no work at all.
|
|
4390
|
-
*/
|
|
4391
|
-
setWatched(ids) {
|
|
4392
|
-
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
4393
|
-
}
|
|
4394
|
-
watched() {
|
|
4395
|
-
return this.ids;
|
|
4396
|
-
}
|
|
4397
|
-
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
4398
|
-
get(deviceId) {
|
|
4399
|
-
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
4400
|
-
return this.states.get(deviceId);
|
|
4401
|
-
}
|
|
4402
|
-
/**
|
|
4403
|
-
* Re-read every watched state. Never throws: a gate that could fail the
|
|
4404
|
-
* evaluation tick would take the notification pipeline with it.
|
|
4405
|
-
*/
|
|
4406
|
-
async refresh() {
|
|
4407
|
-
if (this.ids.length === 0) {
|
|
4408
|
-
this.states = /* @__PURE__ */ new Map();
|
|
4409
|
-
return;
|
|
4410
|
-
}
|
|
4411
|
-
try {
|
|
4412
|
-
const next = await this.deps.readStates(this.ids);
|
|
4413
|
-
this.states = new Map(next);
|
|
4414
|
-
this.lastOkAt = this.now();
|
|
4415
|
-
for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
|
|
4416
|
-
} catch (err) {
|
|
4417
|
-
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
4418
|
-
watched: this.ids.length,
|
|
4419
|
-
error: String(err)
|
|
4420
|
-
} });
|
|
4421
|
-
}
|
|
4422
|
-
}
|
|
4423
|
-
};
|
|
4424
4069
|
//#endregion
|
|
4425
4070
|
//#region src/notification-center/snooze-digest.ts
|
|
4426
4071
|
/** `14:03` in the node's local zone — a digest is read by a person, at home. */
|
|
@@ -4537,119 +4182,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
|
|
|
4537
4182
|
return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
|
|
4538
4183
|
}
|
|
4539
4184
|
//#endregion
|
|
4540
|
-
//#region src/notification-center/device-mute-store.ts
|
|
4541
|
-
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
4542
|
-
var NC_DEVICE_MUTES_COLUMNS = [
|
|
4543
|
-
(
|
|
4544
|
-
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
4545
|
-
{
|
|
4546
|
-
name: "id",
|
|
4547
|
-
type: "TEXT",
|
|
4548
|
-
primaryKey: true,
|
|
4549
|
-
notNull: true
|
|
4550
|
-
}),
|
|
4551
|
-
{
|
|
4552
|
-
name: "deviceId",
|
|
4553
|
-
type: "INTEGER",
|
|
4554
|
-
notNull: true
|
|
4555
|
-
},
|
|
4556
|
-
(
|
|
4557
|
-
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
4558
|
-
{
|
|
4559
|
-
name: "mutedAt",
|
|
4560
|
-
type: "INTEGER",
|
|
4561
|
-
notNull: true
|
|
4562
|
-
})
|
|
4563
|
-
];
|
|
4564
|
-
/**
|
|
4565
|
-
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
4566
|
-
* the table proportional to what the operator actually silenced, and makes the
|
|
4567
|
-
* hot-path read a `Set.has`.
|
|
4568
|
-
*/
|
|
4569
|
-
var NcDeviceMuteStore = class {
|
|
4570
|
-
muted = /* @__PURE__ */ new Set();
|
|
4571
|
-
store;
|
|
4572
|
-
logger;
|
|
4573
|
-
now;
|
|
4574
|
-
constructor(deps) {
|
|
4575
|
-
this.store = deps.store;
|
|
4576
|
-
this.logger = deps.logger;
|
|
4577
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4578
|
-
}
|
|
4579
|
-
static async declare(store) {
|
|
4580
|
-
await store.declareCollection.mutate({
|
|
4581
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4582
|
-
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
4583
|
-
});
|
|
4584
|
-
}
|
|
4585
|
-
/**
|
|
4586
|
-
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
4587
|
-
* clearing it: dropping every mute on a transient store error would start
|
|
4588
|
-
* notifying for a camera the operator silenced, without them asking — the
|
|
4589
|
-
* same reasoning as `NcSnoozeStore.load`.
|
|
4590
|
-
*/
|
|
4591
|
-
async load() {
|
|
4592
|
-
try {
|
|
4593
|
-
const rows = await this.store.query.query({
|
|
4594
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4595
|
-
filter: { limit: 1e4 }
|
|
4596
|
-
});
|
|
4597
|
-
const next = /* @__PURE__ */ new Set();
|
|
4598
|
-
let skipped = 0;
|
|
4599
|
-
for (const row of rows) {
|
|
4600
|
-
const raw = row.data["deviceId"];
|
|
4601
|
-
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
4602
|
-
skipped += 1;
|
|
4603
|
-
continue;
|
|
4604
|
-
}
|
|
4605
|
-
next.add(raw);
|
|
4606
|
-
}
|
|
4607
|
-
this.muted.clear();
|
|
4608
|
-
for (const id of next) this.muted.add(id);
|
|
4609
|
-
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
4610
|
-
} catch (err) {
|
|
4611
|
-
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
4612
|
-
error: String(err),
|
|
4613
|
-
muted: this.muted.size
|
|
4614
|
-
} });
|
|
4615
|
-
}
|
|
4616
|
-
}
|
|
4617
|
-
/** Hot-path read, called once per evaluated subject. */
|
|
4618
|
-
isMuted(deviceId) {
|
|
4619
|
-
return this.muted.has(deviceId);
|
|
4620
|
-
}
|
|
4621
|
-
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
4622
|
-
listMuted() {
|
|
4623
|
-
return [...this.muted].toSorted((a, b) => a - b);
|
|
4624
|
-
}
|
|
4625
|
-
/**
|
|
4626
|
-
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
4627
|
-
* updated only after the write lands, so a failed write cannot leave the
|
|
4628
|
-
* hot path believing a camera is silenced when the next boot will not.
|
|
4629
|
-
*/
|
|
4630
|
-
async setMuted(deviceId, muted) {
|
|
4631
|
-
const id = String(deviceId);
|
|
4632
|
-
if (muted) {
|
|
4633
|
-
await this.store.set.mutate({
|
|
4634
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4635
|
-
key: id,
|
|
4636
|
-
value: {
|
|
4637
|
-
id,
|
|
4638
|
-
deviceId,
|
|
4639
|
-
mutedAt: this.now()
|
|
4640
|
-
}
|
|
4641
|
-
});
|
|
4642
|
-
this.muted.add(deviceId);
|
|
4643
|
-
} else {
|
|
4644
|
-
await this.store.delete.mutate({
|
|
4645
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4646
|
-
key: id
|
|
4647
|
-
});
|
|
4648
|
-
this.muted.delete(deviceId);
|
|
4649
|
-
}
|
|
4650
|
-
}
|
|
4651
|
-
};
|
|
4652
|
-
//#endregion
|
|
4653
4185
|
//#region src/notification-center/snooze-store.ts
|
|
4654
4186
|
/**
|
|
4655
4187
|
* NcSnoozeStore — the durable half of snooze.
|
|
@@ -5003,6 +4535,314 @@ var NcSnoozeStore = class {
|
|
|
5003
4535
|
}
|
|
5004
4536
|
};
|
|
5005
4537
|
//#endregion
|
|
4538
|
+
//#region src/notification-center/test-event.ts
|
|
4539
|
+
/** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
|
|
4540
|
+
var BboxSchema = require_dist.object({
|
|
4541
|
+
x: require_dist.number().min(0).max(1),
|
|
4542
|
+
y: require_dist.number().min(0).max(1),
|
|
4543
|
+
w: require_dist.number().min(0).max(1),
|
|
4544
|
+
h: require_dist.number().min(0).max(1)
|
|
4545
|
+
});
|
|
4546
|
+
/**
|
|
4547
|
+
* Which trigger to simulate. One value per {@link NcSubjectKind}, because the
|
|
4548
|
+
* kind is what decides the delivery a rule must declare to be a candidate at
|
|
4549
|
+
* all — picking the wrong one is the single most common reason an operator's
|
|
4550
|
+
* rule "does not fire", and making it an explicit choice is half the point of
|
|
4551
|
+
* the tester.
|
|
4552
|
+
*/
|
|
4553
|
+
var TriggerSchema = require_dist._enum([
|
|
4554
|
+
"object-event",
|
|
4555
|
+
"track-end",
|
|
4556
|
+
"device-event",
|
|
4557
|
+
"package-event",
|
|
4558
|
+
"audio-event",
|
|
4559
|
+
"occupancy-event"
|
|
4560
|
+
]);
|
|
4561
|
+
/**
|
|
4562
|
+
* What the operator typed. Every field maps onto exactly one field of
|
|
4563
|
+
* {@link NcRuleSubject} — there is no test-only condition input, because a
|
|
4564
|
+
* condition the tester could set and a real record could not is a condition the
|
|
4565
|
+
* test result would lie about.
|
|
4566
|
+
*/
|
|
4567
|
+
var NcTestEventInputSchema = require_dist.object({
|
|
4568
|
+
trigger: TriggerSchema,
|
|
4569
|
+
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
4570
|
+
deviceId: require_dist.number().int(),
|
|
4571
|
+
/** DEVICE-EVENT: the linked device whose state changed — the contact, the
|
|
4572
|
+
* button, the doorbell — when that is not the camera. */
|
|
4573
|
+
sourceDeviceId: require_dist.number().int().optional(),
|
|
4574
|
+
/** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
|
|
4575
|
+
classNames: require_dist.array(require_dist.string()).default([]),
|
|
4576
|
+
/** Collapsed display label — an identity name, a plate, a subclass. */
|
|
4577
|
+
label: require_dist.string().optional(),
|
|
4578
|
+
/** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
|
|
4579
|
+
* condition FAILS on an absent confidence, and the tester must be able to
|
|
4580
|
+
* reproduce that. */
|
|
4581
|
+
confidence: require_dist.number().min(0).max(1).optional(),
|
|
4582
|
+
/** Identity/plate MATCH confidence — distinct from `confidence`. */
|
|
4583
|
+
labelConfidence: require_dist.number().min(0).max(1).optional(),
|
|
4584
|
+
/** Key-event importance [0,1] (track-end only, in practice). */
|
|
4585
|
+
importance: require_dist.number().min(0).max(1).optional(),
|
|
4586
|
+
/** Track lifespan in seconds (track-end only). */
|
|
4587
|
+
dwellSeconds: require_dist.number().min(0).optional(),
|
|
4588
|
+
/** Admin zone ids the subject is in (object) / visited (track). */
|
|
4589
|
+
zones: require_dist.array(require_dist.string()).default([]),
|
|
4590
|
+
/** OBJECT-EVENT: simulate a confirmed zone crossing. */
|
|
4591
|
+
crossing: require_dist.object({
|
|
4592
|
+
zoneId: require_dist.string(),
|
|
4593
|
+
direction: require_dist._enum(["enter", "exit"])
|
|
4594
|
+
}).optional(),
|
|
4595
|
+
/** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
|
|
4596
|
+
sensorKind: require_dist.string().optional(),
|
|
4597
|
+
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
4598
|
+
* event-emitter slice. */
|
|
4599
|
+
eventType: require_dist.string().optional(),
|
|
4600
|
+
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
4601
|
+
packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
|
|
4602
|
+
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
4603
|
+
* source reads as). */
|
|
4604
|
+
source: require_dist._enum([
|
|
4605
|
+
"pipeline",
|
|
4606
|
+
"onboard",
|
|
4607
|
+
"sensor",
|
|
4608
|
+
"audio"
|
|
4609
|
+
]).optional(),
|
|
4610
|
+
/** Normalized bbox — feeds `customZones`, AND is what lets the still be
|
|
4611
|
+
* cropped / boxed the way the rule's media policy asks. */
|
|
4612
|
+
bbox: BboxSchema.optional(),
|
|
4613
|
+
/** OCCUPANCY-EVENT: the committed count edge. */
|
|
4614
|
+
occupancy: require_dist.object({
|
|
4615
|
+
zoneId: require_dist.string().optional(),
|
|
4616
|
+
zoneName: require_dist.string().optional(),
|
|
4617
|
+
className: require_dist.string().optional(),
|
|
4618
|
+
count: require_dist.number().int().min(0),
|
|
4619
|
+
previousCount: require_dist.number().int().min(0).default(0),
|
|
4620
|
+
occupied: require_dist.boolean(),
|
|
4621
|
+
threshold: require_dist.number().int().min(1)
|
|
4622
|
+
}).optional(),
|
|
4623
|
+
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
4624
|
+
* condition without waiting for the hour. */
|
|
4625
|
+
timestamp: require_dist.number().int().optional(),
|
|
4626
|
+
/** Evaluate only this rule (what the per-rule Test button sends). */
|
|
4627
|
+
onlyRuleId: require_dist.string().optional(),
|
|
4628
|
+
bypassCooldown: require_dist.boolean().default(false),
|
|
4629
|
+
bypassMute: require_dist.boolean().default(false),
|
|
4630
|
+
/** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
|
|
4631
|
+
* not open a gate the operator did not ask to open. */
|
|
4632
|
+
runActions: require_dist.boolean().default(false),
|
|
4633
|
+
/** Where the still comes from. `live` takes a snapshot of the camera NOW;
|
|
4634
|
+
* `none` sends the notification without one. */
|
|
4635
|
+
still: require_dist._enum(["live", "none"]).default("live")
|
|
4636
|
+
});
|
|
4637
|
+
/** Per-rule verdict, as the tester reports it. */
|
|
4638
|
+
var NcTestEventRuleOutcomeSchema = require_dist.object({
|
|
4639
|
+
ruleId: require_dist.string(),
|
|
4640
|
+
ruleName: require_dist.string(),
|
|
4641
|
+
matched: require_dist.boolean(),
|
|
4642
|
+
failedCondition: require_dist.string().optional(),
|
|
4643
|
+
matchedOn: require_dist.array(require_dist.string()).optional(),
|
|
4644
|
+
cooldown: require_dist._enum([
|
|
4645
|
+
"n/a",
|
|
4646
|
+
"clear",
|
|
4647
|
+
"suppressed",
|
|
4648
|
+
"bypassed"
|
|
4649
|
+
]),
|
|
4650
|
+
enqueued: require_dist.number().int(),
|
|
4651
|
+
targetIds: require_dist.array(require_dist.string())
|
|
4652
|
+
});
|
|
4653
|
+
var NcTestEventResultSchema = require_dist.object({
|
|
4654
|
+
/** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
|
|
4655
|
+
* for in the logs, in the outbox and in the delivery history. */
|
|
4656
|
+
recordId: require_dist.string(),
|
|
4657
|
+
/** Always `synthetic` — the marker the whole path carried. */
|
|
4658
|
+
origin: require_dist.literal("synthetic"),
|
|
4659
|
+
/** The delivery the chosen trigger rides. A rule declaring anything else was
|
|
4660
|
+
* never a candidate, which is the answer to most "it did not fire". */
|
|
4661
|
+
delivery: require_dist.string(),
|
|
4662
|
+
candidates: require_dist.number().int(),
|
|
4663
|
+
outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
|
|
4664
|
+
/** What the still ended up being. `unattachable` means the chosen TRIGGER
|
|
4665
|
+
* cannot carry one at all — an audio episode and an occupancy edge freeze no
|
|
4666
|
+
* media owner, so those notifications ship text-only in production too, and
|
|
4667
|
+
* that is a property of production, not of the test. A DEVICE event is no
|
|
4668
|
+
* longer one of them: its press projects a marker track that owns the frame. */
|
|
4669
|
+
still: require_dist._enum([
|
|
4670
|
+
"live",
|
|
4671
|
+
"none",
|
|
4672
|
+
"unavailable",
|
|
4673
|
+
"unattachable"
|
|
4674
|
+
]),
|
|
4675
|
+
/** Everything the operator has to know to read the result honestly. */
|
|
4676
|
+
notes: require_dist.array(require_dist.string()),
|
|
4677
|
+
droppedBy: require_dist.string().optional()
|
|
4678
|
+
});
|
|
4679
|
+
/** The id shape every synthetic record carries. One prefix, everywhere. */
|
|
4680
|
+
var SYNTHETIC_ID_PREFIX = "synthetic:";
|
|
4681
|
+
/** True for a record id this module minted. Used by the media shim. */
|
|
4682
|
+
function isSyntheticId(id) {
|
|
4683
|
+
return id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
4684
|
+
}
|
|
4685
|
+
/**
|
|
4686
|
+
* Whether a subject of this kind can carry a still AT ALL, in production.
|
|
4687
|
+
*
|
|
4688
|
+
* The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
|
|
4689
|
+
* owner onto the payload only for the kinds that have one:
|
|
4690
|
+
*
|
|
4691
|
+
* - an `eventId` for object and package events;
|
|
4692
|
+
* - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
|
|
4693
|
+
* DEVICE event too: the same state change projects a synthetic marker track
|
|
4694
|
+
* carrying the camera's frame (`sensor-marker-projector.ts`), and the
|
|
4695
|
+
* notification names it as its owner.
|
|
4696
|
+
*
|
|
4697
|
+
* An audio episode and an occupancy edge still freeze neither. Audio HAS a
|
|
4698
|
+
* marker projection, but its id does not reach the notification subject yet;
|
|
4699
|
+
* occupancy has no projection at all. Those two therefore remain honestly
|
|
4700
|
+
* unattachable — a tester that invented a picture for them would be advertising
|
|
4701
|
+
* a feature that does not exist.
|
|
4702
|
+
*/
|
|
4703
|
+
function triggerCanCarryStill(kind) {
|
|
4704
|
+
return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
|
|
4705
|
+
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
4708
|
+
* event, so the red-green test can assert that this subject and a
|
|
4709
|
+
* producer-built one are indistinguishable.
|
|
4710
|
+
*/
|
|
4711
|
+
function buildSyntheticEvent(input, recordId, now) {
|
|
4712
|
+
const kind = input.trigger;
|
|
4713
|
+
const timestamp = input.timestamp ?? now;
|
|
4714
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
|
|
4715
|
+
return {
|
|
4716
|
+
subject: {
|
|
4717
|
+
kind,
|
|
4718
|
+
recordId,
|
|
4719
|
+
deviceId: input.deviceId,
|
|
4720
|
+
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
4721
|
+
timestamp,
|
|
4722
|
+
classNames: input.classNames,
|
|
4723
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
4724
|
+
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
4725
|
+
zones: input.zones,
|
|
4726
|
+
...carriesTrackId ? { trackId: recordId } : {},
|
|
4727
|
+
source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
|
|
4728
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
4729
|
+
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
4730
|
+
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
4731
|
+
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
4732
|
+
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
4733
|
+
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
4734
|
+
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
4735
|
+
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
4736
|
+
...input.occupancy !== void 0 ? { occupancy: {
|
|
4737
|
+
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
4738
|
+
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
4739
|
+
...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
|
|
4740
|
+
count: input.occupancy.count,
|
|
4741
|
+
previousCount: input.occupancy.previousCount,
|
|
4742
|
+
occupied: input.occupancy.occupied,
|
|
4743
|
+
threshold: input.occupancy.threshold
|
|
4744
|
+
} } : {}
|
|
4745
|
+
},
|
|
4746
|
+
kind,
|
|
4747
|
+
origin: "synthetic",
|
|
4748
|
+
controls: {
|
|
4749
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
|
|
4750
|
+
bypassCooldown: input.bypassCooldown,
|
|
4751
|
+
bypassMute: input.bypassMute,
|
|
4752
|
+
runActions: input.runActions
|
|
4753
|
+
},
|
|
4754
|
+
log: () => ({
|
|
4755
|
+
tags: { deviceId: input.deviceId },
|
|
4756
|
+
meta: {
|
|
4757
|
+
eventId: recordId,
|
|
4758
|
+
synthetic: true,
|
|
4759
|
+
trigger: input.trigger,
|
|
4760
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
4761
|
+
}
|
|
4762
|
+
})
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
4765
|
+
/**
|
|
4766
|
+
* Turn one live JPEG into the media set the dispatcher's ladders look for.
|
|
4767
|
+
*
|
|
4768
|
+
* Every entry is the SAME photograph, differing only in how it was cut — which
|
|
4769
|
+
* is exactly what the ladders mean by their kind names, so nothing here claims
|
|
4770
|
+
* a frame it does not have:
|
|
4771
|
+
*
|
|
4772
|
+
* - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
|
|
4773
|
+
* - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
|
|
4774
|
+
* - `crop` / `thumbnail` — that bbox, cut out.
|
|
4775
|
+
*
|
|
4776
|
+
* With NO bbox only the clean-scene kinds are produced, so a rule asking for
|
|
4777
|
+
* `frame: 'cropped'` correctly finds nothing rather than being handed a whole
|
|
4778
|
+
* scene it did not ask for. sharp failures degrade the same way: the clean
|
|
4779
|
+
* scene always survives.
|
|
4780
|
+
*/
|
|
4781
|
+
async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
4782
|
+
const base64 = Buffer.from(jpeg).toString("base64");
|
|
4783
|
+
const clean = (kind) => ({
|
|
4784
|
+
key: `${SYNTHETIC_ID_PREFIX}${kind}`,
|
|
4785
|
+
kind,
|
|
4786
|
+
base64,
|
|
4787
|
+
sizeBytes: jpeg.byteLength,
|
|
4788
|
+
timestamp
|
|
4789
|
+
});
|
|
4790
|
+
const files = [
|
|
4791
|
+
clean("fullFrame"),
|
|
4792
|
+
clean("keyFrame"),
|
|
4793
|
+
clean("firstFrame")
|
|
4794
|
+
];
|
|
4795
|
+
if (bbox === void 0) return files;
|
|
4796
|
+
try {
|
|
4797
|
+
const { default: sharp$7 } = await import("sharp");
|
|
4798
|
+
const meta = await sharp$7(Buffer.from(jpeg)).metadata();
|
|
4799
|
+
const W = meta.width ?? 0;
|
|
4800
|
+
const H = meta.height ?? 0;
|
|
4801
|
+
if (W === 0 || H === 0) return files;
|
|
4802
|
+
const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
|
|
4803
|
+
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
4804
|
+
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
4805
|
+
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
4806
|
+
const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
|
|
4807
|
+
left,
|
|
4808
|
+
top,
|
|
4809
|
+
width,
|
|
4810
|
+
height
|
|
4811
|
+
}).jpeg({ quality: 85 }).toBuffer();
|
|
4812
|
+
const cropB64 = cropBuf.toString("base64");
|
|
4813
|
+
files.push({
|
|
4814
|
+
key: `${SYNTHETIC_ID_PREFIX}crop`,
|
|
4815
|
+
kind: "crop",
|
|
4816
|
+
base64: cropB64,
|
|
4817
|
+
sizeBytes: cropBuf.byteLength,
|
|
4818
|
+
timestamp
|
|
4819
|
+
}, {
|
|
4820
|
+
key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
|
|
4821
|
+
kind: "thumbnail",
|
|
4822
|
+
base64: cropB64,
|
|
4823
|
+
sizeBytes: cropBuf.byteLength,
|
|
4824
|
+
timestamp
|
|
4825
|
+
});
|
|
4826
|
+
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
4827
|
+
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>`);
|
|
4828
|
+
const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
|
|
4829
|
+
input: overlay,
|
|
4830
|
+
top: 0,
|
|
4831
|
+
left: 0
|
|
4832
|
+
}]).jpeg({ quality: 85 }).toBuffer();
|
|
4833
|
+
files.push({
|
|
4834
|
+
key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
|
|
4835
|
+
kind: "fullFrameBoxed",
|
|
4836
|
+
base64: boxedBuf.toString("base64"),
|
|
4837
|
+
sizeBytes: boxedBuf.byteLength,
|
|
4838
|
+
timestamp
|
|
4839
|
+
});
|
|
4840
|
+
} catch {
|
|
4841
|
+
return files;
|
|
4842
|
+
}
|
|
4843
|
+
return files;
|
|
4844
|
+
}
|
|
4845
|
+
//#endregion
|
|
5006
4846
|
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
5007
4847
|
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
5008
4848
|
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
@@ -5309,6 +5149,16 @@ var WATERMARK_EVERY_TICKS = 15;
|
|
|
5309
5149
|
var OUTBOX_RETENTION_MS = 168 * 36e5;
|
|
5310
5150
|
var TEST_RULE_MAX_RESULTS = 200;
|
|
5311
5151
|
/**
|
|
5152
|
+
* How long a synthetic injection's live snapshot stays in RAM.
|
|
5153
|
+
*
|
|
5154
|
+
* Long enough to cover the drain tick plus the first handful of outbox
|
|
5155
|
+
* backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
|
|
5156
|
+
* cannot hold a JPEG for the life of the process. A row still retrying after
|
|
5157
|
+
* this loses its picture and ships text-only — the same degradation a real
|
|
5158
|
+
* event gets when its media never landed.
|
|
5159
|
+
*/
|
|
5160
|
+
var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
|
|
5161
|
+
/**
|
|
5312
5162
|
* The className every durable package event carries (mirrors
|
|
5313
5163
|
* `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
|
|
5314
5164
|
* stays free of a cross-module import into the pipeline). Delivery events use
|
|
@@ -5422,6 +5272,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5422
5272
|
alarmPanel = null;
|
|
5423
5273
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
5424
5274
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
5275
|
+
/**
|
|
5276
|
+
* The live snapshot a synthetic injection took, held for as long as the
|
|
5277
|
+
* outbox may still be retrying its rows. In RAM and only in RAM: a test that
|
|
5278
|
+
* left a media blob behind would be a test that persisted something, which
|
|
5279
|
+
* is the one thing the synthetic producer promises not to do.
|
|
5280
|
+
*/
|
|
5281
|
+
syntheticMedia = /* @__PURE__ */ new Map();
|
|
5425
5282
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
5426
5283
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
5427
5284
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -5579,6 +5436,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5579
5436
|
this.dispatcher = new NcDispatcher({
|
|
5580
5437
|
...deps.dispatcher,
|
|
5581
5438
|
logger: this.logger.child("dispatch"),
|
|
5439
|
+
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
5440
|
+
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
5441
|
+
if (held !== void 0) return held.files;
|
|
5442
|
+
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
5443
|
+
},
|
|
5582
5444
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
5583
5445
|
isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
|
|
5584
5446
|
snoozeGate: (input) => this.snoozeGate(input)
|
|
@@ -5657,19 +5519,157 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5657
5519
|
this.evaluationActive = false;
|
|
5658
5520
|
}
|
|
5659
5521
|
/**
|
|
5522
|
+
* Consume ONE event. THE entry point of the notification path — every
|
|
5523
|
+
* producer arrives here and nothing else does.
|
|
5524
|
+
*
|
|
5525
|
+
* Fire-and-forget: the frame path must never await a notification. The
|
|
5526
|
+
* evaluation is appended to the serialized chain, so consumption order is
|
|
5527
|
+
* persist order (see {@link evalChain}).
|
|
5528
|
+
*
|
|
5529
|
+
* It persists nothing about the event itself. Whether a durable row exists is
|
|
5530
|
+
* the PRODUCER's business — the tracker writes its tracks, the sensor ingest
|
|
5531
|
+
* its rows, the synthetic tester nothing at all — and the only thing written
|
|
5532
|
+
* from here on is the outbox row, which is the notification's own delivery
|
|
5533
|
+
* queue and not a record of the event.
|
|
5534
|
+
*/
|
|
5535
|
+
consumeEvent(event) {
|
|
5536
|
+
if (!this.evaluationActive) return;
|
|
5537
|
+
this.scheduleEvaluation(event);
|
|
5538
|
+
}
|
|
5539
|
+
/**
|
|
5540
|
+
* The same consumption, awaited, with the verdict returned. Identical code
|
|
5541
|
+
* path — {@link consumeEvent} is this function with the report discarded — so
|
|
5542
|
+
* a producer that reads the report is testing what the live producers do, not
|
|
5543
|
+
* a parallel implementation of it.
|
|
5544
|
+
*/
|
|
5545
|
+
async consumeEventAndReport(event) {
|
|
5546
|
+
if (!this.evaluationActive) return {
|
|
5547
|
+
recordId: event.subject.recordId,
|
|
5548
|
+
kind: event.kind,
|
|
5549
|
+
delivery: deliveryForKind(event.kind),
|
|
5550
|
+
origin: event.origin,
|
|
5551
|
+
candidates: 0,
|
|
5552
|
+
outcomes: [],
|
|
5553
|
+
droppedBy: "evaluation-inactive"
|
|
5554
|
+
};
|
|
5555
|
+
const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
|
|
5556
|
+
this.evalChain = result.then(() => void 0, () => void 0);
|
|
5557
|
+
return result;
|
|
5558
|
+
}
|
|
5559
|
+
/**
|
|
5560
|
+
* The SYNTHETIC producer — an operator-driven event, through the whole path.
|
|
5561
|
+
*
|
|
5562
|
+
* It builds an envelope and calls {@link consumeEventAndReport}. That is the
|
|
5563
|
+
* entire integration: no bypass, no second matcher, no shortcut into the
|
|
5564
|
+
* dispatcher. What the operator receives on their phone was matched by
|
|
5565
|
+
* `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
|
|
5566
|
+
* tick, given media by the ordinary ladders, degraded by the ordinary engine
|
|
5567
|
+
* and delivered by the ordinary notifier.
|
|
5568
|
+
*
|
|
5569
|
+
* What it does NOT do is persist the event. No track row, no object-event
|
|
5570
|
+
* row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
|
|
5571
|
+
* expiry. The outbox row is the exception that proves the rule: it is the
|
|
5572
|
+
* notification's delivery queue, not a record of the event, and it carries
|
|
5573
|
+
* `synthetic: true` so it can never be mistaken for one.
|
|
5574
|
+
*/
|
|
5575
|
+
async injectTestEvent(input, deps = {}) {
|
|
5576
|
+
const now = this.now();
|
|
5577
|
+
this.pruneSyntheticMedia(now);
|
|
5578
|
+
const recordId = `${SYNTHETIC_ID_PREFIX}${(0, node_crypto.randomUUID)()}`;
|
|
5579
|
+
const event = buildSyntheticEvent(input, recordId, now);
|
|
5580
|
+
const notes = [];
|
|
5581
|
+
let still = "none";
|
|
5582
|
+
if (!triggerCanCarryStill(event.kind)) {
|
|
5583
|
+
still = "unattachable";
|
|
5584
|
+
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`);
|
|
5585
|
+
} else if (input.still === "live") {
|
|
5586
|
+
const jpeg = await this.captureLiveStill(input.deviceId, deps);
|
|
5587
|
+
if (jpeg === null) {
|
|
5588
|
+
still = "unavailable";
|
|
5589
|
+
notes.push("the camera returned no snapshot — the notification ships without a still");
|
|
5590
|
+
} else {
|
|
5591
|
+
const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
|
|
5592
|
+
this.syntheticMedia.set(recordId, {
|
|
5593
|
+
files,
|
|
5594
|
+
expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
|
|
5595
|
+
});
|
|
5596
|
+
still = "live";
|
|
5597
|
+
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");
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
|
|
5601
|
+
if (input.bypassMute) notes.push("per-camera notification mute bypassed");
|
|
5602
|
+
if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
|
|
5603
|
+
this.logger.info("synthetic event injected", {
|
|
5604
|
+
tags: { deviceId: input.deviceId },
|
|
5605
|
+
meta: {
|
|
5606
|
+
eventId: recordId,
|
|
5607
|
+
synthetic: true,
|
|
5608
|
+
trigger: input.trigger,
|
|
5609
|
+
classes: input.classNames,
|
|
5610
|
+
zones: input.zones,
|
|
5611
|
+
still,
|
|
5612
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
5613
|
+
}
|
|
5614
|
+
});
|
|
5615
|
+
const report = await this.consumeEventAndReport(event);
|
|
5616
|
+
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}"`);
|
|
5617
|
+
if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
|
|
5618
|
+
if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
|
|
5619
|
+
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");
|
|
5620
|
+
return {
|
|
5621
|
+
recordId,
|
|
5622
|
+
origin: "synthetic",
|
|
5623
|
+
delivery: report.delivery,
|
|
5624
|
+
candidates: report.candidates,
|
|
5625
|
+
outcomes: report.outcomes.map((o) => ({
|
|
5626
|
+
ruleId: o.ruleId,
|
|
5627
|
+
ruleName: o.ruleName,
|
|
5628
|
+
matched: o.matched,
|
|
5629
|
+
...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
|
|
5630
|
+
...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
|
|
5631
|
+
cooldown: o.cooldown,
|
|
5632
|
+
enqueued: o.enqueued,
|
|
5633
|
+
targetIds: [...o.targetIds]
|
|
5634
|
+
})),
|
|
5635
|
+
still,
|
|
5636
|
+
notes,
|
|
5637
|
+
...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
|
|
5638
|
+
};
|
|
5639
|
+
}
|
|
5640
|
+
/** One live frame, or null. Never throws into an injection. */
|
|
5641
|
+
async captureLiveStill(deviceId, deps) {
|
|
5642
|
+
const capture = deps.getSnapshot;
|
|
5643
|
+
if (capture === void 0) return null;
|
|
5644
|
+
try {
|
|
5645
|
+
const shot = await capture(deviceId);
|
|
5646
|
+
if (shot === null || shot.base64.length === 0) return null;
|
|
5647
|
+
const buf = Buffer.from(shot.base64, "base64");
|
|
5648
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
5649
|
+
bytes.set(buf);
|
|
5650
|
+
return bytes;
|
|
5651
|
+
} catch (err) {
|
|
5652
|
+
this.logger.warn("synthetic injection could not photograph the camera", {
|
|
5653
|
+
tags: { deviceId },
|
|
5654
|
+
meta: { error: String(err) }
|
|
5655
|
+
});
|
|
5656
|
+
return null;
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
/** Drop expired synthetic stills. Called on every injection — there is no
|
|
5660
|
+
* timer, because the map is empty except while a test is in flight. */
|
|
5661
|
+
pruneSyntheticMedia(now) {
|
|
5662
|
+
for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
|
|
5663
|
+
}
|
|
5664
|
+
/**
|
|
5660
5665
|
* Called at the object-event persist site, in the SAME moment as the
|
|
5661
5666
|
* durable insert. Fire-and-forget from the frame path (never blocks the
|
|
5662
5667
|
* pipeline); the boot reconcile covers a crash between persist and
|
|
5663
5668
|
* outbox insert.
|
|
5664
5669
|
*/
|
|
5665
5670
|
onObjectEventPersisted(event, _track) {
|
|
5666
|
-
if (!this.evaluationActive) return;
|
|
5667
5671
|
if (packagePhaseOf(event) !== null) return;
|
|
5668
|
-
|
|
5669
|
-
this.scheduleEvaluation(subject, "object-event", () => ({
|
|
5670
|
-
tags: { deviceId: event.deviceId },
|
|
5671
|
-
meta: { eventId: event.id }
|
|
5672
|
-
}));
|
|
5672
|
+
this.consumeEvent(incomingFromObjectEvent(event));
|
|
5673
5673
|
}
|
|
5674
5674
|
/**
|
|
5675
5675
|
* Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
|
|
@@ -5678,18 +5678,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5678
5678
|
* post-close promotions are picked up).
|
|
5679
5679
|
*/
|
|
5680
5680
|
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
|
-
}));
|
|
5681
|
+
this.consumeEvent(incomingFromTrack(track, info));
|
|
5687
5682
|
}
|
|
5688
5683
|
/**
|
|
5689
|
-
* Called
|
|
5690
|
-
*
|
|
5691
|
-
* `device-event` trigger (doorbell press / sensor state
|
|
5692
|
-
* forget from the ingest loop; the outbox owns delivery
|
|
5684
|
+
* Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
|
|
5685
|
+
* camera), once the same state change has had its chance to project a marker
|
|
5686
|
+
* track. Feeds the `device-event` trigger (doorbell press / sensor state
|
|
5687
|
+
* change). Fire-and-forget from the ingest loop; the outbox owns delivery.
|
|
5688
|
+
*
|
|
5689
|
+
* `markerTrackId` is that marker — the synthetic track carrying the camera's
|
|
5690
|
+
* frame — and it is what gives this notification a picture: the payload
|
|
5691
|
+
* freezes it as the media owner and the dispatcher resolves it through the
|
|
5692
|
+
* ladder every other attachment uses. Absent means no marker landed, and the
|
|
5693
|
+
* notification is text-only; that is logged, per camera, at the enqueue.
|
|
5693
5694
|
*
|
|
5694
5695
|
* Delivery-grade boundary (honest): the SensorEventStore is itself fed from
|
|
5695
5696
|
* the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
|
|
@@ -5698,16 +5699,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5698
5699
|
* device-event notification — the durable guarantee begins at this hook, not
|
|
5699
5700
|
* before it.
|
|
5700
5701
|
*/
|
|
5701
|
-
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
|
-
}));
|
|
5702
|
+
onSensorEventPersisted(event, markerTrackId) {
|
|
5703
|
+
this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
|
|
5711
5704
|
}
|
|
5712
5705
|
/**
|
|
5713
5706
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -5722,15 +5715,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5722
5715
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
5723
5716
|
*/
|
|
5724
5717
|
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
|
-
}));
|
|
5718
|
+
this.consumeEvent(incomingFromAudioEvent(event));
|
|
5734
5719
|
}
|
|
5735
5720
|
/**
|
|
5736
5721
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -5739,15 +5724,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5739
5724
|
* crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
|
|
5740
5725
|
*/
|
|
5741
5726
|
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
|
-
}));
|
|
5727
|
+
this.consumeEvent(incomingFromPackageEvent(event, phase));
|
|
5751
5728
|
}
|
|
5752
5729
|
/**
|
|
5753
5730
|
* Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
|
|
@@ -5783,18 +5760,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5783
5760
|
* Public so a test / a future out-of-band edge source can drive it directly.
|
|
5784
5761
|
*/
|
|
5785
5762
|
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
|
-
}));
|
|
5763
|
+
this.consumeEvent(incomingFromOccupancyEdge(edge));
|
|
5798
5764
|
}
|
|
5799
5765
|
buildProvider() {
|
|
5800
5766
|
return {
|
|
@@ -5891,7 +5857,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5891
5857
|
}
|
|
5892
5858
|
/**
|
|
5893
5859
|
* Adopt the panel this node owns. Called once by the addon after
|
|
5894
|
-
* `
|
|
5860
|
+
* `declareAlarmPanel`; a node without one never calls it and the cap then
|
|
5895
5861
|
* answers `deviceId: null`.
|
|
5896
5862
|
*/
|
|
5897
5863
|
setAlarmPanel(panel) {
|
|
@@ -5969,48 +5935,74 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5969
5935
|
});
|
|
5970
5936
|
}
|
|
5971
5937
|
/** Append one evaluation to the serialized chain (see {@link evalChain}). */
|
|
5972
|
-
scheduleEvaluation(
|
|
5938
|
+
scheduleEvaluation(event) {
|
|
5973
5939
|
this.evalChain = this.evalChain.then(async () => {
|
|
5974
5940
|
try {
|
|
5975
|
-
await this.evaluateAndEnqueue(
|
|
5941
|
+
await this.evaluateAndEnqueue(event);
|
|
5976
5942
|
} catch (err) {
|
|
5977
|
-
const ctx =
|
|
5943
|
+
const ctx = event.log();
|
|
5978
5944
|
this.logger.warn("rule evaluation failed", {
|
|
5979
5945
|
tags: ctx.tags,
|
|
5980
5946
|
meta: {
|
|
5981
5947
|
...ctx.meta,
|
|
5982
|
-
kind,
|
|
5948
|
+
kind: event.kind,
|
|
5949
|
+
origin: event.origin,
|
|
5983
5950
|
error: String(err)
|
|
5984
5951
|
}
|
|
5985
5952
|
});
|
|
5986
5953
|
}
|
|
5987
5954
|
});
|
|
5988
5955
|
}
|
|
5989
|
-
async evaluateAndEnqueue(
|
|
5990
|
-
|
|
5956
|
+
async evaluateAndEnqueue(event) {
|
|
5957
|
+
const { subject, kind, origin } = event;
|
|
5958
|
+
const controls = event.controls ?? {};
|
|
5959
|
+
const outcomes = [];
|
|
5960
|
+
const report = (extra = {}) => ({
|
|
5961
|
+
recordId: subject.recordId,
|
|
5962
|
+
kind,
|
|
5963
|
+
delivery: deliveryForKind(kind),
|
|
5964
|
+
origin,
|
|
5965
|
+
candidates: 0,
|
|
5966
|
+
outcomes,
|
|
5967
|
+
...extra
|
|
5968
|
+
});
|
|
5969
|
+
if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
|
|
5991
5970
|
this.reportMutedDrop(subject, kind, this.now());
|
|
5992
|
-
return;
|
|
5971
|
+
return report({ droppedBy: "muted" });
|
|
5993
5972
|
}
|
|
5994
|
-
const delivery = kind
|
|
5995
|
-
const
|
|
5973
|
+
const delivery = deliveryForKind(kind);
|
|
5974
|
+
const enabled = this.rules.listEnabled(delivery);
|
|
5975
|
+
const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
|
|
5996
5976
|
if (candidates.length === 0) {
|
|
5997
5977
|
this.logger.debug("no enabled rule for this trigger", {
|
|
5998
5978
|
tags: { deviceId: subject.deviceId },
|
|
5999
5979
|
meta: {
|
|
6000
5980
|
delivery,
|
|
6001
5981
|
kind,
|
|
5982
|
+
origin,
|
|
6002
5983
|
rulesLoaded: this.rules.list().length
|
|
6003
5984
|
}
|
|
6004
5985
|
});
|
|
6005
|
-
return;
|
|
5986
|
+
return report({ droppedBy: "no-candidate-rule" });
|
|
6006
5987
|
}
|
|
6007
5988
|
const now = this.now();
|
|
6008
5989
|
let anyMatched = false;
|
|
5990
|
+
/** One "no media owner" line per EVENT, not per matching rule. */
|
|
5991
|
+
let noOwnerReported = false;
|
|
6009
5992
|
const rejections = [];
|
|
6010
5993
|
for (const rule of candidates) {
|
|
6011
5994
|
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
|
|
6012
5995
|
if (!evaluation.matched) {
|
|
6013
5996
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
5997
|
+
outcomes.push({
|
|
5998
|
+
ruleId: rule.id,
|
|
5999
|
+
ruleName: rule.name,
|
|
6000
|
+
matched: false,
|
|
6001
|
+
...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
|
|
6002
|
+
cooldown: "n/a",
|
|
6003
|
+
enqueued: 0,
|
|
6004
|
+
targetIds: []
|
|
6005
|
+
});
|
|
6014
6006
|
this.logger.debug("rule did not match", {
|
|
6015
6007
|
tags: { deviceId: subject.deviceId },
|
|
6016
6008
|
meta: {
|
|
@@ -6026,7 +6018,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6026
6018
|
continue;
|
|
6027
6019
|
}
|
|
6028
6020
|
const key = cooldownKey(rule, subject);
|
|
6029
|
-
|
|
6021
|
+
const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
|
|
6022
|
+
if (cooling && controls.bypassCooldown !== true) {
|
|
6030
6023
|
this.logger.debug("rule matched but is cooling down", {
|
|
6031
6024
|
tags: { deviceId: subject.deviceId },
|
|
6032
6025
|
meta: {
|
|
@@ -6037,6 +6030,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6037
6030
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6038
6031
|
}
|
|
6039
6032
|
});
|
|
6033
|
+
outcomes.push({
|
|
6034
|
+
ruleId: rule.id,
|
|
6035
|
+
ruleName: rule.name,
|
|
6036
|
+
matched: true,
|
|
6037
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6038
|
+
cooldown: "suppressed",
|
|
6039
|
+
enqueued: 0,
|
|
6040
|
+
targetIds: []
|
|
6041
|
+
});
|
|
6040
6042
|
continue;
|
|
6041
6043
|
}
|
|
6042
6044
|
this.logger.info("rule matched — enqueueing", {
|
|
@@ -6045,6 +6047,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6045
6047
|
ruleId: rule.id,
|
|
6046
6048
|
rule: rule.name,
|
|
6047
6049
|
kind,
|
|
6050
|
+
origin,
|
|
6048
6051
|
targets: rule.targets.length,
|
|
6049
6052
|
eventId: subject.recordId,
|
|
6050
6053
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
@@ -6053,7 +6056,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6053
6056
|
}
|
|
6054
6057
|
});
|
|
6055
6058
|
const sequences = rule.actions?.onTrigger;
|
|
6056
|
-
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
|
|
6059
|
+
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
|
|
6057
6060
|
ruleId: rule.id,
|
|
6058
6061
|
ruleName: rule.name,
|
|
6059
6062
|
deviceId: subject.deviceId,
|
|
@@ -6068,11 +6071,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6068
6071
|
buttons: unresolvable.join(", ")
|
|
6069
6072
|
}
|
|
6070
6073
|
});
|
|
6074
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
6075
|
+
noOwnerReported = true;
|
|
6076
|
+
this.logger.warn("device event has no marker track — this notification ships text-only", {
|
|
6077
|
+
tags: { deviceId: subject.deviceId },
|
|
6078
|
+
meta: {
|
|
6079
|
+
recordId: subject.recordId,
|
|
6080
|
+
...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
|
|
6081
|
+
ruleId: rule.id,
|
|
6082
|
+
rule: rule.name
|
|
6083
|
+
}
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
6071
6086
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6072
|
-
|
|
6087
|
+
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
|
|
6088
|
+
const inserted = await this.outbox.enqueue(entries);
|
|
6089
|
+
if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
|
|
6090
|
+
outcomes.push({
|
|
6091
|
+
ruleId: rule.id,
|
|
6092
|
+
ruleName: rule.name,
|
|
6093
|
+
matched: true,
|
|
6094
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6095
|
+
cooldown: cooling ? "bypassed" : "clear",
|
|
6096
|
+
enqueued: inserted,
|
|
6097
|
+
targetIds: entries.map((e) => e.targetId)
|
|
6098
|
+
});
|
|
6073
6099
|
anyMatched = true;
|
|
6074
6100
|
}
|
|
6075
6101
|
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
6102
|
+
return report({ candidates: candidates.length });
|
|
6076
6103
|
}
|
|
6077
6104
|
/**
|
|
6078
6105
|
* One INFO line when a MUTED camera's event is dropped, bounded to one per
|
|
@@ -6124,7 +6151,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6124
6151
|
}
|
|
6125
6152
|
});
|
|
6126
6153
|
}
|
|
6127
|
-
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6154
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
|
|
6128
6155
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
6129
6156
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
6130
6157
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
@@ -6132,6 +6159,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6132
6159
|
const payload = {
|
|
6133
6160
|
ruleName: rule.name,
|
|
6134
6161
|
delivery: rule.delivery,
|
|
6162
|
+
...origin === "synthetic" ? { synthetic: true } : {},
|
|
6135
6163
|
priority: rule.priority,
|
|
6136
6164
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
6137
6165
|
media: rule.media.attach,
|
|
@@ -6315,21 +6343,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6315
6343
|
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
6316
6344
|
for (const event of ordered) {
|
|
6317
6345
|
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
|
-
}));
|
|
6346
|
+
this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
|
|
6333
6347
|
}
|
|
6334
6348
|
await this.evalChain;
|
|
6335
6349
|
if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
|
|
@@ -6497,7 +6511,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6497
6511
|
}
|
|
6498
6512
|
} else if (rule.delivery === "track-end") {
|
|
6499
6513
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
6500
|
-
for (const track of tracks) subjects.push(
|
|
6514
|
+
for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
|
|
6501
6515
|
}
|
|
6502
6516
|
let lookup = (id) => this.deviceStates.get(id);
|
|
6503
6517
|
const gated = rule.conditions.deviceState;
|
|
@@ -6600,6 +6614,24 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
6600
6614
|
"nc.cancelSnooze": require_dist.customAction(require_dist.object({ snoozeId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
|
|
6601
6615
|
kind: "mutation",
|
|
6602
6616
|
caller: "required"
|
|
6617
|
+
}),
|
|
6618
|
+
/**
|
|
6619
|
+
* Inject a SYNTHETIC event through the whole notification path — match,
|
|
6620
|
+
* media, delivery — without producing a track, an event row or anything else
|
|
6621
|
+
* durable. See `test-event.ts` for what it does and does not do.
|
|
6622
|
+
*
|
|
6623
|
+
* A bridge action rather than a cap method, deliberately: it needs no
|
|
6624
|
+
* codegen, no `@camstack/system` republish and no place on a release train,
|
|
6625
|
+
* so the tester can ship and change at the speed of the addon it tests.
|
|
6626
|
+
*
|
|
6627
|
+
* `auth: 'admin'` because an injection DELIVERS: it reaches every target the
|
|
6628
|
+
* matched rule addresses, on other people's phones. `caller: 'required'` so
|
|
6629
|
+
* the log line names who pressed it.
|
|
6630
|
+
*/
|
|
6631
|
+
"nc.injectTestEvent": require_dist.customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
|
|
6632
|
+
kind: "mutation",
|
|
6633
|
+
auth: "admin",
|
|
6634
|
+
caller: "required"
|
|
6603
6635
|
})
|
|
6604
6636
|
});
|
|
6605
6637
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
@@ -6714,6 +6746,22 @@ function makeNcActionHandlers(deps) {
|
|
|
6714
6746
|
await deps.snooze.cancel(input.snoozeId, c);
|
|
6715
6747
|
return { success: true };
|
|
6716
6748
|
},
|
|
6749
|
+
"nc.injectTestEvent": async (input, caller) => {
|
|
6750
|
+
const c = requireCaller(caller);
|
|
6751
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
|
|
6752
|
+
const inject = deps.injectTestEvent;
|
|
6753
|
+
if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
|
|
6754
|
+
const parsed = NcTestEventInputSchema.parse(input);
|
|
6755
|
+
deps.logger.info("synthetic notification test requested", {
|
|
6756
|
+
tags: { deviceId: parsed.deviceId },
|
|
6757
|
+
meta: {
|
|
6758
|
+
by: c.userId,
|
|
6759
|
+
trigger: parsed.trigger,
|
|
6760
|
+
...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
|
|
6761
|
+
}
|
|
6762
|
+
});
|
|
6763
|
+
return inject(parsed);
|
|
6764
|
+
},
|
|
6717
6765
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6718
6766
|
const c = requireCaller(caller);
|
|
6719
6767
|
assertRuleVisible(input.ruleId, c);
|
|
@@ -6723,6 +6771,844 @@ function makeNcActionHandlers(deps) {
|
|
|
6723
6771
|
}
|
|
6724
6772
|
};
|
|
6725
6773
|
}
|
|
6774
|
+
var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
|
|
6775
|
+
var DebugFindingSchema = require_dist.object({
|
|
6776
|
+
code: require_dist.string(),
|
|
6777
|
+
severity: require_dist._enum([
|
|
6778
|
+
"error",
|
|
6779
|
+
"warn",
|
|
6780
|
+
"info"
|
|
6781
|
+
]),
|
|
6782
|
+
detail: require_dist.string()
|
|
6783
|
+
});
|
|
6784
|
+
var DebugTrackListItemSchema = require_dist.object({
|
|
6785
|
+
trackId: require_dist.string(),
|
|
6786
|
+
deviceId: require_dist.number(),
|
|
6787
|
+
className: require_dist.string(),
|
|
6788
|
+
label: require_dist.string().optional(),
|
|
6789
|
+
firstSeen: require_dist.number(),
|
|
6790
|
+
lastSeen: require_dist.number(),
|
|
6791
|
+
durationMs: require_dist.number(),
|
|
6792
|
+
active: require_dist.boolean()
|
|
6793
|
+
});
|
|
6794
|
+
var DebugTrackReportSchema = require_dist.object({
|
|
6795
|
+
trackId: require_dist.string(),
|
|
6796
|
+
deviceId: require_dist.number(),
|
|
6797
|
+
className: require_dist.string(),
|
|
6798
|
+
label: require_dist.string().optional(),
|
|
6799
|
+
firstSeen: require_dist.number(),
|
|
6800
|
+
lastSeen: require_dist.number(),
|
|
6801
|
+
durationMs: require_dist.number(),
|
|
6802
|
+
active: require_dist.boolean(),
|
|
6803
|
+
mediaCount: require_dist.number(),
|
|
6804
|
+
mediaKinds: require_dist.array(require_dist.string()).readonly(),
|
|
6805
|
+
eventCount: require_dist.number(),
|
|
6806
|
+
positionCount: require_dist.number(),
|
|
6807
|
+
findings: require_dist.array(DebugFindingSchema).readonly()
|
|
6808
|
+
});
|
|
6809
|
+
var ListTracksInputSchema = require_dist.object({
|
|
6810
|
+
/** Empty or absent means every camera that has tracks. */
|
|
6811
|
+
deviceIds: require_dist.array(require_dist.number()).optional(),
|
|
6812
|
+
limit: require_dist.number().int().positive().max(200).optional()
|
|
6813
|
+
});
|
|
6814
|
+
var ListTracksOutputSchema = require_dist.object({
|
|
6815
|
+
tracks: require_dist.array(DebugTrackListItemSchema).readonly(),
|
|
6816
|
+
/**
|
|
6817
|
+
* True when a camera hit `limit`. Said rather than implied: a silently capped
|
|
6818
|
+
* list is how an operator concludes a mark did not take.
|
|
6819
|
+
*/
|
|
6820
|
+
truncated: require_dist.boolean()
|
|
6821
|
+
});
|
|
6822
|
+
var AnalyzeInputSchema = require_dist.object({ trackIds: require_dist.array(require_dist.string()).min(1).max(200) });
|
|
6823
|
+
var AnalyzeOutputSchema = require_dist.object({
|
|
6824
|
+
reports: require_dist.array(DebugTrackReportSchema).readonly(),
|
|
6825
|
+
/**
|
|
6826
|
+
* Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
|
|
6827
|
+
* pin retention, so a marked track can be evicted between the list and the
|
|
6828
|
+
* analysis — reported as a number the tab can show rather than as silence.
|
|
6829
|
+
*/
|
|
6830
|
+
vanished: require_dist.array(require_dist.string()).readonly()
|
|
6831
|
+
});
|
|
6832
|
+
var debugActions = require_dist.defineCustomActions({
|
|
6833
|
+
"debug.listTracks": require_dist.customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
|
|
6834
|
+
"debug.analyze": require_dist.customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
|
|
6835
|
+
});
|
|
6836
|
+
//#endregion
|
|
6837
|
+
//#region src/pipeline-analytics/debug/zip-stream.ts
|
|
6838
|
+
/**
|
|
6839
|
+
* A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
|
|
6840
|
+
* entry, without ever holding the archive in memory.
|
|
6841
|
+
*
|
|
6842
|
+
* ## Why hand-rolled, and why ZIP rather than the tar next door
|
|
6843
|
+
*
|
|
6844
|
+
* The archive the operator asked for is a ZIP, because it is the format that
|
|
6845
|
+
* opens with a double click on every desktop they use; the training exports use
|
|
6846
|
+
* ustar and that stays as it is. Both are hand-written here for the same two
|
|
6847
|
+
* reasons the {@link ../training-export/tar-stream.js tar writer} records:
|
|
6848
|
+
*
|
|
6849
|
+
* 1. **A dependency would not arrive.** A hub addon is deployed as its built
|
|
6850
|
+
* `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
|
|
6851
|
+
* `archiver` to `package.json` gets a module that is missing at runtime on
|
|
6852
|
+
* the node that actually runs the code. The only libraries an addon can rely
|
|
6853
|
+
* on are Node's builtins and what its bundler inlines.
|
|
6854
|
+
* 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
|
|
6855
|
+
* STORE (method 0) spends no CPU on the hub's one thread for a percent or
|
|
6856
|
+
* two, and — unlike DEFLATE — the entry's size and CRC are known before the
|
|
6857
|
+
* header is written, so no data descriptors and no seeking back: the writer
|
|
6858
|
+
* is append-only and therefore streamable.
|
|
6859
|
+
*
|
|
6860
|
+
* So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
|
|
6861
|
+
* plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
|
|
6862
|
+
* asserted against the system `unzip` binary in the spec next door rather than
|
|
6863
|
+
* against my reading of the format.
|
|
6864
|
+
*
|
|
6865
|
+
* ## What this deliberately does NOT do
|
|
6866
|
+
*
|
|
6867
|
+
* No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
|
|
6868
|
+
* archive is a hand-marked debug set — tens of tracks, not a dataset.
|
|
6869
|
+
* {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
|
|
6870
|
+
* so the caller can skip it and SAY it skipped it, rather than emitting an
|
|
6871
|
+
* archive with a wrapped offset that reads as corrupt.
|
|
6872
|
+
*
|
|
6873
|
+
* ## Entry names are ASCII, by construction
|
|
6874
|
+
*
|
|
6875
|
+
* The writer sets the UTF-8 name flag (bit 11) because that is what the format
|
|
6876
|
+
* says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
|
|
6877
|
+
* 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
|
|
6878
|
+
* comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
|
|
6879
|
+
* valid for the filesystem it refuses to extract the entry at all. Measured, not
|
|
6880
|
+
* assumed; the spec next door pins it.
|
|
6881
|
+
*
|
|
6882
|
+
* So {@link toAsciiZipName} is applied to every path, and — more importantly —
|
|
6883
|
+
* no free text goes into a path in the first place: an entry is addressed by
|
|
6884
|
+
* `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
|
|
6885
|
+
* its encoding is JSON's problem and not the archive's.
|
|
6886
|
+
*/
|
|
6887
|
+
var LOCAL_SIG = 67324752;
|
|
6888
|
+
var CENTRAL_SIG = 33639248;
|
|
6889
|
+
var EOCD_SIG = 101010256;
|
|
6890
|
+
/** Bit 11 — the entry name is UTF-8. */
|
|
6891
|
+
var FLAG_UTF8 = 2048;
|
|
6892
|
+
/** STORE. */
|
|
6893
|
+
var METHOD_STORE = 0;
|
|
6894
|
+
/** PKZIP 2.0, the floor for STORE + UTF-8 names. */
|
|
6895
|
+
var VERSION = 20;
|
|
6896
|
+
/** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
|
|
6897
|
+
var ZIP_MAX_BYTES = 4294967295;
|
|
6898
|
+
var CRC_TABLE = (() => {
|
|
6899
|
+
const table = new Array(256);
|
|
6900
|
+
for (let i = 0; i < 256; i++) {
|
|
6901
|
+
let c = i;
|
|
6902
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
6903
|
+
table[i] = c >>> 0;
|
|
6904
|
+
}
|
|
6905
|
+
return table;
|
|
6906
|
+
})();
|
|
6907
|
+
/**
|
|
6908
|
+
* Reduce a path to the ASCII set every ZIP reader agrees on.
|
|
6909
|
+
*
|
|
6910
|
+
* Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
|
|
6911
|
+
* transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
|
|
6912
|
+
* only name this archive can promise, and a lossy-but-stable mapping is better
|
|
6913
|
+
* than a pretty one that a reader mangles differently from the manifest.
|
|
6914
|
+
*/
|
|
6915
|
+
function toAsciiZipName(name) {
|
|
6916
|
+
return name.replace(/[^A-Za-z0-9._/-]/g, "_");
|
|
6917
|
+
}
|
|
6918
|
+
/** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
|
|
6919
|
+
function crc32(buf) {
|
|
6920
|
+
let c = 4294967295;
|
|
6921
|
+
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
|
|
6922
|
+
return (c ^ 4294967295) >>> 0;
|
|
6923
|
+
}
|
|
6924
|
+
/**
|
|
6925
|
+
* MS-DOS date/time, the only timestamp a classic ZIP record carries.
|
|
6926
|
+
*
|
|
6927
|
+
* Two-second resolution and an epoch of 1980 — a date before that is clamped
|
|
6928
|
+
* rather than allowed to write a negative year field, which some readers show as
|
|
6929
|
+
* 2107 and others reject outright.
|
|
6930
|
+
*/
|
|
6931
|
+
function dosDateTime(mtimeMs) {
|
|
6932
|
+
const d = new Date(mtimeMs);
|
|
6933
|
+
const year = Math.max(1980, d.getFullYear());
|
|
6934
|
+
return {
|
|
6935
|
+
time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
|
|
6936
|
+
date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
|
|
6937
|
+
};
|
|
6938
|
+
}
|
|
6939
|
+
/**
|
|
6940
|
+
* The 30-byte local header + name for one stored file.
|
|
6941
|
+
*
|
|
6942
|
+
* Sizes and CRC are written into the header itself (never a trailing data
|
|
6943
|
+
* descriptor) — STORE knows both before the body moves, and a header that tells
|
|
6944
|
+
* the truth is what lets a reader index the archive without scanning it.
|
|
6945
|
+
*/
|
|
6946
|
+
function zipLocalHeader(entry, crc, nameBytes) {
|
|
6947
|
+
const head = Buffer.alloc(30);
|
|
6948
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
6949
|
+
head.writeUInt32LE(LOCAL_SIG, 0);
|
|
6950
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6951
|
+
head.writeUInt16LE(FLAG_UTF8, 6);
|
|
6952
|
+
head.writeUInt16LE(METHOD_STORE, 8);
|
|
6953
|
+
head.writeUInt16LE(time, 10);
|
|
6954
|
+
head.writeUInt16LE(date, 12);
|
|
6955
|
+
head.writeUInt32LE(crc, 14);
|
|
6956
|
+
head.writeUInt32LE(entry.body.length, 18);
|
|
6957
|
+
head.writeUInt32LE(entry.body.length, 22);
|
|
6958
|
+
head.writeUInt16LE(nameBytes.length, 26);
|
|
6959
|
+
head.writeUInt16LE(0, 28);
|
|
6960
|
+
return Buffer.concat([head, nameBytes]);
|
|
6961
|
+
}
|
|
6962
|
+
function centralHeader(entry) {
|
|
6963
|
+
const head = Buffer.alloc(46);
|
|
6964
|
+
head.writeUInt32LE(CENTRAL_SIG, 0);
|
|
6965
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6966
|
+
head.writeUInt16LE(VERSION, 6);
|
|
6967
|
+
head.writeUInt16LE(FLAG_UTF8, 8);
|
|
6968
|
+
head.writeUInt16LE(METHOD_STORE, 10);
|
|
6969
|
+
head.writeUInt16LE(entry.dosTime, 12);
|
|
6970
|
+
head.writeUInt16LE(entry.dosDate, 14);
|
|
6971
|
+
head.writeUInt32LE(entry.crc, 16);
|
|
6972
|
+
head.writeUInt32LE(entry.size, 20);
|
|
6973
|
+
head.writeUInt32LE(entry.size, 24);
|
|
6974
|
+
head.writeUInt16LE(entry.nameBytes.length, 28);
|
|
6975
|
+
head.writeUInt16LE(0, 30);
|
|
6976
|
+
head.writeUInt16LE(0, 32);
|
|
6977
|
+
head.writeUInt16LE(0, 34);
|
|
6978
|
+
head.writeUInt16LE(0, 36);
|
|
6979
|
+
head.writeUInt32LE(0, 38);
|
|
6980
|
+
head.writeUInt32LE(entry.offset, 42);
|
|
6981
|
+
return Buffer.concat([head, entry.nameBytes]);
|
|
6982
|
+
}
|
|
6983
|
+
function endOfCentralDirectory(count, size, offset) {
|
|
6984
|
+
const eocd = Buffer.alloc(22);
|
|
6985
|
+
eocd.writeUInt32LE(EOCD_SIG, 0);
|
|
6986
|
+
eocd.writeUInt16LE(0, 4);
|
|
6987
|
+
eocd.writeUInt16LE(0, 6);
|
|
6988
|
+
eocd.writeUInt16LE(count, 8);
|
|
6989
|
+
eocd.writeUInt16LE(count, 10);
|
|
6990
|
+
eocd.writeUInt32LE(size, 12);
|
|
6991
|
+
eocd.writeUInt32LE(offset, 16);
|
|
6992
|
+
eocd.writeUInt16LE(0, 20);
|
|
6993
|
+
return eocd;
|
|
6994
|
+
}
|
|
6995
|
+
/**
|
|
6996
|
+
* True when one more entry still fits inside the classic (non-ZIP64) ceilings.
|
|
6997
|
+
*
|
|
6998
|
+
* Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
|
|
6999
|
+
* be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
|
|
7000
|
+
* 32-bit offset, producing an archive that looks complete and unpacks as
|
|
7001
|
+
* garbage) is precisely the one a test must be able to reach.
|
|
7002
|
+
*/
|
|
7003
|
+
function zipEntryFits(query) {
|
|
7004
|
+
if (query.entryIndex >= 65535) return false;
|
|
7005
|
+
const nameLen = toAsciiZipName(query.name).length;
|
|
7006
|
+
return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
|
|
7007
|
+
}
|
|
7008
|
+
/**
|
|
7009
|
+
* An append-only ZIP writer.
|
|
7010
|
+
*
|
|
7011
|
+
* The caller supplies `write` — the real `res.write`, awaited, so backpressure
|
|
7012
|
+
* is the socket's. The writer holds only the central directory (one small record
|
|
7013
|
+
* per entry), never a body: {@link add} takes one file at a time and forgets it.
|
|
7014
|
+
*/
|
|
7015
|
+
var ZipStreamWriter = class {
|
|
7016
|
+
write;
|
|
7017
|
+
entries = [];
|
|
7018
|
+
offset = 0;
|
|
7019
|
+
constructor(write) {
|
|
7020
|
+
this.write = write;
|
|
7021
|
+
}
|
|
7022
|
+
/** Entries accepted so far — what the manifest should claim, not what was asked. */
|
|
7023
|
+
get entryCount() {
|
|
7024
|
+
return this.entries.length;
|
|
7025
|
+
}
|
|
7026
|
+
/** Bytes emitted so far, central directory excluded. */
|
|
7027
|
+
get bytesWritten() {
|
|
7028
|
+
return this.offset;
|
|
7029
|
+
}
|
|
7030
|
+
/**
|
|
7031
|
+
* True when this entry still fits inside the classic ZIP ceilings.
|
|
7032
|
+
*
|
|
7033
|
+
* Checked BEFORE the header goes out, because a 32-bit offset that wraps
|
|
7034
|
+
* produces an archive that looks complete and unpacks as garbage — the failure
|
|
7035
|
+
* this writer most needs to make impossible.
|
|
7036
|
+
*/
|
|
7037
|
+
fits(entry) {
|
|
7038
|
+
return zipEntryFits({
|
|
7039
|
+
offset: this.offset,
|
|
7040
|
+
entryIndex: this.entries.length,
|
|
7041
|
+
name: entry.name,
|
|
7042
|
+
bodyLength: entry.body.length
|
|
7043
|
+
});
|
|
7044
|
+
}
|
|
7045
|
+
/** Append one stored file. Returns false (and writes nothing) when it does not fit. */
|
|
7046
|
+
async add(entry) {
|
|
7047
|
+
if (!this.fits(entry)) return false;
|
|
7048
|
+
const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
|
|
7049
|
+
const crc = crc32(entry.body);
|
|
7050
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
7051
|
+
const header = zipLocalHeader(entry, crc, nameBytes);
|
|
7052
|
+
this.entries.push({
|
|
7053
|
+
nameBytes,
|
|
7054
|
+
crc,
|
|
7055
|
+
size: entry.body.length,
|
|
7056
|
+
offset: this.offset,
|
|
7057
|
+
dosTime: time,
|
|
7058
|
+
dosDate: date
|
|
7059
|
+
});
|
|
7060
|
+
await this.write(header);
|
|
7061
|
+
await this.write(entry.body);
|
|
7062
|
+
this.offset += header.length + entry.body.length;
|
|
7063
|
+
return true;
|
|
7064
|
+
}
|
|
7065
|
+
/** Write the central directory + EOCD. The archive is unreadable without it. */
|
|
7066
|
+
async finish() {
|
|
7067
|
+
const start = this.offset;
|
|
7068
|
+
let size = 0;
|
|
7069
|
+
for (const entry of this.entries) {
|
|
7070
|
+
const block = centralHeader(entry);
|
|
7071
|
+
await this.write(block);
|
|
7072
|
+
size += block.length;
|
|
7073
|
+
}
|
|
7074
|
+
await this.write(endOfCentralDirectory(this.entries.length, size, start));
|
|
7075
|
+
this.offset = start + size + 22;
|
|
7076
|
+
}
|
|
7077
|
+
};
|
|
7078
|
+
var DEBUG_MANIFEST_ENTRY = "manifest.json";
|
|
7079
|
+
var DEBUG_TRACK_ENTRY = "track.json";
|
|
7080
|
+
var DEBUG_ANALYSIS_ENTRY = "analysis.json";
|
|
7081
|
+
var DEBUG_EVENTS_ENTRY = "events.json";
|
|
7082
|
+
/** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
|
|
7083
|
+
function debugTrackDir(deviceId, trackId) {
|
|
7084
|
+
return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
|
|
7085
|
+
}
|
|
7086
|
+
/**
|
|
7087
|
+
* Resolve every media row of one track to an archive-relative name.
|
|
7088
|
+
*
|
|
7089
|
+
* Deterministic: the input order (the store returns `timestamp asc`) fully
|
|
7090
|
+
* determines the output, so re-exporting an unchanged track produces the same
|
|
7091
|
+
* names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
|
|
7092
|
+
* carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
|
|
7093
|
+
*/
|
|
7094
|
+
function planTrackMediaNames(media) {
|
|
7095
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
7096
|
+
for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
|
|
7097
|
+
const used = /* @__PURE__ */ new Set();
|
|
7098
|
+
const out = [];
|
|
7099
|
+
for (const m of media) {
|
|
7100
|
+
const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
|
|
7101
|
+
let name = `media/${base}.jpg`;
|
|
7102
|
+
for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
|
|
7103
|
+
used.add(name);
|
|
7104
|
+
out.push({
|
|
7105
|
+
key: m.key,
|
|
7106
|
+
name: toAsciiZipName(name),
|
|
7107
|
+
kind: m.kind,
|
|
7108
|
+
timestamp: m.timestamp,
|
|
7109
|
+
sizeBytes: m.sizeBytes
|
|
7110
|
+
});
|
|
7111
|
+
}
|
|
7112
|
+
return out;
|
|
7113
|
+
}
|
|
7114
|
+
var DEBUG_RETENTION_NOTE = "A `debug` mark does not pin retention (D81). A track with no media here may have been evicted rather than never captured.";
|
|
7115
|
+
/** The archive filename, stamped so two downloads never collide in ~/Downloads. */
|
|
7116
|
+
function debugArchiveFilename(nowMs) {
|
|
7117
|
+
return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
|
|
7118
|
+
}
|
|
7119
|
+
/** Parse `?deviceIds=615,617` — empty means every camera. */
|
|
7120
|
+
function parseDebugDeviceIds(url) {
|
|
7121
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
7122
|
+
const raw = new URLSearchParams(query).get("deviceIds");
|
|
7123
|
+
if (raw === null || raw.trim() === "") return [];
|
|
7124
|
+
const out = [];
|
|
7125
|
+
for (const part of raw.split(",")) {
|
|
7126
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
7127
|
+
if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
|
|
7128
|
+
}
|
|
7129
|
+
return out;
|
|
7130
|
+
}
|
|
7131
|
+
//#endregion
|
|
7132
|
+
//#region src/pipeline-analytics/data-plane-access.ts
|
|
7133
|
+
/**
|
|
7134
|
+
* Prefix literals for the export planes, mirrored here so the policy reads as
|
|
7135
|
+
* a table without importing three handler modules into the addon's boot path.
|
|
7136
|
+
* `data-plane-access.spec.ts` asserts they still match the constants their
|
|
7137
|
+
* handlers register with — a mirror nothing checks is a lie waiting to happen.
|
|
7138
|
+
*/
|
|
7139
|
+
var TRAINING_EXPORT_PREFIX_REF = "training-export";
|
|
7140
|
+
var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
|
|
7141
|
+
var DEBUG_EXPORT_PREFIX_REF = "debug-export";
|
|
7142
|
+
/**
|
|
7143
|
+
* Every data plane this addon serves, and the level the HUB enforces before it
|
|
7144
|
+
* proxies. Exhaustive by test: a new plane that forgets to appear here fails
|
|
7145
|
+
* `data-plane-access.spec.ts`.
|
|
7146
|
+
*/
|
|
7147
|
+
var PIPELINE_ANALYTICS_PLANE_ACCESS = {
|
|
7148
|
+
/** One stored crop/thumbnail by an unguessable id the caller already holds. */
|
|
7149
|
+
"event-media": "authenticated",
|
|
7150
|
+
/** Self-authenticating single-use action token (D47). */
|
|
7151
|
+
"nc-action": "public",
|
|
7152
|
+
/** Self-authenticating single-use artifact token (D47). */
|
|
7153
|
+
"nc-artifact": "public",
|
|
7154
|
+
/** Every frame behind a marked selection — a corpus, not an item. */
|
|
7155
|
+
[TRAINING_EXPORT_PREFIX_REF]: "admin",
|
|
7156
|
+
[RETRAIN_EXPORT_PREFIX_REF]: "admin",
|
|
7157
|
+
/** Diagnostic bundle: logs, config and frames. */
|
|
7158
|
+
[DEBUG_EXPORT_PREFIX_REF]: "admin"
|
|
7159
|
+
};
|
|
7160
|
+
/** The declared level for a plane prefix. Typed, so a prefix with no declared
|
|
7161
|
+
* policy is a compile error rather than a silent default. */
|
|
7162
|
+
function planeAccess(prefix) {
|
|
7163
|
+
return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
|
|
7164
|
+
}
|
|
7165
|
+
//#endregion
|
|
7166
|
+
//#region src/pipeline-analytics/debug/debug-export-handler.ts
|
|
7167
|
+
/**
|
|
7168
|
+
* Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
|
|
7169
|
+
* it yields twice: between tracks, and between individual media files.
|
|
7170
|
+
*
|
|
7171
|
+
* The per-file yield is the one that matters. A track can own twenty blobs, and
|
|
7172
|
+
* reading them back to back — each a base64 decode of a few hundred KB —
|
|
7173
|
+
* occupies the loop for long enough that snapshot resizes start timing out. One
|
|
7174
|
+
* file at a time, with a gap, keeps the export off the critical path; it is slow
|
|
7175
|
+
* on purpose.
|
|
7176
|
+
*/
|
|
7177
|
+
var TRACK_PACING_MS = 25;
|
|
7178
|
+
var MEDIA_PACING_MS = 15;
|
|
7179
|
+
function json(value) {
|
|
7180
|
+
return Buffer.from(JSON.stringify(value, null, 2), "utf8");
|
|
7181
|
+
}
|
|
7182
|
+
/** Total media rows the plan hopes to ship — the number HEAD reports. */
|
|
7183
|
+
function plannedMediaCount(plan) {
|
|
7184
|
+
let total = 0;
|
|
7185
|
+
for (const t of plan.tracks) total += t.media.length;
|
|
7186
|
+
return total;
|
|
7187
|
+
}
|
|
7188
|
+
function createDebugExportHandler(deps) {
|
|
7189
|
+
return async (req, res) => {
|
|
7190
|
+
const method = req.method ?? "GET";
|
|
7191
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
7192
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
7193
|
+
res.end();
|
|
7194
|
+
return;
|
|
7195
|
+
}
|
|
7196
|
+
const deviceIds = parseDebugDeviceIds(req.url ?? "");
|
|
7197
|
+
let plan;
|
|
7198
|
+
try {
|
|
7199
|
+
plan = await deps.buildPlan(deviceIds);
|
|
7200
|
+
} catch (err) {
|
|
7201
|
+
deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
|
|
7202
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
7203
|
+
res.end("debug export plan failed");
|
|
7204
|
+
return;
|
|
7205
|
+
}
|
|
7206
|
+
res.writeHead(200, {
|
|
7207
|
+
"content-type": "application/zip",
|
|
7208
|
+
"content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
|
|
7209
|
+
"cache-control": "no-store",
|
|
7210
|
+
"x-camstack-debug-tracks": String(plan.tracks.length),
|
|
7211
|
+
"x-camstack-debug-media": String(plannedMediaCount(plan))
|
|
7212
|
+
});
|
|
7213
|
+
if (method === "HEAD") {
|
|
7214
|
+
res.end();
|
|
7215
|
+
return;
|
|
7216
|
+
}
|
|
7217
|
+
const write = (chunk) => new Promise((resolve, reject) => {
|
|
7218
|
+
if (!res.write(chunk, (err) => {
|
|
7219
|
+
if (err) reject(err);
|
|
7220
|
+
else resolve();
|
|
7221
|
+
})) res.once("drain", resolve);
|
|
7222
|
+
});
|
|
7223
|
+
const zip = new ZipStreamWriter(write);
|
|
7224
|
+
const mtimeMs = deps.now();
|
|
7225
|
+
const startedAt = deps.now();
|
|
7226
|
+
const manifestTracks = [];
|
|
7227
|
+
let included = 0;
|
|
7228
|
+
let missing = 0;
|
|
7229
|
+
let stoppedBecause = plan.capped ? "track-cap" : null;
|
|
7230
|
+
for (const entry of plan.tracks) {
|
|
7231
|
+
if (zip.bytesWritten >= 268435456) {
|
|
7232
|
+
stoppedBecause ??= "byte-budget";
|
|
7233
|
+
deps.logger.warn("debug export stopped — byte budget reached", { meta: {
|
|
7234
|
+
budget: MAX_EXPORT_BYTES,
|
|
7235
|
+
written: zip.bytesWritten
|
|
7236
|
+
} });
|
|
7237
|
+
break;
|
|
7238
|
+
}
|
|
7239
|
+
const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
|
|
7240
|
+
const deviceId = entry.report.deviceId;
|
|
7241
|
+
for (const [name, body] of [
|
|
7242
|
+
[DEBUG_TRACK_ENTRY, json(entry.track)],
|
|
7243
|
+
[DEBUG_ANALYSIS_ENTRY, json(entry.report)],
|
|
7244
|
+
[DEBUG_EVENTS_ENTRY, json(entry.events)]
|
|
7245
|
+
]) if (!await zip.add({
|
|
7246
|
+
name: `${dir}/${name}`,
|
|
7247
|
+
body,
|
|
7248
|
+
mtimeMs
|
|
7249
|
+
})) stoppedBecause ??= "zip-ceiling";
|
|
7250
|
+
const mediaIncluded = [];
|
|
7251
|
+
const mediaMissing = [];
|
|
7252
|
+
for (const media of entry.media) {
|
|
7253
|
+
const bytes = await deps.readMedia(media.key);
|
|
7254
|
+
if (bytes === null) {
|
|
7255
|
+
missing += 1;
|
|
7256
|
+
mediaMissing.push(media.key);
|
|
7257
|
+
deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
|
|
7258
|
+
tags: { deviceId },
|
|
7259
|
+
meta: {
|
|
7260
|
+
trackId: entry.report.trackId,
|
|
7261
|
+
mediaKey: media.key,
|
|
7262
|
+
kind: media.kind
|
|
7263
|
+
}
|
|
7264
|
+
});
|
|
7265
|
+
continue;
|
|
7266
|
+
}
|
|
7267
|
+
if (!await zip.add({
|
|
7268
|
+
name: `${dir}/${media.name}`,
|
|
7269
|
+
body: bytes,
|
|
7270
|
+
mtimeMs
|
|
7271
|
+
})) {
|
|
7272
|
+
stoppedBecause ??= "zip-ceiling";
|
|
7273
|
+
missing += 1;
|
|
7274
|
+
mediaMissing.push(media.key);
|
|
7275
|
+
deps.logger.warn("debug export skipped a media file — the archive is full", {
|
|
7276
|
+
tags: { deviceId },
|
|
7277
|
+
meta: {
|
|
7278
|
+
trackId: entry.report.trackId,
|
|
7279
|
+
mediaKey: media.key,
|
|
7280
|
+
kind: media.kind
|
|
7281
|
+
}
|
|
7282
|
+
});
|
|
7283
|
+
continue;
|
|
7284
|
+
}
|
|
7285
|
+
included += 1;
|
|
7286
|
+
mediaIncluded.push(media.name);
|
|
7287
|
+
await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
|
|
7288
|
+
}
|
|
7289
|
+
manifestTracks.push({
|
|
7290
|
+
trackId: entry.report.trackId,
|
|
7291
|
+
deviceId,
|
|
7292
|
+
dir,
|
|
7293
|
+
className: entry.report.className,
|
|
7294
|
+
...entry.report.label === void 0 ? {} : { label: entry.report.label },
|
|
7295
|
+
firstSeen: entry.report.firstSeen,
|
|
7296
|
+
lastSeen: entry.report.lastSeen,
|
|
7297
|
+
durationMs: entry.report.durationMs,
|
|
7298
|
+
eventCount: entry.report.eventCount,
|
|
7299
|
+
findings: entry.report.findings.map((f) => f.code),
|
|
7300
|
+
mediaIncluded,
|
|
7301
|
+
mediaMissing
|
|
7302
|
+
});
|
|
7303
|
+
await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
|
|
7304
|
+
}
|
|
7305
|
+
const manifest = {
|
|
7306
|
+
generatedAt: plan.generatedAt,
|
|
7307
|
+
deviceIds: plan.deviceIds,
|
|
7308
|
+
trackCount: manifestTracks.length,
|
|
7309
|
+
mediaIncluded: included,
|
|
7310
|
+
mediaMissing: missing,
|
|
7311
|
+
truncated: stoppedBecause !== null,
|
|
7312
|
+
stoppedBecause,
|
|
7313
|
+
retentionNote: DEBUG_RETENTION_NOTE,
|
|
7314
|
+
tracks: manifestTracks
|
|
7315
|
+
};
|
|
7316
|
+
await zip.add({
|
|
7317
|
+
name: DEBUG_MANIFEST_ENTRY,
|
|
7318
|
+
body: json(manifest),
|
|
7319
|
+
mtimeMs
|
|
7320
|
+
});
|
|
7321
|
+
await zip.finish();
|
|
7322
|
+
await new Promise((resolve) => {
|
|
7323
|
+
res.end(resolve);
|
|
7324
|
+
});
|
|
7325
|
+
deps.logger.info("debug export complete", { meta: {
|
|
7326
|
+
tracks: manifestTracks.length,
|
|
7327
|
+
mediaIncluded: included,
|
|
7328
|
+
mediaMissing: missing,
|
|
7329
|
+
stoppedBecause,
|
|
7330
|
+
bytes: zip.bytesWritten,
|
|
7331
|
+
elapsedMs: deps.now() - startedAt
|
|
7332
|
+
} });
|
|
7333
|
+
};
|
|
7334
|
+
}
|
|
7335
|
+
//#endregion
|
|
7336
|
+
//#region src/pipeline-analytics/debug/debug-track-analysis.ts
|
|
7337
|
+
/** A track is "short" below this — the pipeline rarely produces a full media set. */
|
|
7338
|
+
var SHORT_TRACK_MS = 2e3;
|
|
7339
|
+
/** Below this, the label the track settled on is weak enough to be worth saying. */
|
|
7340
|
+
var LOW_BEST_SCORE = .5;
|
|
7341
|
+
/** Duration in ms. Never negative — a clock that went backwards reads as 0. */
|
|
7342
|
+
function trackDurationMs(facts) {
|
|
7343
|
+
return Math.max(0, facts.lastSeen - facts.firstSeen);
|
|
7344
|
+
}
|
|
7345
|
+
function hasKind(media, kind) {
|
|
7346
|
+
return media.some((m) => m.kind === kind);
|
|
7347
|
+
}
|
|
7348
|
+
function latestMediaAt(media) {
|
|
7349
|
+
let latest = null;
|
|
7350
|
+
for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
|
|
7351
|
+
return latest;
|
|
7352
|
+
}
|
|
7353
|
+
function finding(code, severity, detail) {
|
|
7354
|
+
return {
|
|
7355
|
+
code,
|
|
7356
|
+
severity,
|
|
7357
|
+
detail
|
|
7358
|
+
};
|
|
7359
|
+
}
|
|
7360
|
+
/**
|
|
7361
|
+
* Analyse one track. Pure — same inventory in, same findings out.
|
|
7362
|
+
*
|
|
7363
|
+
* Order is stable and roughly "worst first": the media the whole rest of the
|
|
7364
|
+
* system reads, then the track's own substance, then the softer signals.
|
|
7365
|
+
*/
|
|
7366
|
+
function analyseDebugTrack(inventory) {
|
|
7367
|
+
const { facts, media, eventCount } = inventory;
|
|
7368
|
+
const durationMs = trackDurationMs(facts);
|
|
7369
|
+
const closed = !facts.active;
|
|
7370
|
+
const out = [];
|
|
7371
|
+
if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
|
|
7372
|
+
else if (closed) {
|
|
7373
|
+
if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
|
|
7374
|
+
if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
|
|
7375
|
+
if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
|
|
7376
|
+
if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
|
|
7377
|
+
if (!hasKind(media, "crop") && !hasKind(media, "faceCrop") && !hasKind(media, "plateCrop")) out.push(finding("MISSING_BEST_CROP", "warn", "No crop of any kind — nothing was cut for a subject."));
|
|
7378
|
+
}
|
|
7379
|
+
if (durationMs < 2e3) out.push(finding("SHORT_TRACK", "warn", `Track lasted ${String(durationMs)} ms, under the ${String(SHORT_TRACK_MS)} ms floor — likely a fragment.`));
|
|
7380
|
+
if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
|
|
7381
|
+
if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
|
|
7382
|
+
if (durationMs >= 1e4 && facts.snapshotCount === 0 && !hasKind(media, "snapshot")) out.push(finding("NO_PERIODIC_SNAPSHOTS", "warn", `No periodic snapshot over ${String(durationMs)} ms — the movement gate never opened.`));
|
|
7383
|
+
if (closed && hasKind(media, "lastFrame")) {
|
|
7384
|
+
const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
|
|
7385
|
+
const lag = facts.lastSeen - lastFrameAt;
|
|
7386
|
+
if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
|
|
7387
|
+
}
|
|
7388
|
+
if (closed && durationMs >= 1e4 && media.length > 0) {
|
|
7389
|
+
const latest = latestMediaAt(media);
|
|
7390
|
+
if (latest !== null) {
|
|
7391
|
+
const covered = (latest - facts.firstSeen) / durationMs;
|
|
7392
|
+
if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
|
|
7396
|
+
if (facts.labelScore !== void 0 && facts.labelScore < .5) out.push(finding("LOW_BEST_SCORE", "info", `Best label score ${facts.labelScore.toFixed(2)} is under ${String(LOW_BEST_SCORE)}.`));
|
|
7397
|
+
return out;
|
|
7398
|
+
}
|
|
7399
|
+
/** Fold an inventory into the report shape, findings included. */
|
|
7400
|
+
function buildDebugTrackReport(inventory) {
|
|
7401
|
+
const { facts, media, eventCount } = inventory;
|
|
7402
|
+
const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
|
|
7403
|
+
return {
|
|
7404
|
+
trackId: facts.trackId,
|
|
7405
|
+
deviceId: facts.deviceId,
|
|
7406
|
+
className: facts.className,
|
|
7407
|
+
...facts.label === void 0 ? {} : { label: facts.label },
|
|
7408
|
+
firstSeen: facts.firstSeen,
|
|
7409
|
+
lastSeen: facts.lastSeen,
|
|
7410
|
+
durationMs: trackDurationMs(facts),
|
|
7411
|
+
active: facts.active,
|
|
7412
|
+
mediaCount: media.length,
|
|
7413
|
+
mediaKinds: kinds,
|
|
7414
|
+
eventCount,
|
|
7415
|
+
positionCount: facts.positionCount,
|
|
7416
|
+
findings: analyseDebugTrack(inventory)
|
|
7417
|
+
};
|
|
7418
|
+
}
|
|
7419
|
+
//#endregion
|
|
7420
|
+
//#region src/pipeline-analytics/debug/debug-service.ts
|
|
7421
|
+
/**
|
|
7422
|
+
* The three media owners one track's files hang off.
|
|
7423
|
+
*
|
|
7424
|
+
* A track's face and plate crops are NOT owned by `track:<trackId>` — they live
|
|
7425
|
+
* under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
|
|
7426
|
+
* first owner reports MISSING_BEST_CROP on every track that produced a face,
|
|
7427
|
+
* which is the opposite of the truth.
|
|
7428
|
+
*/
|
|
7429
|
+
var MEDIA_OWNERS = [
|
|
7430
|
+
{
|
|
7431
|
+
kind: "track",
|
|
7432
|
+
prefix: ""
|
|
7433
|
+
},
|
|
7434
|
+
{
|
|
7435
|
+
kind: "face",
|
|
7436
|
+
prefix: "face-"
|
|
7437
|
+
},
|
|
7438
|
+
{
|
|
7439
|
+
kind: "plate",
|
|
7440
|
+
prefix: "plate-"
|
|
7441
|
+
}
|
|
7442
|
+
];
|
|
7443
|
+
function toFacts(track) {
|
|
7444
|
+
return {
|
|
7445
|
+
trackId: track.trackId,
|
|
7446
|
+
deviceId: track.deviceId,
|
|
7447
|
+
className: track.className,
|
|
7448
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7449
|
+
...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
|
|
7450
|
+
firstSeen: track.firstSeen,
|
|
7451
|
+
lastSeen: track.lastSeen,
|
|
7452
|
+
active: track.active,
|
|
7453
|
+
positionCount: track.positions.length,
|
|
7454
|
+
snapshotCount: track.snapshots.length,
|
|
7455
|
+
...track.importance === void 0 ? {} : { importance: track.importance },
|
|
7456
|
+
...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
function toListItem(track) {
|
|
7460
|
+
return {
|
|
7461
|
+
trackId: track.trackId,
|
|
7462
|
+
deviceId: track.deviceId,
|
|
7463
|
+
className: track.className,
|
|
7464
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7465
|
+
firstSeen: track.firstSeen,
|
|
7466
|
+
lastSeen: track.lastSeen,
|
|
7467
|
+
durationMs: trackDurationMs(toFacts(track)),
|
|
7468
|
+
active: track.active
|
|
7469
|
+
};
|
|
7470
|
+
}
|
|
7471
|
+
/** Which cameras to sweep — the caller's list, else every camera with tracks. */
|
|
7472
|
+
async function resolveDeviceIds(deps, requested) {
|
|
7473
|
+
if (requested !== void 0 && requested.length > 0) return requested;
|
|
7474
|
+
return deps.listDeviceIds();
|
|
7475
|
+
}
|
|
7476
|
+
/**
|
|
7477
|
+
* The marked set, newest first per camera.
|
|
7478
|
+
*
|
|
7479
|
+
* One indexed query per camera and nothing else — no media, no events, and no
|
|
7480
|
+
* trajectory: `listDebug` reads `slim` rows, so nothing here parses the
|
|
7481
|
+
* `positions` JSON. The counts an operator wants (media, events, positions) come
|
|
7482
|
+
* from `debug.analyze`, per track, by id.
|
|
7483
|
+
*
|
|
7484
|
+
* A camera that hits `limit` sets `truncated`, because a list that silently
|
|
7485
|
+
* stops at 200 is how an operator concludes a mark did not take.
|
|
7486
|
+
*/
|
|
7487
|
+
async function listDebugTracks(deps, input) {
|
|
7488
|
+
const limit = input.limit ?? 200;
|
|
7489
|
+
const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
|
|
7490
|
+
const tracks = [];
|
|
7491
|
+
let truncated = false;
|
|
7492
|
+
for (const deviceId of deviceIds) {
|
|
7493
|
+
const rows = await deps.listDebugTracks(deviceId, limit);
|
|
7494
|
+
if (rows.length >= limit) {
|
|
7495
|
+
truncated = true;
|
|
7496
|
+
deps.logger.warn("debug listTracks hit the per-camera cap", {
|
|
7497
|
+
tags: { deviceId },
|
|
7498
|
+
meta: {
|
|
7499
|
+
deviceId,
|
|
7500
|
+
limit
|
|
7501
|
+
}
|
|
7502
|
+
});
|
|
7503
|
+
}
|
|
7504
|
+
for (const row of rows) tracks.push(toListItem(row));
|
|
7505
|
+
}
|
|
7506
|
+
return {
|
|
7507
|
+
tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
|
|
7508
|
+
truncated
|
|
7509
|
+
};
|
|
7510
|
+
}
|
|
7511
|
+
/**
|
|
7512
|
+
* Gather one track's whole inventory: three media queries plus its events.
|
|
7513
|
+
*
|
|
7514
|
+
* The events are RETURNED as well as counted — the bundle writes them to
|
|
7515
|
+
* `events.json`, and fetching them a second time there would double the query
|
|
7516
|
+
* count of an export for a number the caller already holds.
|
|
7517
|
+
*/
|
|
7518
|
+
async function gatherTrack(deps, track) {
|
|
7519
|
+
const media = [];
|
|
7520
|
+
for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
|
|
7521
|
+
const events = await deps.listEvents(track.trackId);
|
|
7522
|
+
return {
|
|
7523
|
+
inventory: {
|
|
7524
|
+
facts: toFacts(track),
|
|
7525
|
+
media,
|
|
7526
|
+
eventCount: events.length
|
|
7527
|
+
},
|
|
7528
|
+
events
|
|
7529
|
+
};
|
|
7530
|
+
}
|
|
7531
|
+
/**
|
|
7532
|
+
* Analyse the named tracks.
|
|
7533
|
+
*
|
|
7534
|
+
* An id that no longer resolves is reported in `vanished` rather than dropped: a
|
|
7535
|
+
* `debug` mark does not pin retention (D81), so a track CAN disappear between
|
|
7536
|
+
* the list and the analysis, and the tab has to be able to say so instead of
|
|
7537
|
+
* quietly showing fewer rows than it listed.
|
|
7538
|
+
*/
|
|
7539
|
+
async function analyzeDebugTracks(deps, input) {
|
|
7540
|
+
const reports = [];
|
|
7541
|
+
const vanished = [];
|
|
7542
|
+
for (const trackId of input.trackIds) {
|
|
7543
|
+
const track = await deps.getTrack(trackId);
|
|
7544
|
+
if (track === null) {
|
|
7545
|
+
vanished.push(trackId);
|
|
7546
|
+
deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
|
|
7547
|
+
continue;
|
|
7548
|
+
}
|
|
7549
|
+
const gathered = await gatherTrack(deps, track);
|
|
7550
|
+
reports.push(buildDebugTrackReport(gathered.inventory));
|
|
7551
|
+
}
|
|
7552
|
+
return {
|
|
7553
|
+
reports,
|
|
7554
|
+
vanished
|
|
7555
|
+
};
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
7558
|
+
* Plan the bundle — INDEX READS ONLY, no blob touched.
|
|
7559
|
+
*
|
|
7560
|
+
* The plan is what the manifest is built from and what the handler streams
|
|
7561
|
+
* against, so it must be produced in one pass: a plan that re-queried while
|
|
7562
|
+
* streaming could describe a set the archive does not contain.
|
|
7563
|
+
*
|
|
7564
|
+
* **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
|
|
7565
|
+
* index rows; exporting is every blob those rows point at, base64-decoded and
|
|
7566
|
+
* pushed through the hub's one thread. An unbounded "export all" is a
|
|
7567
|
+
* self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
|
|
7568
|
+
* rather than quietly shipping less than the button offered.
|
|
7569
|
+
*
|
|
7570
|
+
* Each row is re-read at full projection: `listDebugTracks` deliberately returns
|
|
7571
|
+
* `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
|
|
7572
|
+
* every track and write an empty `positions` array into `track.json`.
|
|
7573
|
+
*/
|
|
7574
|
+
async function buildDebugBundlePlan(deps, requestedDeviceIds) {
|
|
7575
|
+
const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
|
|
7576
|
+
const tracks = [];
|
|
7577
|
+
const covered = [];
|
|
7578
|
+
let capped = false;
|
|
7579
|
+
for (const deviceId of deviceIds) {
|
|
7580
|
+
if (tracks.length >= 25) {
|
|
7581
|
+
capped = true;
|
|
7582
|
+
break;
|
|
7583
|
+
}
|
|
7584
|
+
const rows = await deps.listDebugTracks(deviceId, 200);
|
|
7585
|
+
if (rows.length > 0) covered.push(deviceId);
|
|
7586
|
+
for (const row of rows) {
|
|
7587
|
+
if (tracks.length >= 25) {
|
|
7588
|
+
capped = true;
|
|
7589
|
+
break;
|
|
7590
|
+
}
|
|
7591
|
+
const full = await deps.getTrack(row.trackId) ?? row;
|
|
7592
|
+
const { inventory, events } = await gatherTrack(deps, full);
|
|
7593
|
+
tracks.push({
|
|
7594
|
+
report: buildDebugTrackReport(inventory),
|
|
7595
|
+
track: full,
|
|
7596
|
+
events,
|
|
7597
|
+
media: planTrackMediaNames(inventory.media)
|
|
7598
|
+
});
|
|
7599
|
+
}
|
|
7600
|
+
}
|
|
7601
|
+
if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
|
|
7602
|
+
limit: 25,
|
|
7603
|
+
deviceIds: covered
|
|
7604
|
+
} });
|
|
7605
|
+
return {
|
|
7606
|
+
generatedAt: deps.now(),
|
|
7607
|
+
deviceIds: covered,
|
|
7608
|
+
tracks,
|
|
7609
|
+
capped
|
|
7610
|
+
};
|
|
7611
|
+
}
|
|
6726
7612
|
//#endregion
|
|
6727
7613
|
//#region src/pipeline-analytics/embedding-actions.ts
|
|
6728
7614
|
/**
|
|
@@ -10969,6 +11855,13 @@ function eventsToClips(events, pad) {
|
|
|
10969
11855
|
...events.audio.map((e) => make(e, "audio"))
|
|
10970
11856
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
10971
11857
|
}
|
|
11858
|
+
/** Keep only clips whose window overlaps at least one recorded range — a clip
|
|
11859
|
+
* is a window over footage, so a window with nothing behind it is unplayable
|
|
11860
|
+
* and must not be listed (a camera that never recorded showed 15 dead audio
|
|
11861
|
+
* "clips" in the viewer ribbon, every tap a 500). Pure. */
|
|
11862
|
+
function filterClipsByFootage(clips, ranges) {
|
|
11863
|
+
return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
|
|
11864
|
+
}
|
|
10972
11865
|
function createVideoclipsProvider(deps) {
|
|
10973
11866
|
return {
|
|
10974
11867
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
@@ -10978,7 +11871,20 @@ function createVideoclipsProvider(deps) {
|
|
|
10978
11871
|
until,
|
|
10979
11872
|
limit
|
|
10980
11873
|
}), deps.defaultPad);
|
|
10981
|
-
|
|
11874
|
+
let filtered = clips;
|
|
11875
|
+
if (deps.fetchAvailability) try {
|
|
11876
|
+
filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
|
|
11877
|
+
deviceId,
|
|
11878
|
+
since,
|
|
11879
|
+
until
|
|
11880
|
+
}));
|
|
11881
|
+
} catch (err) {
|
|
11882
|
+
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
11883
|
+
deviceId,
|
|
11884
|
+
error: err instanceof Error ? err.message : String(err)
|
|
11885
|
+
});
|
|
11886
|
+
}
|
|
11887
|
+
return limit != null ? filtered.slice(0, limit) : filtered;
|
|
10982
11888
|
},
|
|
10983
11889
|
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
10984
11890
|
const parsed = parseClipId(clipId);
|
|
@@ -10991,6 +11897,230 @@ function createVideoclipsProvider(deps) {
|
|
|
10991
11897
|
}
|
|
10992
11898
|
};
|
|
10993
11899
|
}
|
|
11900
|
+
/**
|
|
11901
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
11902
|
+
*
|
|
11903
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
11904
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
11905
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
11906
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
11907
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
11908
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
11909
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
11910
|
+
* The re-encode is smaller than what it replaces.
|
|
11911
|
+
*
|
|
11912
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
11913
|
+
* clip, one rate, two containers.
|
|
11914
|
+
*/
|
|
11915
|
+
var DEFAULT_SPEED = 4;
|
|
11916
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
11917
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
11918
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
11919
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
11920
|
+
var RING_MP4_FPS = 15;
|
|
11921
|
+
var NO_MEDIA = {
|
|
11922
|
+
mp4: null,
|
|
11923
|
+
gif: null,
|
|
11924
|
+
source: "none",
|
|
11925
|
+
startOffsetMs: null,
|
|
11926
|
+
endOffsetMs: null,
|
|
11927
|
+
profile: null,
|
|
11928
|
+
video: null
|
|
11929
|
+
};
|
|
11930
|
+
var EventMediaService = class {
|
|
11931
|
+
deps;
|
|
11932
|
+
constructor(deps) {
|
|
11933
|
+
this.deps = deps;
|
|
11934
|
+
}
|
|
11935
|
+
/**
|
|
11936
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
11937
|
+
* a notification, and every branch that drops it logs why.
|
|
11938
|
+
*/
|
|
11939
|
+
async cut(request) {
|
|
11940
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
11941
|
+
const produced = await this.produce(request);
|
|
11942
|
+
if (produced !== null) return produced;
|
|
11943
|
+
return this.fallbackToRing(request);
|
|
11944
|
+
}
|
|
11945
|
+
async produce(request) {
|
|
11946
|
+
const deviceId = request.deviceId;
|
|
11947
|
+
const log = this.deps.logger;
|
|
11948
|
+
const kinds = [];
|
|
11949
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
11950
|
+
if (request.wantGif) kinds.push("gif");
|
|
11951
|
+
let production;
|
|
11952
|
+
try {
|
|
11953
|
+
production = await this.deps.produce({
|
|
11954
|
+
deviceId,
|
|
11955
|
+
aroundMs: request.aroundMs,
|
|
11956
|
+
preSeconds: request.preRollSec,
|
|
11957
|
+
postSeconds: request.postRollSec,
|
|
11958
|
+
kinds,
|
|
11959
|
+
gifMaxWidth: 640,
|
|
11960
|
+
gifFps: 12,
|
|
11961
|
+
gifSpeed: 4,
|
|
11962
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
11963
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
11964
|
+
});
|
|
11965
|
+
} catch (err) {
|
|
11966
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
11967
|
+
tags: { deviceId },
|
|
11968
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
11969
|
+
});
|
|
11970
|
+
return null;
|
|
11971
|
+
}
|
|
11972
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
11973
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
11974
|
+
if (mp4 === null && gif === null) {
|
|
11975
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
11976
|
+
tags: { deviceId },
|
|
11977
|
+
meta: {
|
|
11978
|
+
kinds: kinds.join(","),
|
|
11979
|
+
profile: production.profile
|
|
11980
|
+
}
|
|
11981
|
+
});
|
|
11982
|
+
return null;
|
|
11983
|
+
}
|
|
11984
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
11985
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
11986
|
+
log.info("nc media: one production, every attachment", {
|
|
11987
|
+
tags: { deviceId },
|
|
11988
|
+
meta: {
|
|
11989
|
+
profile: production.profile,
|
|
11990
|
+
video: production.video,
|
|
11991
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
11992
|
+
gifBytes: gif?.byteLength ?? null,
|
|
11993
|
+
startOffsetMs,
|
|
11994
|
+
endOffsetMs,
|
|
11995
|
+
sharedWindow: true
|
|
11996
|
+
}
|
|
11997
|
+
});
|
|
11998
|
+
return {
|
|
11999
|
+
mp4,
|
|
12000
|
+
gif,
|
|
12001
|
+
source: "produced",
|
|
12002
|
+
startOffsetMs,
|
|
12003
|
+
endOffsetMs,
|
|
12004
|
+
profile: production.profile,
|
|
12005
|
+
video: production.video
|
|
12006
|
+
};
|
|
12007
|
+
}
|
|
12008
|
+
/**
|
|
12009
|
+
* Redeem one artifact handle at the node that produced it.
|
|
12010
|
+
*
|
|
12011
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
12012
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
12013
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
12014
|
+
* existed and did not arrive.
|
|
12015
|
+
*/
|
|
12016
|
+
async redeem(deviceId, media, kind) {
|
|
12017
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
12018
|
+
if (artifact === void 0) return null;
|
|
12019
|
+
try {
|
|
12020
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
12021
|
+
if (res === null) {
|
|
12022
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
12023
|
+
tags: { deviceId },
|
|
12024
|
+
meta: {
|
|
12025
|
+
kind,
|
|
12026
|
+
handle: artifact.handle,
|
|
12027
|
+
nodeId: artifact.nodeId
|
|
12028
|
+
}
|
|
12029
|
+
});
|
|
12030
|
+
return null;
|
|
12031
|
+
}
|
|
12032
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
12033
|
+
if (buf.byteLength === 0) return null;
|
|
12034
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
12035
|
+
bytes.set(buf);
|
|
12036
|
+
return bytes;
|
|
12037
|
+
} catch (err) {
|
|
12038
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
12039
|
+
tags: { deviceId },
|
|
12040
|
+
meta: {
|
|
12041
|
+
kind,
|
|
12042
|
+
handle: artifact.handle,
|
|
12043
|
+
nodeId: artifact.nodeId,
|
|
12044
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12045
|
+
}
|
|
12046
|
+
});
|
|
12047
|
+
return null;
|
|
12048
|
+
}
|
|
12049
|
+
}
|
|
12050
|
+
/**
|
|
12051
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
12052
|
+
*
|
|
12053
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
12054
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
12055
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
12056
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
12057
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
12058
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
12059
|
+
* used and handed to the second.
|
|
12060
|
+
*
|
|
12061
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
12062
|
+
* production method must not stop attaching footage, and three of the four
|
|
12063
|
+
* live rules on this install ask for a gif and nothing else.
|
|
12064
|
+
*/
|
|
12065
|
+
async fallbackToRing(request) {
|
|
12066
|
+
const deviceId = request.deviceId;
|
|
12067
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
12068
|
+
const window = {
|
|
12069
|
+
deviceId,
|
|
12070
|
+
aroundMs: request.aroundMs,
|
|
12071
|
+
preRollSec: request.preRollSec,
|
|
12072
|
+
postRollSec: request.postRollSec,
|
|
12073
|
+
speed,
|
|
12074
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
12075
|
+
};
|
|
12076
|
+
try {
|
|
12077
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
12078
|
+
...window,
|
|
12079
|
+
format: "mp4",
|
|
12080
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
12081
|
+
fps: RING_MP4_FPS
|
|
12082
|
+
}) : null;
|
|
12083
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
12084
|
+
...window,
|
|
12085
|
+
format: "gif",
|
|
12086
|
+
maxWidth: 640,
|
|
12087
|
+
fps: 12 / 4,
|
|
12088
|
+
speed: 4
|
|
12089
|
+
}) : null;
|
|
12090
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
12091
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
12092
|
+
tags: { deviceId },
|
|
12093
|
+
meta: { aroundMs: request.aroundMs }
|
|
12094
|
+
});
|
|
12095
|
+
return NO_MEDIA;
|
|
12096
|
+
}
|
|
12097
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
12098
|
+
tags: { deviceId },
|
|
12099
|
+
meta: {
|
|
12100
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
12101
|
+
gifBytes: gif?.byteLength ?? null,
|
|
12102
|
+
profile: request.profile ?? null,
|
|
12103
|
+
sharedWindow: "by-parameters"
|
|
12104
|
+
}
|
|
12105
|
+
});
|
|
12106
|
+
return {
|
|
12107
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
12108
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
12109
|
+
source: "ring-fallback",
|
|
12110
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
12111
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
12112
|
+
profile: request.profile ?? null,
|
|
12113
|
+
video: null
|
|
12114
|
+
};
|
|
12115
|
+
} catch (err) {
|
|
12116
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
12117
|
+
tags: { deviceId },
|
|
12118
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
12119
|
+
});
|
|
12120
|
+
return NO_MEDIA;
|
|
12121
|
+
}
|
|
12122
|
+
}
|
|
12123
|
+
};
|
|
10994
12124
|
//#endregion
|
|
10995
12125
|
//#region src/shared/frame/crop-extractor.ts
|
|
10996
12126
|
/**
|
|
@@ -21879,6 +23009,10 @@ var TRACKS_COLUMNS = [
|
|
|
21879
23009
|
name: "debug",
|
|
21880
23010
|
type: "BOOLEAN"
|
|
21881
23011
|
},
|
|
23012
|
+
{
|
|
23013
|
+
name: "hasFace",
|
|
23014
|
+
type: "BOOLEAN"
|
|
23015
|
+
},
|
|
21882
23016
|
{
|
|
21883
23017
|
name: "envMinX",
|
|
21884
23018
|
type: "REAL"
|
|
@@ -21959,7 +23093,8 @@ function cloneTrack(t) {
|
|
|
21959
23093
|
retrainStatus: t.retrainStatus,
|
|
21960
23094
|
markForTrain: t.retrainStatus === "staging"
|
|
21961
23095
|
} : {},
|
|
21962
|
-
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
23096
|
+
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
23097
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
|
|
21963
23098
|
};
|
|
21964
23099
|
}
|
|
21965
23100
|
var TrackStore = class {
|
|
@@ -22322,6 +23457,49 @@ var TrackStore = class {
|
|
|
22322
23457
|
}
|
|
22323
23458
|
}
|
|
22324
23459
|
/**
|
|
23460
|
+
* Record that a face DETECTOR found a face on this track. Set-once, never
|
|
23461
|
+
* cleared, and deliberately NOT part of {@link setFlags}: that method is the
|
|
23462
|
+
* operator's write surface and this is the pipeline's.
|
|
23463
|
+
*
|
|
23464
|
+
* **The signal is detection, not recognition.** The caller is the detail-route
|
|
23465
|
+
* fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
|
|
23466
|
+
* `minFacePx` gate, before the recognizer is consulted, and regardless of
|
|
23467
|
+
* whether face recognition is enabled at all. That is the whole point: the
|
|
23468
|
+
* viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
|
|
23469
|
+
* and a flag that only fired when the recognizer answered would be true
|
|
23470
|
+
* exactly when the badge must not show.
|
|
23471
|
+
*
|
|
23472
|
+
* **Idempotent, and it does not write per detail.** A face detail lands many
|
|
23473
|
+
* times over a track's life. While the track is LIVE the flag is set in RAM
|
|
23474
|
+
* only and rides `persistCompleted` at expiry — one row write, not one per
|
|
23475
|
+
* frame. Once the flag is set, every later call returns immediately.
|
|
23476
|
+
*
|
|
23477
|
+
* The persisted patch is for the LATE detail: enrichment answers routinely
|
|
23478
|
+
* arrive after the track has expired, when there is no RAM entry left and the
|
|
23479
|
+
* row is the only thing to correct. Best-effort — a failed patch costs a badge,
|
|
23480
|
+
* never a track.
|
|
23481
|
+
*/
|
|
23482
|
+
async markFaceDetected(trackId) {
|
|
23483
|
+
const active = this.active.get(trackId);
|
|
23484
|
+
if (active) {
|
|
23485
|
+
if (active.hasFace === true) return;
|
|
23486
|
+
active.hasFace = true;
|
|
23487
|
+
return;
|
|
23488
|
+
}
|
|
23489
|
+
try {
|
|
23490
|
+
await this.store.update.mutate({
|
|
23491
|
+
collection: TRACKS_COLLECTION,
|
|
23492
|
+
id: trackId,
|
|
23493
|
+
data: { hasFace: true }
|
|
23494
|
+
});
|
|
23495
|
+
} catch (err) {
|
|
23496
|
+
this.logger.warn("markFaceDetected persist failed", { meta: {
|
|
23497
|
+
trackId,
|
|
23498
|
+
error: String(err)
|
|
23499
|
+
} });
|
|
23500
|
+
}
|
|
23501
|
+
}
|
|
23502
|
+
/**
|
|
22325
23503
|
* Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
|
|
22326
23504
|
* express.
|
|
22327
23505
|
*
|
|
@@ -22535,6 +23713,71 @@ var TrackStore = class {
|
|
|
22535
23713
|
}
|
|
22536
23714
|
}
|
|
22537
23715
|
/**
|
|
23716
|
+
* The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
|
|
23717
|
+
*
|
|
23718
|
+
* Two things about this query are load-bearing.
|
|
23719
|
+
*
|
|
23720
|
+
* **`where: { debug: true }` matches only the explicit `true`.** The column is
|
|
23721
|
+
* nullable with no default (unlike `retrainStatus`), so a row that was never
|
|
23722
|
+
* marked holds NULL and positive equality skips it — which is exactly the set
|
|
23723
|
+
* wanted here, and the reason the column was left nullable in the first place.
|
|
23724
|
+
*
|
|
23725
|
+
* **The result is re-checked in memory.** `compileFilter` DROPS a predicate
|
|
23726
|
+
* whose column does not exist in SQLite — an older hub whose `declareCollection`
|
|
23727
|
+
* fingerprint never re-ran would answer this query with EVERY track on the
|
|
23728
|
+
* camera, silently, and the tab would present the whole history as
|
|
23729
|
+
* operator-marked. A skipped predicate is indistinguishable from a matching one
|
|
23730
|
+
* at the wire, so the guard is the only thing that can tell them apart.
|
|
23731
|
+
*
|
|
23732
|
+
* **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
|
|
23733
|
+
* JSON column averaging ~10 KB a row, and `'full'` parses it for every row
|
|
23734
|
+
* returned. That is affordable for a handful of marked tracks and ruinous in
|
|
23735
|
+
* the case this method already defends against: if the predicate is dropped,
|
|
23736
|
+
* the store hands back `limit` rows PER CAMERA and the trajectory of the
|
|
23737
|
+
* camera's whole recent history is parsed before the guard below throws it
|
|
23738
|
+
* away. The cheap question comes first (D56) — the list carries no trajectory,
|
|
23739
|
+
* and the per-track analysis reads the one row it needs through
|
|
23740
|
+
* `getPersistedByTrackId`.
|
|
23741
|
+
*/
|
|
23742
|
+
async listDebug(deviceId, limit) {
|
|
23743
|
+
try {
|
|
23744
|
+
const tracks = (await this.store.query.query({
|
|
23745
|
+
collection: TRACKS_COLLECTION,
|
|
23746
|
+
filter: {
|
|
23747
|
+
where: {
|
|
23748
|
+
deviceId,
|
|
23749
|
+
debug: true
|
|
23750
|
+
},
|
|
23751
|
+
orderBy: {
|
|
23752
|
+
field: "lastSeen",
|
|
23753
|
+
direction: "desc"
|
|
23754
|
+
},
|
|
23755
|
+
limit
|
|
23756
|
+
}
|
|
23757
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
|
|
23758
|
+
const marked = tracks.filter((t) => t.debug === true);
|
|
23759
|
+
if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
|
|
23760
|
+
tags: { deviceId },
|
|
23761
|
+
meta: {
|
|
23762
|
+
deviceId,
|
|
23763
|
+
returned: tracks.length,
|
|
23764
|
+
marked: marked.length
|
|
23765
|
+
}
|
|
23766
|
+
});
|
|
23767
|
+
return marked;
|
|
23768
|
+
} catch (err) {
|
|
23769
|
+
this.logger.warn("TrackStore.listDebug failed", {
|
|
23770
|
+
tags: { deviceId },
|
|
23771
|
+
meta: {
|
|
23772
|
+
deviceId,
|
|
23773
|
+
limit,
|
|
23774
|
+
error: String(err)
|
|
23775
|
+
}
|
|
23776
|
+
});
|
|
23777
|
+
return [];
|
|
23778
|
+
}
|
|
23779
|
+
}
|
|
23780
|
+
/**
|
|
22538
23781
|
* How many of this device's tracks are STAGING — optionally only those older
|
|
22539
23782
|
* than a cutoff, i.e. exactly the rows a sweep just declined to touch.
|
|
22540
23783
|
*
|
|
@@ -22941,6 +24184,7 @@ var TrackStore = class {
|
|
|
22941
24184
|
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
22942
24185
|
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
22943
24186
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
24187
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
22944
24188
|
...envelope !== null && dims !== void 0 ? {
|
|
22945
24189
|
envMinX: envelope.minX,
|
|
22946
24190
|
envMinY: envelope.minY,
|
|
@@ -22975,6 +24219,7 @@ var TrackStore = class {
|
|
|
22975
24219
|
const audioLabels = data["audioLabels"];
|
|
22976
24220
|
const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
|
|
22977
24221
|
const debug = data["debug"];
|
|
24222
|
+
const hasFace = data["hasFace"];
|
|
22978
24223
|
const envMinX = data["envMinX"];
|
|
22979
24224
|
const envMinY = data["envMinY"];
|
|
22980
24225
|
const envMaxX = data["envMaxX"];
|
|
@@ -23010,6 +24255,7 @@ var TrackStore = class {
|
|
|
23010
24255
|
markForTrain: retrainParsed.data === "staging"
|
|
23011
24256
|
} : {},
|
|
23012
24257
|
...typeof debug === "boolean" ? { debug } : {},
|
|
24258
|
+
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
23013
24259
|
...envelope !== null ? { envelope } : {}
|
|
23014
24260
|
};
|
|
23015
24261
|
}
|
|
@@ -25380,6 +26626,31 @@ var LinkedCamerasCache = class {
|
|
|
25380
26626
|
}
|
|
25381
26627
|
};
|
|
25382
26628
|
/**
|
|
26629
|
+
* THE camera-set derivation for a sensor-cap state change — one function, so
|
|
26630
|
+
* every consumer of a device-event attributes it to the same cameras.
|
|
26631
|
+
*
|
|
26632
|
+
* It is shared on purpose. The self-camera half (a doorbell wrapper bound on
|
|
26633
|
+
* camera 615 makes 615 both source and subject) was added to the history-row
|
|
26634
|
+
* ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
|
|
26635
|
+
* which kept resolving `camerasFor` alone. The result: camera 615's real
|
|
26636
|
+
* doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
|
|
26637
|
+
* timeline marker, for weeks, with the divergence invisible in both codepaths.
|
|
26638
|
+
* Two derivations of "which cameras" is one too many.
|
|
26639
|
+
*/
|
|
26640
|
+
async function resolveSensorEventCameras(cache, data) {
|
|
26641
|
+
const linkedCameraIds = await cache.camerasFor(data.deviceId);
|
|
26642
|
+
const isCamera = await cache.isCamera(data.deviceId);
|
|
26643
|
+
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
26644
|
+
if (cameraIds.length === 0) return {
|
|
26645
|
+
cameraIds,
|
|
26646
|
+
dropReason: isCamera ? "camera-native-cap" : "no-camera"
|
|
26647
|
+
};
|
|
26648
|
+
return {
|
|
26649
|
+
cameraIds,
|
|
26650
|
+
dropReason: null
|
|
26651
|
+
};
|
|
26652
|
+
}
|
|
26653
|
+
/**
|
|
25383
26654
|
* One `DeviceStateChanged` → N history rows, one per camera the event belongs
|
|
25384
26655
|
* to: the emitting device's LINKED cameras, plus the emitting device ITSELF
|
|
25385
26656
|
* when it is a camera carrying the sensor cap (a doorbell wrapper bound on
|
|
@@ -25392,11 +26663,9 @@ var LinkedCamerasCache = class {
|
|
|
25392
26663
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
25393
26664
|
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
25394
26665
|
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);
|
|
26666
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
26667
|
+
if (dropReason !== null) {
|
|
26668
|
+
deps.onDropped?.(dropReason, descriptor.kind);
|
|
25400
26669
|
return 0;
|
|
25401
26670
|
}
|
|
25402
26671
|
const slice = data.slice;
|
|
@@ -26135,6 +27404,70 @@ var EventMediaDispatcher = class {
|
|
|
26135
27404
|
}
|
|
26136
27405
|
}
|
|
26137
27406
|
};
|
|
27407
|
+
/**
|
|
27408
|
+
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
27409
|
+
* synthetic track per camera the event belongs to. Returns the number of
|
|
27410
|
+
* markers that landed. Best-effort (telemetry, D8) — a materializer that
|
|
27411
|
+
* throws propagates to the caller, which logs it.
|
|
27412
|
+
*/
|
|
27413
|
+
async function projectSensorMarkers(deps, data, timestamp) {
|
|
27414
|
+
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
27415
|
+
if (descriptor === void 0) {
|
|
27416
|
+
deps.onSkipped({
|
|
27417
|
+
cameraId: null,
|
|
27418
|
+
sourceDeviceId: data.deviceId,
|
|
27419
|
+
capName: data.capName,
|
|
27420
|
+
kind: null,
|
|
27421
|
+
reason: "unmapped-cap"
|
|
27422
|
+
});
|
|
27423
|
+
return 0;
|
|
27424
|
+
}
|
|
27425
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
27426
|
+
if (dropReason !== null) {
|
|
27427
|
+
deps.onSkipped({
|
|
27428
|
+
cameraId: null,
|
|
27429
|
+
sourceDeviceId: data.deviceId,
|
|
27430
|
+
capName: data.capName,
|
|
27431
|
+
kind: descriptor.kind,
|
|
27432
|
+
reason: dropReason
|
|
27433
|
+
});
|
|
27434
|
+
return 0;
|
|
27435
|
+
}
|
|
27436
|
+
const producingDeviceName = await deps.producingDeviceName(data.deviceId);
|
|
27437
|
+
let landed = 0;
|
|
27438
|
+
for (const cameraId of cameraIds) {
|
|
27439
|
+
const track = await deps.markers.materialize({
|
|
27440
|
+
cameraId,
|
|
27441
|
+
sourceDeviceId: data.deviceId,
|
|
27442
|
+
source: "sensor",
|
|
27443
|
+
kind: descriptor.kind,
|
|
27444
|
+
timestamp,
|
|
27445
|
+
snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
|
|
27446
|
+
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
27447
|
+
});
|
|
27448
|
+
if (track === null) {
|
|
27449
|
+
deps.onSkipped({
|
|
27450
|
+
cameraId,
|
|
27451
|
+
sourceDeviceId: data.deviceId,
|
|
27452
|
+
capName: data.capName,
|
|
27453
|
+
kind: descriptor.kind,
|
|
27454
|
+
reason: "not-materialized"
|
|
27455
|
+
});
|
|
27456
|
+
continue;
|
|
27457
|
+
}
|
|
27458
|
+
landed++;
|
|
27459
|
+
deps.onProjected({
|
|
27460
|
+
cameraId,
|
|
27461
|
+
sourceDeviceId: data.deviceId,
|
|
27462
|
+
capName: data.capName,
|
|
27463
|
+
kind: descriptor.kind,
|
|
27464
|
+
trackId: track.trackId,
|
|
27465
|
+
selfCamera: cameraId === data.deviceId,
|
|
27466
|
+
hasMedia: track.snapshots.length > 0
|
|
27467
|
+
});
|
|
27468
|
+
}
|
|
27469
|
+
return landed;
|
|
27470
|
+
}
|
|
26138
27471
|
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
26139
27472
|
var FULL_FRAME_QUALITY = 80;
|
|
26140
27473
|
/**
|
|
@@ -26245,13 +27578,13 @@ var SyntheticTrackMaterializer = class {
|
|
|
26245
27578
|
* answers: ≤10 s old on a mains camera, and on a battery camera the
|
|
26246
27579
|
* intentionally stale-but-honest frame that costs no wake.
|
|
26247
27580
|
*/
|
|
26248
|
-
async fetchSnapshot(cameraId) {
|
|
27581
|
+
async fetchSnapshot(cameraId, timeoutMs) {
|
|
26249
27582
|
try {
|
|
26250
|
-
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }),
|
|
26251
|
-
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
27583
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
|
|
27584
|
+
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
|
|
26252
27585
|
return snap;
|
|
26253
27586
|
} catch (err) {
|
|
26254
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
|
|
27587
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
|
|
26255
27588
|
return null;
|
|
26256
27589
|
}
|
|
26257
27590
|
}
|
|
@@ -26268,14 +27601,14 @@ var SyntheticTrackMaterializer = class {
|
|
|
26268
27601
|
const trackId = this.makeId();
|
|
26269
27602
|
const ts = input.timestamp;
|
|
26270
27603
|
let mediaKey = null;
|
|
26271
|
-
const snap = await this.fetchSnapshot(input.cameraId);
|
|
27604
|
+
const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
|
|
26272
27605
|
if (snap !== null) try {
|
|
26273
27606
|
const raw = Buffer.from(snap.base64, "base64");
|
|
26274
27607
|
let data = raw;
|
|
26275
27608
|
try {
|
|
26276
27609
|
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
26277
27610
|
} catch (err) {
|
|
26278
|
-
this.deps.onError?.("downscaleSnapshot", err);
|
|
27611
|
+
this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
|
|
26279
27612
|
}
|
|
26280
27613
|
mediaKey = await this.deps.media.put({
|
|
26281
27614
|
deviceId: input.cameraId,
|
|
@@ -26286,7 +27619,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26286
27619
|
data
|
|
26287
27620
|
});
|
|
26288
27621
|
} catch (err) {
|
|
26289
|
-
this.deps.onError?.("snapshotMedia", err);
|
|
27622
|
+
this.deps.onError?.("snapshotMedia", err, input.cameraId);
|
|
26290
27623
|
}
|
|
26291
27624
|
const track = {
|
|
26292
27625
|
trackId,
|
|
@@ -26311,7 +27644,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26311
27644
|
try {
|
|
26312
27645
|
await this.deps.tracks.persistSyntheticTrack(track);
|
|
26313
27646
|
} catch (err) {
|
|
26314
|
-
this.deps.onError?.("persistSyntheticTrack", err);
|
|
27647
|
+
this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
|
|
26315
27648
|
return null;
|
|
26316
27649
|
}
|
|
26317
27650
|
return track;
|
|
@@ -29011,8 +30344,6 @@ var REBUILD_PAGE_SIZE = 200;
|
|
|
29011
30344
|
*/
|
|
29012
30345
|
var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
29013
30346
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
29014
|
-
/** Operator-visible name of the fixed integration. */
|
|
29015
|
-
var NC_INTEGRATION_NAME = "Notification Center";
|
|
29016
30347
|
/**
|
|
29017
30348
|
* The FULL action catalog for this addon.
|
|
29018
30349
|
*
|
|
@@ -29023,7 +30354,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
|
|
|
29023
30354
|
*/
|
|
29024
30355
|
var customActions = {
|
|
29025
30356
|
...ncActions,
|
|
29026
|
-
...embeddingActions
|
|
30357
|
+
...embeddingActions,
|
|
30358
|
+
...debugActions
|
|
29027
30359
|
};
|
|
29028
30360
|
/**
|
|
29029
30361
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -29106,15 +30438,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
29106
30438
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
29107
30439
|
* narrowing the old raw read applied inline. */
|
|
29108
30440
|
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
30441
|
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
29119
30442
|
var NcArtifactSecretSchema = require_dist.string();
|
|
29120
30443
|
var EmbeddingEnabledSchema = require_dist.boolean();
|
|
@@ -29279,6 +30602,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29279
30602
|
trainingExportDataPlane = null;
|
|
29280
30603
|
/** Handle for the retrain-export data-plane listener (dispose on shutdown). */
|
|
29281
30604
|
retrainExportDataPlane = null;
|
|
30605
|
+
/** Handle for the debug-export data-plane listener (dispose on shutdown). */
|
|
30606
|
+
debugExportDataPlane = null;
|
|
29282
30607
|
/** The retrain curation surface — see `retrain/retrain-service.ts`. Null
|
|
29283
30608
|
* until the stores are built. */
|
|
29284
30609
|
retrain = null;
|
|
@@ -29288,38 +30613,83 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29288
30613
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
29289
30614
|
ncArtifactPlane = null;
|
|
29290
30615
|
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
30616
|
/**
|
|
29296
|
-
*
|
|
29297
|
-
* the REASON is always logged with `tags: { deviceId }`.
|
|
30617
|
+
* Roadmap 4b: ONE cut of an event, in every container the rule asked for.
|
|
29298
30618
|
*
|
|
29299
|
-
*
|
|
29300
|
-
*
|
|
29301
|
-
* precisely the thing an operator cannot diagnose from a phone.
|
|
30619
|
+
* Built on first use — a hub whose rules never attach footage never
|
|
30620
|
+
* constructs it, and constructing it costs nothing until `cut`.
|
|
29302
30621
|
*/
|
|
29303
|
-
|
|
29304
|
-
|
|
30622
|
+
ncEventMedia = null;
|
|
30623
|
+
/**
|
|
30624
|
+
* The dispatcher's single media dep.
|
|
30625
|
+
*
|
|
30626
|
+
* ONE call for the gif and the mp4, deliberately: rendering them separately
|
|
30627
|
+
* is what produced a gif around the event and a video seven seconds after it
|
|
30628
|
+
* (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
|
|
30629
|
+
* slot, asks the broker's own prebuffer ring for the window at that slot's
|
|
30630
|
+
* own width in real time — which is what makes the broker COPY rather than
|
|
30631
|
+
* re-encode — and derives the gif from those same bytes.
|
|
30632
|
+
*/
|
|
30633
|
+
async renderEventMedia(req) {
|
|
29305
30634
|
const api = this.ctx.api;
|
|
29306
|
-
this.
|
|
29307
|
-
logger:
|
|
29308
|
-
|
|
30635
|
+
this.ncEventMedia ??= new EventMediaService({
|
|
30636
|
+
logger: this.ctx.logger.child("nc-event-media"),
|
|
30637
|
+
produce: (input) => api.streamBroker.produceEventMedia.mutate({
|
|
30638
|
+
deviceId: input.deviceId,
|
|
30639
|
+
aroundMs: input.aroundMs,
|
|
30640
|
+
preSeconds: input.preSeconds,
|
|
30641
|
+
postSeconds: input.postSeconds,
|
|
30642
|
+
kinds: [...input.kinds],
|
|
30643
|
+
gifMaxWidth: input.gifMaxWidth,
|
|
30644
|
+
gifFps: input.gifFps,
|
|
30645
|
+
gifSpeed: input.gifSpeed,
|
|
30646
|
+
speed: input.speed,
|
|
30647
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
30648
|
+
}),
|
|
30649
|
+
fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? require_dist.nodePin(nodeId) : void 0),
|
|
30650
|
+
renderRingClip: async (input) => {
|
|
30651
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
30652
|
+
deviceId: input.deviceId,
|
|
30653
|
+
aroundMs: input.aroundMs,
|
|
30654
|
+
format: input.format,
|
|
30655
|
+
preRollSec: input.preRollSec,
|
|
30656
|
+
postRollSec: input.postRollSec,
|
|
30657
|
+
maxWidth: input.maxWidth,
|
|
30658
|
+
fps: input.fps,
|
|
30659
|
+
speed: input.speed,
|
|
30660
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
30661
|
+
});
|
|
30662
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
30663
|
+
if (buf.byteLength === 0) return null;
|
|
30664
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
30665
|
+
bytes.set(buf);
|
|
30666
|
+
return bytes;
|
|
30667
|
+
}
|
|
29309
30668
|
});
|
|
29310
30669
|
try {
|
|
29311
|
-
const
|
|
30670
|
+
const media = await this.ncEventMedia.cut({
|
|
29312
30671
|
deviceId: req.deviceId,
|
|
29313
30672
|
aroundMs: req.aroundMs,
|
|
29314
|
-
|
|
30673
|
+
wantMp4: req.wantMp4,
|
|
30674
|
+
wantGif: req.wantGif,
|
|
30675
|
+
preRollSec: req.preRollSec,
|
|
30676
|
+
postRollSec: req.postRollSec,
|
|
30677
|
+
...req.profile !== void 0 ? { profile: req.profile } : {},
|
|
30678
|
+
...req.speed !== void 0 ? { speed: req.speed } : {}
|
|
29315
30679
|
});
|
|
29316
|
-
return
|
|
30680
|
+
return {
|
|
30681
|
+
mp4: media.mp4,
|
|
30682
|
+
gif: media.gif
|
|
30683
|
+
};
|
|
29317
30684
|
} catch (err) {
|
|
29318
|
-
|
|
30685
|
+
this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
|
|
29319
30686
|
tags: { deviceId: req.deviceId },
|
|
29320
30687
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
29321
30688
|
});
|
|
29322
|
-
return
|
|
30689
|
+
return {
|
|
30690
|
+
mp4: null,
|
|
30691
|
+
gif: null
|
|
30692
|
+
};
|
|
29323
30693
|
}
|
|
29324
30694
|
}
|
|
29325
30695
|
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
@@ -29397,7 +30767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29397
30767
|
});
|
|
29398
30768
|
const served = await this.ctx.dataPlane?.serve({
|
|
29399
30769
|
prefix: "nc-action",
|
|
29400
|
-
access: "
|
|
30770
|
+
access: planeAccess("nc-action"),
|
|
29401
30771
|
handler: plane.handler
|
|
29402
30772
|
}) ?? null;
|
|
29403
30773
|
this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
|
|
@@ -29451,7 +30821,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29451
30821
|
});
|
|
29452
30822
|
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
29453
30823
|
prefix: "nc-artifact",
|
|
29454
|
-
access: "
|
|
30824
|
+
access: planeAccess("nc-artifact"),
|
|
29455
30825
|
handler: plane.handler
|
|
29456
30826
|
}) ?? null;
|
|
29457
30827
|
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
@@ -29734,7 +31104,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29734
31104
|
const api = this.ctx.api;
|
|
29735
31105
|
if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
|
|
29736
31106
|
await this.declareCollections(api);
|
|
29737
|
-
await this.
|
|
31107
|
+
await this.declareAlarmPanel(api);
|
|
29738
31108
|
const logger = this.ctx.logger;
|
|
29739
31109
|
const storage = await this.resolveMediaStorage(logger);
|
|
29740
31110
|
const stores = await this.buildStores(api, logger, storage);
|
|
@@ -29796,11 +31166,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29796
31166
|
...isHub && ncHandlers !== void 0 ? {
|
|
29797
31167
|
customActions: {
|
|
29798
31168
|
...ncActions,
|
|
29799
|
-
...embeddingActions
|
|
31169
|
+
...embeddingActions,
|
|
31170
|
+
...debugActions
|
|
29800
31171
|
},
|
|
29801
31172
|
actionHandlers: {
|
|
29802
31173
|
...ncHandlers,
|
|
29803
|
-
...this.buildEmbeddingActionHandlers()
|
|
31174
|
+
...this.buildEmbeddingActionHandlers(),
|
|
31175
|
+
...this.buildDebugActionHandlers()
|
|
29804
31176
|
}
|
|
29805
31177
|
} : {}
|
|
29806
31178
|
};
|
|
@@ -29824,70 +31196,45 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29824
31196
|
},
|
|
29825
31197
|
listCallerTargetIds: async (userId) => {
|
|
29826
31198
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
29827
|
-
}
|
|
31199
|
+
},
|
|
31200
|
+
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
|
|
29828
31201
|
});
|
|
29829
31202
|
}
|
|
29830
31203
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
29831
31204
|
* a CREATE TABLE. Idempotent. */
|
|
29832
31205
|
/**
|
|
29833
|
-
*
|
|
31206
|
+
* DECLARE the Notification Center's alarm panel.
|
|
29834
31207
|
*
|
|
29835
|
-
*
|
|
29836
|
-
*
|
|
29837
|
-
*
|
|
29838
|
-
*
|
|
29839
|
-
|
|
29840
|
-
|
|
29841
|
-
|
|
29842
|
-
|
|
29843
|
-
const
|
|
29844
|
-
|
|
29845
|
-
|
|
29846
|
-
|
|
29847
|
-
|
|
29848
|
-
|
|
29849
|
-
|
|
29850
|
-
|
|
31208
|
+
* The procedure is not here any more — `DeclaredDevices` owns the
|
|
31209
|
+
* get-or-create, the FIXED integration and its every-pass `fixed`
|
|
31210
|
+
* re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
|
|
31211
|
+
* second read that stops a transiently empty index re-stamping the declared
|
|
31212
|
+
* name over the operator's rename. What is left is the declaration and the
|
|
31213
|
+
* ports.
|
|
31214
|
+
*/
|
|
31215
|
+
async declareAlarmPanel(api) {
|
|
31216
|
+
const declared = new require_dist.DeclaredDevices({
|
|
31217
|
+
logger: this.ctx.logger.child("alarm"),
|
|
31218
|
+
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31219
|
+
devices: this.ctx.kernel.devices,
|
|
31220
|
+
localNodeId: this.ctx.kernel.localNodeId,
|
|
31221
|
+
getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
|
|
31222
|
+
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
31223
|
+
updateIntegration: async ({ id, info }) => {
|
|
31224
|
+
await api.integrations.update.mutate({
|
|
31225
|
+
id,
|
|
31226
|
+
info,
|
|
31227
|
+
skipRestart: true
|
|
31228
|
+
});
|
|
31229
|
+
},
|
|
31230
|
+
listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
|
|
31231
|
+
});
|
|
29851
31232
|
try {
|
|
29852
|
-
const result = await
|
|
29853
|
-
logger: this.ctx.logger.child("alarm"),
|
|
29854
|
-
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31233
|
+
const result = await declared.reconcile({
|
|
29855
31234
|
integrationName: NC_INTEGRATION_NAME,
|
|
29856
|
-
|
|
29857
|
-
const found = await api.integrations.getByAddonId.query({ addonId });
|
|
29858
|
-
return found === null ? null : {
|
|
29859
|
-
id: found.id,
|
|
29860
|
-
fixed: found.info?.["fixed"] === true
|
|
29861
|
-
};
|
|
29862
|
-
},
|
|
29863
|
-
markFixed: async (integrationId) => {
|
|
29864
|
-
await api.integrations.update.mutate({
|
|
29865
|
-
id: integrationId,
|
|
29866
|
-
info: { fixed: true },
|
|
29867
|
-
skipRestart: true
|
|
29868
|
-
});
|
|
29869
|
-
},
|
|
29870
|
-
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
29871
|
-
findDeviceByStableId: async (stableId) => {
|
|
29872
|
-
const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
|
|
29873
|
-
return found === void 0 ? null : { id: found.id };
|
|
29874
|
-
},
|
|
29875
|
-
createDevice: async ({ stableId, integrationId, name }) => {
|
|
29876
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
|
|
29877
|
-
type: NC_ALARM_DEVICE_TYPE,
|
|
29878
|
-
name,
|
|
29879
|
-
integrationId
|
|
29880
|
-
});
|
|
29881
|
-
this.holdAlarmPanel(device);
|
|
29882
|
-
return { id: device.id };
|
|
29883
|
-
},
|
|
29884
|
-
adoptDevice: async ({ stableId }) => {
|
|
29885
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
|
|
29886
|
-
this.holdAlarmPanel(device);
|
|
29887
|
-
return { id: device.id };
|
|
29888
|
-
}
|
|
31235
|
+
devices: [NC_ALARM_DECLARATION]
|
|
29889
31236
|
});
|
|
29890
|
-
|
|
31237
|
+
for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
|
|
29891
31238
|
} catch (err) {
|
|
29892
31239
|
this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
29893
31240
|
}
|
|
@@ -29910,7 +31257,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29910
31257
|
/**
|
|
29911
31258
|
* Join the panel and the Notification Center, once both exist.
|
|
29912
31259
|
*
|
|
29913
|
-
* They are built in that order (`
|
|
31260
|
+
* They are built in that order (`declareAlarmPanel` runs before the centre is
|
|
29914
31261
|
* constructed) and neither can reach the other on its own, so the join is
|
|
29915
31262
|
* here. A node with no panel simply never calls it, and the cap answers
|
|
29916
31263
|
* `deviceId: null` — which is what an agent node should say.
|
|
@@ -29958,7 +31305,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29958
31305
|
let storage = this.ctx.kernel.storage;
|
|
29959
31306
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
29960
31307
|
if (mediaRoot) {
|
|
29961
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
31308
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CpXZkQ6V.js"));
|
|
29962
31309
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
29963
31310
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
29964
31311
|
}
|
|
@@ -30416,28 +31763,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30416
31763
|
y: pt.y
|
|
30417
31764
|
})));
|
|
30418
31765
|
},
|
|
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
|
-
},
|
|
31766
|
+
renderEventMedia: (req) => this.renderEventMedia(req),
|
|
30441
31767
|
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
30442
31768
|
listTargets: async () => {
|
|
30443
31769
|
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
@@ -30488,7 +31814,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30488
31814
|
}
|
|
30489
31815
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
30490
31816
|
* `/addon/<addonId>/event-media/<eventId>` through the hub's port.
|
|
30491
|
-
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31817
|
+
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31818
|
+
*
|
|
31819
|
+
* `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
|
|
31820
|
+
* the reason it changed: `getTrackMedia` / `getEventMedia` are declared
|
|
31821
|
+
* `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
|
|
31822
|
+
* admin gate protected nothing and cost every non-admin session its tiles. */
|
|
30492
31823
|
async serveEventMediaDataPlane() {
|
|
30493
31824
|
try {
|
|
30494
31825
|
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
@@ -30504,22 +31835,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30504
31835
|
} });
|
|
30505
31836
|
this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
30506
31837
|
prefix: "event-media",
|
|
30507
|
-
access: "
|
|
31838
|
+
access: planeAccess("event-media"),
|
|
30508
31839
|
handler
|
|
30509
31840
|
}) ?? null;
|
|
30510
31841
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
30511
31842
|
await this.serveNcArtifactPlane();
|
|
30512
31843
|
await this.serveTrainingExportPlane();
|
|
30513
31844
|
await this.serveRetrainExportPlane();
|
|
30514
|
-
|
|
31845
|
+
await this.serveDebugExportPlane();
|
|
31846
|
+
this.ctx.logger.info("event-media data-plane served", { meta: {
|
|
31847
|
+
baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
|
|
31848
|
+
access: planeAccess("event-media")
|
|
31849
|
+
} });
|
|
30515
31850
|
} catch (err) {
|
|
30516
31851
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
30517
31852
|
}
|
|
30518
31853
|
}
|
|
30519
31854
|
/**
|
|
30520
|
-
* The training-export data plane. Separate listener,
|
|
30521
|
-
* marking a track is a viewer-level
|
|
30522
|
-
* every stored frame behind the
|
|
31855
|
+
* The training-export data plane. Separate listener, admin-only (the level
|
|
31856
|
+
* is declared in `data-plane-access.ts`) — marking a track is a viewer-level
|
|
31857
|
+
* curation act (D81), but walking off with every stored frame behind the
|
|
31858
|
+
* marked set is not.
|
|
30523
31859
|
*/
|
|
30524
31860
|
async serveTrainingExportPlane() {
|
|
30525
31861
|
try {
|
|
@@ -30534,7 +31870,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30534
31870
|
});
|
|
30535
31871
|
this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
30536
31872
|
prefix: "training-export",
|
|
30537
|
-
access: "
|
|
31873
|
+
access: planeAccess("training-export"),
|
|
30538
31874
|
handler
|
|
30539
31875
|
}) ?? null;
|
|
30540
31876
|
} catch (err) {
|
|
@@ -30663,6 +31999,60 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30663
31999
|
return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
|
|
30664
32000
|
}
|
|
30665
32001
|
/**
|
|
32002
|
+
* The store seam the `debug.*` actions and the debug bundle both read through.
|
|
32003
|
+
*
|
|
32004
|
+
* INDEX READS ONLY — three media-inventory queries and an event query per
|
|
32005
|
+
* track, no blob. The bytes are fetched one at a time, by key, by the export
|
|
32006
|
+
* handler, so a bundle of a hundred tracks never holds more than one file.
|
|
32007
|
+
*/
|
|
32008
|
+
buildDebugServiceDeps() {
|
|
32009
|
+
const trackStore = this.trackStore;
|
|
32010
|
+
const mediaStore = this.mediaStore;
|
|
32011
|
+
const eventStore = this.eventStore;
|
|
32012
|
+
if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
|
|
32013
|
+
return {
|
|
32014
|
+
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
32015
|
+
listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
|
|
32016
|
+
getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
|
|
32017
|
+
listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
|
|
32018
|
+
listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
|
|
32019
|
+
logger: this.ctx.logger.child("debug"),
|
|
32020
|
+
now: () => Date.now()
|
|
32021
|
+
};
|
|
32022
|
+
}
|
|
32023
|
+
/** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
|
|
32024
|
+
buildDebugActionHandlers() {
|
|
32025
|
+
return {
|
|
32026
|
+
"debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
|
|
32027
|
+
"debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
|
|
32028
|
+
};
|
|
32029
|
+
}
|
|
32030
|
+
/**
|
|
32031
|
+
* The debug-export data plane. Its own listener, for the same reason the
|
|
32032
|
+
* retrain export has one: a client asking for the debug bundle must never be
|
|
32033
|
+
* able to receive the annotated dataset by changing a query parameter.
|
|
32034
|
+
*/
|
|
32035
|
+
async serveDebugExportPlane() {
|
|
32036
|
+
try {
|
|
32037
|
+
const handler = createDebugExportHandler({
|
|
32038
|
+
buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
|
|
32039
|
+
readMedia: async (key) => {
|
|
32040
|
+
const file = await this.mediaStore?.getByKey(key);
|
|
32041
|
+
return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
|
|
32042
|
+
},
|
|
32043
|
+
logger: this.ctx.logger.child("debug-export"),
|
|
32044
|
+
now: () => Date.now()
|
|
32045
|
+
});
|
|
32046
|
+
this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
32047
|
+
prefix: "debug-export",
|
|
32048
|
+
access: planeAccess("debug-export"),
|
|
32049
|
+
handler
|
|
32050
|
+
}) ?? null;
|
|
32051
|
+
} catch (err) {
|
|
32052
|
+
this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
32053
|
+
}
|
|
32054
|
+
}
|
|
32055
|
+
/**
|
|
30666
32056
|
* The retrain-export data plane. Separate listener from the training export
|
|
30667
32057
|
* next door: different archive, different contents, and a client asking for
|
|
30668
32058
|
* one must never be able to receive the other by changing a query parameter.
|
|
@@ -30684,7 +32074,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30684
32074
|
});
|
|
30685
32075
|
this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
30686
32076
|
prefix: "retrain-export",
|
|
30687
|
-
access: "
|
|
32077
|
+
access: planeAccess("retrain-export"),
|
|
30688
32078
|
handler
|
|
30689
32079
|
}) ?? null;
|
|
30690
32080
|
} catch (err) {
|
|
@@ -30894,6 +32284,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30894
32284
|
audio
|
|
30895
32285
|
};
|
|
30896
32286
|
},
|
|
32287
|
+
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
32288
|
+
return (await api.recording.getAvailability.query({
|
|
32289
|
+
deviceId,
|
|
32290
|
+
fromMs: since,
|
|
32291
|
+
toMs: until
|
|
32292
|
+
})).ranges;
|
|
32293
|
+
},
|
|
32294
|
+
logWarn: (message, meta) => this.ctx.logger.warn(message, {
|
|
32295
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
32296
|
+
meta
|
|
32297
|
+
}),
|
|
30897
32298
|
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
30898
32299
|
const manifest = await api.recording.getPlaybackManifest.query({
|
|
30899
32300
|
deviceId,
|
|
@@ -31015,6 +32416,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31015
32416
|
this.trainingExportDataPlane = null;
|
|
31016
32417
|
await this.retrainExportDataPlane?.dispose();
|
|
31017
32418
|
this.retrainExportDataPlane = null;
|
|
32419
|
+
await this.debugExportDataPlane?.dispose();
|
|
32420
|
+
this.debugExportDataPlane = null;
|
|
31018
32421
|
}
|
|
31019
32422
|
async handleInferenceResult(data) {
|
|
31020
32423
|
if (this.shuttingDown) return;
|
|
@@ -31943,6 +33346,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31943
33346
|
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
31944
33347
|
for (const d of details) try {
|
|
31945
33348
|
const isFaceDetail = d.className === "face";
|
|
33349
|
+
if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
|
|
31946
33350
|
if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
|
|
31947
33351
|
x: d.bbox.x,
|
|
31948
33352
|
y: d.bbox.y,
|
|
@@ -33365,11 +34769,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33365
34769
|
const store = this.sensorEventStore;
|
|
33366
34770
|
const cache = this.linkedCamerasCache;
|
|
33367
34771
|
if (store === null || cache === null) return;
|
|
34772
|
+
const persisted = [];
|
|
33368
34773
|
try {
|
|
33369
34774
|
await ingestSensorStateChange({
|
|
33370
34775
|
sink: store,
|
|
33371
34776
|
cache,
|
|
33372
|
-
onPersisted: (ev) =>
|
|
34777
|
+
onPersisted: (ev) => persisted.push(ev),
|
|
33373
34778
|
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
33374
34779
|
}, data, timestamp);
|
|
33375
34780
|
} catch (err) {
|
|
@@ -33381,30 +34786,40 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33381
34786
|
}
|
|
33382
34787
|
});
|
|
33383
34788
|
}
|
|
34789
|
+
const markerByCamera = /* @__PURE__ */ new Map();
|
|
33384
34790
|
const materializer = this.syntheticTracks;
|
|
33385
|
-
|
|
33386
|
-
|
|
33387
|
-
|
|
33388
|
-
|
|
33389
|
-
|
|
33390
|
-
|
|
33391
|
-
|
|
34791
|
+
if (materializer !== null) try {
|
|
34792
|
+
await projectSensorMarkers({
|
|
34793
|
+
cache,
|
|
34794
|
+
markers: materializer,
|
|
34795
|
+
producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
|
|
34796
|
+
onProjected: (r) => {
|
|
34797
|
+
markerByCamera.set(r.cameraId, {
|
|
34798
|
+
trackId: r.trackId,
|
|
34799
|
+
hasMedia: r.hasMedia
|
|
34800
|
+
});
|
|
34801
|
+
this.ctx.logger.info("synthetic sensor-track projected", {
|
|
34802
|
+
tags: { deviceId: r.cameraId },
|
|
34803
|
+
meta: {
|
|
34804
|
+
capName: r.capName,
|
|
34805
|
+
kind: r.kind,
|
|
34806
|
+
trackId: r.trackId,
|
|
34807
|
+
sourceDeviceId: r.sourceDeviceId,
|
|
34808
|
+
selfCamera: r.selfCamera,
|
|
34809
|
+
hasMedia: r.hasMedia
|
|
34810
|
+
}
|
|
34811
|
+
});
|
|
34812
|
+
},
|
|
34813
|
+
onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
34814
|
+
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
33392
34815
|
meta: {
|
|
33393
|
-
capName:
|
|
33394
|
-
kind:
|
|
34816
|
+
capName: r.capName,
|
|
34817
|
+
kind: r.kind,
|
|
34818
|
+
reason: r.reason,
|
|
34819
|
+
sourceDeviceId: r.sourceDeviceId
|
|
33395
34820
|
}
|
|
33396
|
-
})
|
|
33397
|
-
|
|
33398
|
-
}
|
|
33399
|
-
const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
|
|
33400
|
-
for (const cameraId of cameraIds) await materializer.materialize({
|
|
33401
|
-
cameraId,
|
|
33402
|
-
sourceDeviceId: data.deviceId,
|
|
33403
|
-
source: "sensor",
|
|
33404
|
-
kind: descriptor.kind,
|
|
33405
|
-
timestamp,
|
|
33406
|
-
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
33407
|
-
});
|
|
34821
|
+
})
|
|
34822
|
+
}, data, timestamp);
|
|
33408
34823
|
} catch (err) {
|
|
33409
34824
|
this.ctx.logger.warn("synthetic sensor-track materialize failed", {
|
|
33410
34825
|
tags: { deviceId: data.deviceId },
|
|
@@ -33414,6 +34829,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33414
34829
|
}
|
|
33415
34830
|
});
|
|
33416
34831
|
}
|
|
34832
|
+
for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
33417
34833
|
}
|
|
33418
34834
|
/**
|
|
33419
34835
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|