@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
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as array, B as subKindsOf, C as addonWidgetsSourceCapability, D as cosineSimilarity, E as buildEventKindDescriptor, F as kebabToCamel, G as BaseAddon, H as videoclipsCapability, I as notificationRulesCapability, J as hydrateSchema, K as DeviceType, L as pipelineAnalyticsCapability, M as faceGalleryCapability, O as customAction, P as isScheduleActive, Q as _enum, R as plateGalleryCapability, S as TrackSourceSchema, T as audioMetricsCapability, U as zoneAnalyticsCapability, V as vectorDimFromBase64, W as errMsg, X as nodePin, Y as isDeviceScopedCap, Z as sleep, _ as NcTaxonomySchema, a as EVENT_PAD_MS, at as string, b as TimelapseRuleInputSchema, c as NC_CONDITION_CATALOG, d as NcRuleInputSchema, et as boolean, f as NcRulePatchSchema, g as NcSnoozeSuppressedSchema, h as NcSnoozeSchema, i as EVENT_KIND_BY_CAP, it as record, j as encodeVectorBase64, k as defineCustomActions, l as NC_TAXONOMY, m as NcSnoozeInputSchema, n as DEFAULT_EVENT_COLOR, nt as number, o as LabelAttributionSchema, ot as unknown, p as NcRuleSchema, q as createEvent, r as DeclaredDevices, rt as object, s as MACRO_LABELS, st as EventCategory, t as BaseDevice, tt as literal, u as NcConditionDescriptorSchema, v as OpsLogEntrySchema, w as alarmPanelCapability, x as TimelapseRuleSchema, y as RetrainStatusSchema, z as readDeviceStateFrom } from "../dist-Da97q0x6.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
5
|
import sharp from "sharp";
|
|
7
6
|
//#region src/notification-center/action-token.ts
|
|
8
7
|
/**
|
|
@@ -693,77 +692,34 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
693
692
|
}
|
|
694
693
|
};
|
|
695
694
|
//#endregion
|
|
696
|
-
//#region src/notification-center/alarm/
|
|
695
|
+
//#region src/notification-center/alarm/alarm-panel-declaration.ts
|
|
697
696
|
/**
|
|
698
|
-
*
|
|
697
|
+
* The Notification Center's alarm panel, as a DECLARATION.
|
|
699
698
|
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
699
|
+
* There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
|
|
700
|
+
* re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
|
|
701
|
+
* to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
|
|
702
|
+
* addon declares. What is left here is the *declaration*: the stable id, the
|
|
703
|
+
* seed name, the type and the class. Data, not procedure.
|
|
703
704
|
*
|
|
704
|
-
* The
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
705
|
+
* The bug the hand-rolled version carried died in that move: it read the device
|
|
706
|
+
* index once, so a transiently empty answer took the create branch and
|
|
707
|
+
* re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
|
|
708
|
+
* second read to agree before it does anything destructive (D49).
|
|
708
709
|
*/
|
|
709
710
|
/** Stable id of the one panel. Never derived from a name — a rename must not
|
|
710
711
|
* mint a second device. */
|
|
711
712
|
var NC_ALARM_STABLE_ID = "nc-alarm-panel";
|
|
713
|
+
/** Seeded ONCE, at create. A later boot never re-stamps it. */
|
|
712
714
|
var NC_ALARM_DEVICE_NAME = "Alarm";
|
|
713
|
-
/**
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const existingIntegration = await deps.getIntegration(deps.addonId);
|
|
722
|
-
if (existingIntegration !== null && !existingIntegration.fixed) {
|
|
723
|
-
await deps.markFixed(existingIntegration.id);
|
|
724
|
-
deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
|
|
725
|
-
}
|
|
726
|
-
const integration = existingIntegration ?? await deps.createIntegration({
|
|
727
|
-
addonId: deps.addonId,
|
|
728
|
-
name: deps.integrationName,
|
|
729
|
-
info: { fixed: true }
|
|
730
|
-
});
|
|
731
|
-
if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
|
|
732
|
-
const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
|
|
733
|
-
deps.logger.info("alarm panel adopted", {
|
|
734
|
-
tags: { deviceId: adopted.id },
|
|
735
|
-
meta: {
|
|
736
|
-
integrationId: integration.id,
|
|
737
|
-
stableId: NC_ALARM_STABLE_ID
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
return {
|
|
741
|
-
integrationId: integration.id,
|
|
742
|
-
deviceId: adopted.id,
|
|
743
|
-
created: false
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
const device = await deps.createDevice({
|
|
747
|
-
stableId: NC_ALARM_STABLE_ID,
|
|
748
|
-
integrationId: integration.id,
|
|
749
|
-
name: NC_ALARM_DEVICE_NAME
|
|
750
|
-
});
|
|
751
|
-
deps.logger.info("alarm panel created", {
|
|
752
|
-
tags: { deviceId: device.id },
|
|
753
|
-
meta: {
|
|
754
|
-
integrationId: integration.id,
|
|
755
|
-
stableId: NC_ALARM_STABLE_ID
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
return {
|
|
759
|
-
integrationId: integration.id,
|
|
760
|
-
deviceId: device.id,
|
|
761
|
-
created: true
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
/** The device class + type the caller passes to `kernel.devices.create`. */
|
|
765
|
-
var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
|
|
766
|
-
var NC_ALARM_DEVICE_TYPE = DeviceType.AlarmPanel;
|
|
715
|
+
/** Display name of the FIXED integration the panel hangs from. */
|
|
716
|
+
var NC_INTEGRATION_NAME = "Notification Center";
|
|
717
|
+
var NC_ALARM_DECLARATION = {
|
|
718
|
+
stableId: NC_ALARM_STABLE_ID,
|
|
719
|
+
name: NC_ALARM_DEVICE_NAME,
|
|
720
|
+
type: DeviceType.AlarmPanel,
|
|
721
|
+
DeviceClass: NcAlarmPanelDevice
|
|
722
|
+
};
|
|
767
723
|
//#endregion
|
|
768
724
|
//#region src/notification-center/artifact-url.ts
|
|
769
725
|
/**
|
|
@@ -1103,556 +1059,219 @@ function mimeFromExtension(file) {
|
|
|
1103
1059
|
return "application/octet-stream";
|
|
1104
1060
|
}
|
|
1105
1061
|
//#endregion
|
|
1106
|
-
//#region src/notification-center/
|
|
1107
|
-
/**
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
* child alive for a notification that was sent minutes ago.
|
|
1113
|
-
*/
|
|
1114
|
-
async function assembleFmp4Clip(input) {
|
|
1115
|
-
const now = input.now ?? Date.now;
|
|
1116
|
-
const schedule = input.setTimeoutFn ?? setTimeout;
|
|
1117
|
-
const parts = [];
|
|
1118
|
-
let initBytes = 0;
|
|
1119
|
-
let fragments = 0;
|
|
1120
|
-
let totalBytes = 0;
|
|
1121
|
-
let firstFragmentAt = null;
|
|
1122
|
-
let lastFragmentAt = null;
|
|
1123
|
-
let stop = null;
|
|
1124
|
-
const iterator = input.subscription.packets()[Symbol.asyncIterator]();
|
|
1125
|
-
try {
|
|
1126
|
-
for (;;) {
|
|
1127
|
-
const remaining = input.deadlineMs - now();
|
|
1128
|
-
if (remaining <= 0) {
|
|
1129
|
-
stop = "deadline";
|
|
1130
|
-
break;
|
|
1131
|
-
}
|
|
1132
|
-
const packet = await nextWithin(iterator, remaining, schedule);
|
|
1133
|
-
if (packet === TIMED_OUT) {
|
|
1134
|
-
stop = "deadline";
|
|
1135
|
-
break;
|
|
1136
|
-
}
|
|
1137
|
-
if (packet === null) {
|
|
1138
|
-
stop = input.subscription.closedReason === "slow-consumer" ? "slow-consumer" : "producer-ended";
|
|
1139
|
-
break;
|
|
1140
|
-
}
|
|
1141
|
-
if (packet.kind === "init") {
|
|
1142
|
-
if (initBytes > 0) {
|
|
1143
|
-
stop = "producer-ended";
|
|
1144
|
-
break;
|
|
1145
|
-
}
|
|
1146
|
-
initBytes = packet.data.length;
|
|
1147
|
-
totalBytes += packet.data.length;
|
|
1148
|
-
parts.push(packet.data);
|
|
1149
|
-
continue;
|
|
1150
|
-
}
|
|
1151
|
-
if (initBytes === 0) return {
|
|
1152
|
-
ok: false,
|
|
1153
|
-
refusal: "no-init"
|
|
1154
|
-
};
|
|
1155
|
-
const arrivedAt = now();
|
|
1156
|
-
if (totalBytes + packet.data.length > input.maxBytes) {
|
|
1157
|
-
stop = "byte-cap";
|
|
1158
|
-
break;
|
|
1159
|
-
}
|
|
1160
|
-
parts.push(packet.data);
|
|
1161
|
-
totalBytes += packet.data.length;
|
|
1162
|
-
fragments += 1;
|
|
1163
|
-
if (firstFragmentAt === null) firstFragmentAt = arrivedAt;
|
|
1164
|
-
lastFragmentAt = arrivedAt;
|
|
1165
|
-
if (packet.isLast) {
|
|
1166
|
-
stop = "producer-ended";
|
|
1167
|
-
break;
|
|
1168
|
-
}
|
|
1169
|
-
if (arrivedAt >= input.collectUntilMs) {
|
|
1170
|
-
stop = "window";
|
|
1171
|
-
break;
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
} finally {
|
|
1175
|
-
input.subscription.release();
|
|
1176
|
-
}
|
|
1177
|
-
if (initBytes === 0) return {
|
|
1178
|
-
ok: false,
|
|
1179
|
-
refusal: "no-init"
|
|
1180
|
-
};
|
|
1181
|
-
if (fragments === 0) return {
|
|
1182
|
-
ok: false,
|
|
1183
|
-
refusal: "no-fragments"
|
|
1184
|
-
};
|
|
1185
|
-
const stoppedBecause = stop ?? "producer-ended";
|
|
1186
|
-
return {
|
|
1187
|
-
ok: true,
|
|
1188
|
-
clip: {
|
|
1189
|
-
bytes: concat(parts, totalBytes),
|
|
1190
|
-
initBytes,
|
|
1191
|
-
fragments,
|
|
1192
|
-
arrivalSpanMs: firstFragmentAt === null || lastFragmentAt === null ? 0 : lastFragmentAt - firstFragmentAt,
|
|
1193
|
-
stoppedBecause,
|
|
1194
|
-
truncated: stoppedBecause !== "window"
|
|
1195
|
-
}
|
|
1196
|
-
};
|
|
1197
|
-
}
|
|
1198
|
-
/** One line for the log — a refused clip that says nothing reads as a bug. */
|
|
1199
|
-
function clipAssemblyRefusalReason(refusal) {
|
|
1200
|
-
switch (refusal) {
|
|
1201
|
-
case "no-init": return "the first packet was a fragment, not an initialisation segment — the bytes would not decode";
|
|
1202
|
-
case "no-fragments": return "the initialisation segment arrived but no media fragment did";
|
|
1203
|
-
}
|
|
1062
|
+
//#region src/notification-center/action-buttons.ts
|
|
1063
|
+
/** Sequence names the rule actually declares. A button may only name one. */
|
|
1064
|
+
function declaredSequences(actions) {
|
|
1065
|
+
const names = /* @__PURE__ */ new Set();
|
|
1066
|
+
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
1067
|
+
return names;
|
|
1204
1068
|
}
|
|
1205
|
-
var TIMED_OUT = Symbol("fmp4-clip-timeout");
|
|
1206
|
-
/** `setTimeout`'s 32-bit ceiling. Above it Node fires after 1 ms. */
|
|
1207
|
-
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
1208
1069
|
/**
|
|
1209
|
-
*
|
|
1070
|
+
* Build the notification's buttons.
|
|
1210
1071
|
*
|
|
1211
|
-
*
|
|
1212
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
* the subscription immediately afterwards.
|
|
1072
|
+
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
1073
|
+
* to set the field, and an empty array is the honest answer to "which buttons
|
|
1074
|
+
* survived", which `undefined` would conflate with "the rule declared none".
|
|
1215
1075
|
*/
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
if (
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1076
|
+
function buildActionButtons(input) {
|
|
1077
|
+
const actions = input.actions;
|
|
1078
|
+
if (actions === void 0) return [];
|
|
1079
|
+
const buttons = actions.buttons ?? [];
|
|
1080
|
+
if (buttons.length === 0) return [];
|
|
1081
|
+
const known = declaredSequences(actions);
|
|
1082
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1083
|
+
const out = [];
|
|
1084
|
+
for (const button of buttons) {
|
|
1085
|
+
if (!known.has(button.sequence)) continue;
|
|
1086
|
+
if (seen.has(button.id)) continue;
|
|
1087
|
+
seen.add(button.id);
|
|
1088
|
+
out.push({
|
|
1089
|
+
id: button.id,
|
|
1090
|
+
label: button.label,
|
|
1091
|
+
url: input.mintUrl({
|
|
1092
|
+
sequence: button.sequence,
|
|
1093
|
+
actionId: button.id
|
|
1094
|
+
}),
|
|
1095
|
+
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
1096
|
+
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
1097
|
+
});
|
|
1234
1098
|
}
|
|
1235
1099
|
return out;
|
|
1236
1100
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
function pickClipSource(entries) {
|
|
1251
|
-
const enabled = entries.filter((e) => e.enabled);
|
|
1252
|
-
if (enabled.length === 0) return {
|
|
1253
|
-
ok: false,
|
|
1254
|
-
refusal: "no-enabled-stream"
|
|
1255
|
-
};
|
|
1256
|
-
const copyable = enabled.filter((e) => normaliseCodec(e.codec) === COPYABLE_CODEC);
|
|
1257
|
-
if (copyable.length === 0) return {
|
|
1258
|
-
ok: false,
|
|
1259
|
-
refusal: "no-h264-stream"
|
|
1260
|
-
};
|
|
1261
|
-
const sized = copyable.filter(hasUsableResolution);
|
|
1262
|
-
if (sized.length === 0) return {
|
|
1263
|
-
ok: false,
|
|
1264
|
-
refusal: "no-resolution"
|
|
1265
|
-
};
|
|
1266
|
-
const withinCeiling = sized.filter((e) => e.resolution.height <= CLIP_MAX_HEIGHT);
|
|
1267
|
-
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? b.resolution.height - a.resolution.height : a.resolution.height - b.resolution.height)[0];
|
|
1268
|
-
if (best === void 0) return {
|
|
1269
|
-
ok: false,
|
|
1270
|
-
refusal: "no-resolution"
|
|
1271
|
-
};
|
|
1272
|
-
return {
|
|
1273
|
-
ok: true,
|
|
1274
|
-
source: {
|
|
1275
|
-
brokerId: best.brokerId,
|
|
1276
|
-
profile: best.profile ?? best.brokerId,
|
|
1277
|
-
url: best.url,
|
|
1278
|
-
width: best.resolution.width,
|
|
1279
|
-
height: best.resolution.height
|
|
1280
|
-
}
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
/** One line for the log — a withheld clip that says nothing reads as a bug. */
|
|
1284
|
-
function clipRefusalReason(refusal) {
|
|
1285
|
-
switch (refusal) {
|
|
1286
|
-
case "no-enabled-stream": return "the camera has no enabled profile slot to dial";
|
|
1287
|
-
case "no-h264-stream": return "every enabled profile is H.265 — the seam copies and never transcodes, so the clip ring serves this camera";
|
|
1288
|
-
case "no-resolution": return "no enabled profile declares a resolution, so no slot could be sized";
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
function hasUsableResolution(entry) {
|
|
1292
|
-
const r = entry.resolution;
|
|
1293
|
-
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
1294
|
-
}
|
|
1295
|
-
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
1296
|
-
function normaliseCodec(codec) {
|
|
1297
|
-
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
1101
|
+
/**
|
|
1102
|
+
* Which of a rule's buttons name a sequence it does not have.
|
|
1103
|
+
*
|
|
1104
|
+
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
1105
|
+
* notification is the exact shape of failure this repo keeps paying for — the
|
|
1106
|
+
* operator authored it, it never appeared, and nothing said why.
|
|
1107
|
+
*/
|
|
1108
|
+
function unresolvableButtons(actions) {
|
|
1109
|
+
if (actions === void 0) return [];
|
|
1110
|
+
const buttons = actions.buttons ?? [];
|
|
1111
|
+
if (buttons.length === 0) return [];
|
|
1112
|
+
const known = declaredSequences(actions);
|
|
1113
|
+
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
1298
1114
|
}
|
|
1299
1115
|
//#endregion
|
|
1300
|
-
//#region src/notification-center/
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
* **This path has NO pre-roll, and the guess that it might have had one was
|
|
1335
|
-
* wrong.** `RtspRestreamer` bursts the current GOP to a joining session, so it
|
|
1336
|
-
* looked as though the first fragment would open before we asked for it.
|
|
1337
|
-
* Measured against 615's 720p slot from outside the hub — the exact argv, the
|
|
1338
|
-
* real child, the real plane:
|
|
1339
|
-
*
|
|
1340
|
-
* | dial → init | fragments | media duration | wall clock | bytes |
|
|
1341
|
-
* | --- | --- | --- | --- | --- |
|
|
1342
|
-
* | 4479 ms | 3 × ~141 KB | 12.03 s | 12.42 s | 425 KB |
|
|
1343
|
-
*
|
|
1344
|
-
* Media duration and wall clock differ by 387 ms — the dial. Nothing from
|
|
1345
|
-
* before the spawn survives into the file: ffmpeg discards up to the first key
|
|
1346
|
-
* frame it can open on, which is also why the first fragment costs one full
|
|
1347
|
-
* key-frame interval. So `preRollSec` is NOT honoured here; the fallback's is.
|
|
1348
|
-
*
|
|
1349
|
-
* A real pre-roll needs a plane that was already running. The seam is ready for
|
|
1350
|
-
* one — the assembler takes any `Fmp4Subscription`, including a prebuffered one
|
|
1351
|
-
* — and it is queued rather than built, because a warm child per
|
|
1352
|
-
* camera-with-a-clip-rule is a new permanent cost and that is an operator
|
|
1353
|
-
* decision, not an implementation one.
|
|
1354
|
-
*
|
|
1355
|
-
* ## Fail-closed, and always with the reason
|
|
1356
|
-
*
|
|
1357
|
-
* Every refusal returns a code AND a sentence, and the caller falls back to the
|
|
1358
|
-
* clip ring. An H.265 camera, a broker on another node, an ffmpeg that will not
|
|
1359
|
-
* start: each is a `debug`/`warn` line carrying `tags: { deviceId }`, because a
|
|
1360
|
-
* clip that silently changed shape per camera is exactly the thing nobody can
|
|
1361
|
-
* diagnose from an attachment.
|
|
1362
|
-
*/
|
|
1363
|
-
/**
|
|
1364
|
-
* The fragment length asked of the muxer.
|
|
1365
|
-
*
|
|
1366
|
-
* Deliberately SHORT. `-min_frag_duration` can only ever DELAY a cut past a key
|
|
1367
|
-
* frame (D80), so asking for 1 s against a 4 s key-frame grid yields exactly the
|
|
1368
|
-
* source cadence — the finest granularity the stream can give. Asking for the
|
|
1369
|
-
* grid's own length is the D84 bug: the deadline lands on the same instant as
|
|
1370
|
-
* the key frame, loses, and DOUBLES every fragment.
|
|
1371
|
-
*/
|
|
1372
|
-
var CLIP_FRAGMENT_MS = 1e3;
|
|
1373
|
-
/**
|
|
1374
|
-
* How long the child may produce nothing before it is declared dead.
|
|
1375
|
-
*
|
|
1376
|
-
* Shorter than the transport's 12 s default on purpose: this runs inside the
|
|
1377
|
-
* notification drain, and the commonest cause of a stall here is a restream url
|
|
1378
|
-
* that is not reachable from THIS runner (the broker for that camera lives on
|
|
1379
|
-
* another node and its url is loopback). Paying 12 s per notification for that
|
|
1380
|
-
* is worse than falling back sooner.
|
|
1381
|
-
*
|
|
1382
|
-
* NOT shorter than 9 s, and that bound is measured rather than chosen: the init
|
|
1383
|
-
* segment cannot be written before ffmpeg has a key frame to open on, and 615
|
|
1384
|
-
* took **4479 ms** over the LAN on a 4.0 s grid. A camera on an 8 s grid needs
|
|
1385
|
-
* roughly twice that, so anything under ~9 s would declare healthy cameras dead
|
|
1386
|
-
* and cool them down for ten minutes.
|
|
1387
|
-
*/
|
|
1388
|
-
var CLIP_FIRST_UNIT_TIMEOUT_MS = 9e3;
|
|
1389
|
-
/** Slack past the requested window for one more key-frame interval to land. */
|
|
1390
|
-
var CLIP_CADENCE_SLACK_MS = 1e4;
|
|
1391
|
-
/** The whole attempt, spawn included. A notification is not worth more. */
|
|
1392
|
-
var CLIP_TOTAL_BUDGET_MS = 25e3;
|
|
1393
|
-
/**
|
|
1394
|
-
* Ceiling on the assembled file.
|
|
1395
|
-
*
|
|
1396
|
-
* The degrade engine DROPS an over-`maxBytes` attachment rather than degrading
|
|
1397
|
-
* it to a link, and the SMALLEST cap declared today is pushover's 5 MiB. So the
|
|
1398
|
-
* cap is that, not something roomier: a clip truncated to four seconds and
|
|
1399
|
-
* delivered beats a whole one the engine throws away, and the truncation is
|
|
1400
|
-
* marked either way. A 1080p copy clip of a few seconds is 1-2 MB, so this only
|
|
1401
|
-
* ever bites a 4K-only camera.
|
|
1402
|
-
*/
|
|
1403
|
-
var CLIP_MAX_BYTES = 5 * 1024 * 1024;
|
|
1404
|
-
/**
|
|
1405
|
-
* How long a camera is left alone after the seam failed to start on it.
|
|
1406
|
-
*
|
|
1407
|
-
* Without this, a camera whose broker is on another node pays
|
|
1408
|
-
* {@link CLIP_FIRST_UNIT_TIMEOUT_MS} on EVERY notification, forever. The
|
|
1409
|
-
* cooldown is announced once and the suppressed attempts are counted into the
|
|
1410
|
-
* next line, the same shape the NC's own rate-limited lines use.
|
|
1411
|
-
*/
|
|
1412
|
-
var CLIP_FAILURE_COOLDOWN_MS = 10 * 6e4;
|
|
1413
|
-
/** ffmpeg on `PATH`, as every other spawn in this repo resolves it. */
|
|
1414
|
-
var FFMPEG_BINARY = "ffmpeg";
|
|
1415
|
-
var Fmp4ClipService = class {
|
|
1416
|
-
deps;
|
|
1417
|
-
cooldowns = /* @__PURE__ */ new Map();
|
|
1418
|
-
inFlight = /* @__PURE__ */ new Set();
|
|
1419
|
-
transport = null;
|
|
1420
|
-
transportProbed = false;
|
|
1116
|
+
//#region src/notification-center/device-mute-store.ts
|
|
1117
|
+
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
1118
|
+
var NC_DEVICE_MUTES_COLUMNS = [
|
|
1119
|
+
(
|
|
1120
|
+
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
1121
|
+
{
|
|
1122
|
+
name: "id",
|
|
1123
|
+
type: "TEXT",
|
|
1124
|
+
primaryKey: true,
|
|
1125
|
+
notNull: true
|
|
1126
|
+
}),
|
|
1127
|
+
{
|
|
1128
|
+
name: "deviceId",
|
|
1129
|
+
type: "INTEGER",
|
|
1130
|
+
notNull: true
|
|
1131
|
+
},
|
|
1132
|
+
(
|
|
1133
|
+
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
1134
|
+
{
|
|
1135
|
+
name: "mutedAt",
|
|
1136
|
+
type: "INTEGER",
|
|
1137
|
+
notNull: true
|
|
1138
|
+
})
|
|
1139
|
+
];
|
|
1140
|
+
/**
|
|
1141
|
+
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
1142
|
+
* the table proportional to what the operator actually silenced, and makes the
|
|
1143
|
+
* hot-path read a `Set.has`.
|
|
1144
|
+
*/
|
|
1145
|
+
var NcDeviceMuteStore = class {
|
|
1146
|
+
muted = /* @__PURE__ */ new Set();
|
|
1147
|
+
store;
|
|
1148
|
+
logger;
|
|
1149
|
+
now;
|
|
1421
1150
|
constructor(deps) {
|
|
1422
|
-
this.
|
|
1151
|
+
this.store = deps.store;
|
|
1152
|
+
this.logger = deps.logger;
|
|
1153
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1423
1154
|
}
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
*
|
|
1429
|
-
* The DECLINE IS LOGGED HERE and not at the call site, so there is exactly one
|
|
1430
|
-
* place that can forget to. Two cameras silently attaching two different kinds
|
|
1431
|
-
* of clip is precisely what an operator cannot diagnose from a phone, and the
|
|
1432
|
-
* question is always per-camera — hence `tags: { deviceId }` on every line.
|
|
1433
|
-
*/
|
|
1434
|
-
async renderClip(request) {
|
|
1435
|
-
const result = await this.attempt(request);
|
|
1436
|
-
if (!result.ok) this.deps.logger.debug("nc clip: the fMP4 seam declined — the clip ring serves this one", {
|
|
1437
|
-
tags: { deviceId: request.deviceId },
|
|
1438
|
-
meta: {
|
|
1439
|
-
code: result.code,
|
|
1440
|
-
reason: result.reason
|
|
1441
|
-
}
|
|
1155
|
+
static async declare(store) {
|
|
1156
|
+
await store.declareCollection.mutate({
|
|
1157
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1158
|
+
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
1442
1159
|
});
|
|
1443
|
-
return result;
|
|
1444
1160
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
return {
|
|
1453
|
-
ok: false,
|
|
1454
|
-
code: "cooldown",
|
|
1455
|
-
reason: `the seam failed to start on this camera and is suppressed for another ${Math.round((cooling.until - now()) / 1e3)}s`
|
|
1456
|
-
};
|
|
1457
|
-
}
|
|
1458
|
-
if (this.inFlight.has(deviceId)) return {
|
|
1459
|
-
ok: false,
|
|
1460
|
-
code: "busy",
|
|
1461
|
-
reason: "a seam clip for this camera is already being cut"
|
|
1462
|
-
};
|
|
1463
|
-
const transport = await this.loadTransport();
|
|
1464
|
-
if (transport === null) return {
|
|
1465
|
-
ok: false,
|
|
1466
|
-
code: "transport-missing",
|
|
1467
|
-
reason: "this host has no fMP4 fragment transport — @camstack/types predates it"
|
|
1468
|
-
};
|
|
1469
|
-
const entries = await this.readEntries(deviceId);
|
|
1470
|
-
if (entries === null || entries.length === 0) return {
|
|
1471
|
-
ok: false,
|
|
1472
|
-
code: "no-entries",
|
|
1473
|
-
reason: "the camera reported no profile RTSP entries"
|
|
1474
|
-
};
|
|
1475
|
-
const choice = pickClipSource(entries);
|
|
1476
|
-
if (!choice.ok) return {
|
|
1477
|
-
ok: false,
|
|
1478
|
-
code: choice.refusal,
|
|
1479
|
-
reason: clipRefusalReason(choice.refusal)
|
|
1480
|
-
};
|
|
1481
|
-
const source = choice.source;
|
|
1482
|
-
this.inFlight.add(deviceId);
|
|
1483
|
-
const plane = new transport.Fmp4FragmentPlane(log.child("nc-clip-plane"));
|
|
1484
|
-
const child = new transport.Fmp4FragmentChild({
|
|
1485
|
-
logger: log.child("nc-clip-fmp4"),
|
|
1486
|
-
ffmpegBinaryPath: this.deps.ffmpegBinaryPath ?? FFMPEG_BINARY,
|
|
1487
|
-
spawnFn: this.deps.spawnFn ?? spawn,
|
|
1488
|
-
firstUnitTimeoutMs: CLIP_FIRST_UNIT_TIMEOUT_MS
|
|
1489
|
-
}, {
|
|
1490
|
-
sourceId: `nc-clip/${deviceId}`,
|
|
1491
|
-
deviceId,
|
|
1492
|
-
fragmentMs: CLIP_FRAGMENT_MS,
|
|
1493
|
-
invocation: buildClipInvocation(source.url),
|
|
1494
|
-
plane
|
|
1495
|
-
});
|
|
1496
|
-
const startedAt = now();
|
|
1161
|
+
/**
|
|
1162
|
+
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
1163
|
+
* clearing it: dropping every mute on a transient store error would start
|
|
1164
|
+
* notifying for a camera the operator silenced, without them asking — the
|
|
1165
|
+
* same reasoning as `NcSnoozeStore.load`.
|
|
1166
|
+
*/
|
|
1167
|
+
async load() {
|
|
1497
1168
|
try {
|
|
1498
|
-
await
|
|
1169
|
+
const rows = await this.store.query.query({
|
|
1170
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1171
|
+
filter: { limit: 1e4 }
|
|
1172
|
+
});
|
|
1173
|
+
const next = /* @__PURE__ */ new Set();
|
|
1174
|
+
let skipped = 0;
|
|
1175
|
+
for (const row of rows) {
|
|
1176
|
+
const raw = row.data["deviceId"];
|
|
1177
|
+
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
1178
|
+
skipped += 1;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
next.add(raw);
|
|
1182
|
+
}
|
|
1183
|
+
this.muted.clear();
|
|
1184
|
+
for (const id of next) this.muted.add(id);
|
|
1185
|
+
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
1499
1186
|
} catch (err) {
|
|
1500
|
-
this.
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
return {
|
|
1505
|
-
ok: false,
|
|
1506
|
-
code: "child-failed",
|
|
1507
|
-
reason: `ffmpeg produced no fMP4 initialisation segment: ${errText(err)}`
|
|
1508
|
-
};
|
|
1187
|
+
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
1188
|
+
error: String(err),
|
|
1189
|
+
muted: this.muted.size
|
|
1190
|
+
} });
|
|
1509
1191
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
return {
|
|
1535
|
-
ok: false,
|
|
1536
|
-
code: outcome.refusal,
|
|
1537
|
-
reason: clipAssemblyRefusalReason(outcome.refusal)
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
const clip = outcome.clip;
|
|
1541
|
-
log.info("nc clip: cut from the fMP4 seam", {
|
|
1542
|
-
tags: { deviceId },
|
|
1543
|
-
meta: {
|
|
1544
|
-
brokerId: source.brokerId,
|
|
1545
|
-
profile: source.profile,
|
|
1546
|
-
resolution: `${source.width}x${source.height}`,
|
|
1547
|
-
fragments: clip.fragments,
|
|
1548
|
-
bytes: clip.bytes.length,
|
|
1549
|
-
arrivalSpanMs: clip.arrivalSpanMs,
|
|
1550
|
-
truncated: clip.truncated,
|
|
1551
|
-
stoppedBecause: clip.stoppedBecause,
|
|
1552
|
-
dialLatencyMs: subscribedAt - startedAt,
|
|
1553
|
-
eventToDialMs: subscribedAt - request.aroundMs
|
|
1192
|
+
}
|
|
1193
|
+
/** Hot-path read, called once per evaluated subject. */
|
|
1194
|
+
isMuted(deviceId) {
|
|
1195
|
+
return this.muted.has(deviceId);
|
|
1196
|
+
}
|
|
1197
|
+
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
1198
|
+
listMuted() {
|
|
1199
|
+
return [...this.muted].toSorted((a, b) => a - b);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
1203
|
+
* updated only after the write lands, so a failed write cannot leave the
|
|
1204
|
+
* hot path believing a camera is silenced when the next boot will not.
|
|
1205
|
+
*/
|
|
1206
|
+
async setMuted(deviceId, muted) {
|
|
1207
|
+
const id = String(deviceId);
|
|
1208
|
+
if (muted) {
|
|
1209
|
+
await this.store.set.mutate({
|
|
1210
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1211
|
+
key: id,
|
|
1212
|
+
value: {
|
|
1213
|
+
id,
|
|
1214
|
+
deviceId,
|
|
1215
|
+
mutedAt: this.now()
|
|
1554
1216
|
}
|
|
1555
1217
|
});
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
plane.dispose();
|
|
1564
|
-
this.inFlight.delete(deviceId);
|
|
1218
|
+
this.muted.add(deviceId);
|
|
1219
|
+
} else {
|
|
1220
|
+
await this.store.delete.mutate({
|
|
1221
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1222
|
+
key: id
|
|
1223
|
+
});
|
|
1224
|
+
this.muted.delete(deviceId);
|
|
1565
1225
|
}
|
|
1566
1226
|
}
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
return this.transport;
|
|
1227
|
+
};
|
|
1228
|
+
var NcDeviceStateCache = class {
|
|
1229
|
+
deps;
|
|
1230
|
+
states = /* @__PURE__ */ new Map();
|
|
1231
|
+
lastOkAt = 0;
|
|
1232
|
+
ids = [];
|
|
1233
|
+
now;
|
|
1234
|
+
constructor(deps) {
|
|
1235
|
+
this.deps = deps;
|
|
1236
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1578
1237
|
}
|
|
1579
|
-
|
|
1238
|
+
/**
|
|
1239
|
+
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
1240
|
+
* with no gated rule does no work at all.
|
|
1241
|
+
*/
|
|
1242
|
+
setWatched(ids) {
|
|
1243
|
+
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
1244
|
+
}
|
|
1245
|
+
watched() {
|
|
1246
|
+
return this.ids;
|
|
1247
|
+
}
|
|
1248
|
+
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
1249
|
+
get(deviceId) {
|
|
1250
|
+
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
1251
|
+
return this.states.get(deviceId);
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Re-read every watched state. Never throws: a gate that could fail the
|
|
1255
|
+
* evaluation tick would take the notification pipeline with it.
|
|
1256
|
+
*/
|
|
1257
|
+
async refresh() {
|
|
1258
|
+
if (this.ids.length === 0) {
|
|
1259
|
+
this.states = /* @__PURE__ */ new Map();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1580
1262
|
try {
|
|
1581
|
-
|
|
1263
|
+
const next = await this.deps.readStates(this.ids);
|
|
1264
|
+
this.states = new Map(next);
|
|
1265
|
+
this.lastOkAt = this.now();
|
|
1266
|
+
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 } });
|
|
1582
1267
|
} catch (err) {
|
|
1583
|
-
this.deps.logger.
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
});
|
|
1587
|
-
return null;
|
|
1268
|
+
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
1269
|
+
watched: this.ids.length,
|
|
1270
|
+
error: String(err)
|
|
1271
|
+
} });
|
|
1588
1272
|
}
|
|
1589
1273
|
}
|
|
1590
|
-
noteStartFailure(deviceId, err) {
|
|
1591
|
-
const now = this.deps.now ?? Date.now;
|
|
1592
|
-
this.cooldowns.set(deviceId, {
|
|
1593
|
-
until: now() + CLIP_FAILURE_COOLDOWN_MS,
|
|
1594
|
-
suppressed: 0
|
|
1595
|
-
});
|
|
1596
|
-
this.deps.logger.warn("nc clip: the fMP4 child would not start — this camera falls back to the clip ring for now", {
|
|
1597
|
-
tags: { deviceId },
|
|
1598
|
-
meta: {
|
|
1599
|
-
error: errText(err),
|
|
1600
|
-
cooldownMs: CLIP_FAILURE_COOLDOWN_MS
|
|
1601
|
-
}
|
|
1602
|
-
});
|
|
1603
|
-
}
|
|
1604
|
-
clearCooldown(deviceId) {
|
|
1605
|
-
const previous = this.cooldowns.get(deviceId);
|
|
1606
|
-
if (previous === void 0) return;
|
|
1607
|
-
this.cooldowns.delete(deviceId);
|
|
1608
|
-
this.deps.logger.info("nc clip: the fMP4 seam recovered on this camera", {
|
|
1609
|
-
tags: { deviceId },
|
|
1610
|
-
meta: { suppressedAttempts: previous.suppressed }
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
1274
|
};
|
|
1614
|
-
/**
|
|
1615
|
-
* The invocation, minus the sink the child owns.
|
|
1616
|
-
*
|
|
1617
|
-
* `copy` is the entire point (see the class docblock) and the audio IS encoded,
|
|
1618
|
-
* because camera microphones are G.711/PCM and an mp4 attachment has to carry
|
|
1619
|
-
* AAC to play anywhere. `-c:a aac` with no audio stream in the source is a
|
|
1620
|
-
* no-op in ffmpeg's default stream selection, so a silent camera needs no
|
|
1621
|
-
* second branch.
|
|
1622
|
-
*/
|
|
1623
|
-
function buildClipInvocation(url) {
|
|
1624
|
-
return {
|
|
1625
|
-
logLevel: "error",
|
|
1626
|
-
decodeHwAccel: null,
|
|
1627
|
-
input: {
|
|
1628
|
-
url,
|
|
1629
|
-
rtspTransport: "tcp",
|
|
1630
|
-
analyzeDurationUs: 1e6,
|
|
1631
|
-
probeSizeBytes: 1e6
|
|
1632
|
-
},
|
|
1633
|
-
video: { kind: "copy" },
|
|
1634
|
-
audio: {
|
|
1635
|
-
kind: "encode",
|
|
1636
|
-
codec: "aac",
|
|
1637
|
-
bitrateKbps: 64,
|
|
1638
|
-
sampleRateHz: 24e3,
|
|
1639
|
-
channels: 1
|
|
1640
|
-
},
|
|
1641
|
-
threadCount: 0,
|
|
1642
|
-
outputArgs: []
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
async function defaultLoadTransport() {
|
|
1646
|
-
const mod = await import("../node-pIpcQ_aN.mjs");
|
|
1647
|
-
if (typeof mod.Fmp4FragmentChild !== "function" || typeof mod.Fmp4FragmentPlane !== "function") return null;
|
|
1648
|
-
return {
|
|
1649
|
-
Fmp4FragmentChild: mod.Fmp4FragmentChild,
|
|
1650
|
-
Fmp4FragmentPlane: mod.Fmp4FragmentPlane
|
|
1651
|
-
};
|
|
1652
|
-
}
|
|
1653
|
-
function errText(err) {
|
|
1654
|
-
return err instanceof Error ? err.message : String(err);
|
|
1655
|
-
}
|
|
1656
1275
|
//#endregion
|
|
1657
1276
|
//#region src/pipeline-analytics/pipeline/label-utils.ts
|
|
1658
1277
|
/**
|
|
@@ -2066,12 +1685,13 @@ function readSensorEventType(value) {
|
|
|
2066
1685
|
* what the operator named in the rule. Carrying only the camera is what made a
|
|
2067
1686
|
* sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
|
|
2068
1687
|
*/
|
|
2069
|
-
function subjectFromSensorEvent(ev) {
|
|
1688
|
+
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
2070
1689
|
const eventType = readSensorEventType(ev.value);
|
|
2071
1690
|
return {
|
|
2072
1691
|
kind: "device-event",
|
|
2073
1692
|
recordId: ev.id,
|
|
2074
1693
|
deviceId: ev.deviceId,
|
|
1694
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
|
|
2075
1695
|
...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
|
|
2076
1696
|
timestamp: ev.timestamp,
|
|
2077
1697
|
classNames: [],
|
|
@@ -2556,20 +2176,6 @@ function bestMatchingKindPreference(signal, ownerKind) {
|
|
|
2556
2176
|
//#endregion
|
|
2557
2177
|
//#region src/notification-center/dispatcher.ts
|
|
2558
2178
|
var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
|
|
2559
|
-
/** GIF and MP4 are the same cut in two containers — one render request each. */
|
|
2560
|
-
var FOOTAGE_FORMATS = [{
|
|
2561
|
-
flag: "mediaGif",
|
|
2562
|
-
format: "gif",
|
|
2563
|
-
mediaType: "gif",
|
|
2564
|
-
mime: "image/gif",
|
|
2565
|
-
name: "event.gif"
|
|
2566
|
-
}, {
|
|
2567
|
-
flag: "mediaClip",
|
|
2568
|
-
format: "mp4",
|
|
2569
|
-
mediaType: "video",
|
|
2570
|
-
mime: "video/mp4",
|
|
2571
|
-
name: "event.mp4"
|
|
2572
|
-
}];
|
|
2573
2179
|
var NcDispatcher = class {
|
|
2574
2180
|
deps;
|
|
2575
2181
|
targetCache = null;
|
|
@@ -2952,32 +2558,43 @@ var NcDispatcher = class {
|
|
|
2952
2558
|
name: "zone.jpg"
|
|
2953
2559
|
} : still);
|
|
2954
2560
|
} else out.push(still);
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2561
|
+
} else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
|
|
2562
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
2563
|
+
meta: {
|
|
2564
|
+
recordKind: entry.recordKind,
|
|
2565
|
+
recordId: entry.recordId,
|
|
2566
|
+
policy: entry.payload.media,
|
|
2567
|
+
...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
|
|
2568
|
+
owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
const wantGif = entry.payload.mediaGif === true;
|
|
2572
|
+
const wantMp4 = entry.payload.mediaClip === true;
|
|
2573
|
+
if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
|
|
2574
|
+
const media = await this.deps.renderEventMedia({
|
|
2575
|
+
deviceId: entry.payload.subject.deviceId,
|
|
2576
|
+
aroundMs: entry.payload.subject.timestamp,
|
|
2577
|
+
wantGif,
|
|
2578
|
+
wantMp4,
|
|
2579
|
+
preRollSec: entry.payload.mediaClipPreRollSec ?? 4,
|
|
2580
|
+
postRollSec: entry.payload.mediaClipPostRollSec ?? 6,
|
|
2581
|
+
...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
|
|
2582
|
+
...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
|
|
2583
|
+
});
|
|
2584
|
+
if (media.gif !== null && media.gif.byteLength > 0) out.push({
|
|
2585
|
+
mediaType: "gif",
|
|
2586
|
+
bytes: copyBytes(media.gif),
|
|
2587
|
+
mime: "image/gif",
|
|
2588
|
+
name: "event.gif"
|
|
2589
|
+
});
|
|
2590
|
+
if (media.mp4 !== null && media.mp4.byteLength > 0) out.push({
|
|
2591
|
+
mediaType: "video",
|
|
2592
|
+
bytes: copyBytes(media.mp4),
|
|
2593
|
+
mime: "video/mp4",
|
|
2594
|
+
name: "event.mp4"
|
|
2595
|
+
});
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
this.notePendingFootage(entry, err, wantMp4 ? "clip" : "gif");
|
|
2981
2598
|
}
|
|
2982
2599
|
return out;
|
|
2983
2600
|
}
|
|
@@ -3146,6 +2763,135 @@ function paramNumber(params, key) {
|
|
|
3146
2763
|
function clampPriority(priority) {
|
|
3147
2764
|
return Math.max(1, Math.min(5, Math.round(priority)));
|
|
3148
2765
|
}
|
|
2766
|
+
/**
|
|
2767
|
+
* A copy backed by its own `ArrayBuffer`.
|
|
2768
|
+
*
|
|
2769
|
+
* The attachment types demand `Uint8Array<ArrayBuffer>`, and a view handed over
|
|
2770
|
+
* by an assembler may sit on a shared buffer. Copying is cheap next to the
|
|
2771
|
+
* encode that produced it and removes a whole class of aliasing question.
|
|
2772
|
+
*/
|
|
2773
|
+
function copyBytes(bytes) {
|
|
2774
|
+
const out = new Uint8Array(bytes.byteLength);
|
|
2775
|
+
out.set(bytes);
|
|
2776
|
+
return out;
|
|
2777
|
+
}
|
|
2778
|
+
//#endregion
|
|
2779
|
+
//#region src/notification-center/event-intake.ts
|
|
2780
|
+
/** Object-event insert (movement-state transitions + zone crossings). */
|
|
2781
|
+
function incomingFromObjectEvent(event) {
|
|
2782
|
+
return {
|
|
2783
|
+
subject: subjectFromObjectEvent(event),
|
|
2784
|
+
kind: "object-event",
|
|
2785
|
+
origin: "pipeline",
|
|
2786
|
+
log: () => ({
|
|
2787
|
+
tags: { deviceId: event.deviceId },
|
|
2788
|
+
meta: { eventId: event.id }
|
|
2789
|
+
})
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
/** `TrackCloser.closeExpired` — the ONE close point. */
|
|
2793
|
+
function incomingFromTrack(track, info) {
|
|
2794
|
+
return {
|
|
2795
|
+
subject: subjectFromTrack(track, info),
|
|
2796
|
+
kind: "track-end",
|
|
2797
|
+
origin: "pipeline",
|
|
2798
|
+
log: () => ({
|
|
2799
|
+
tags: { deviceId: track.deviceId },
|
|
2800
|
+
meta: { trackId: track.trackId }
|
|
2801
|
+
})
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* Sensor-event persist (one row per linked camera).
|
|
2806
|
+
*
|
|
2807
|
+
* `markerTrackId` is the synthetic marker track the same state change
|
|
2808
|
+
* projected onto this camera — the notification's media owner, and the only
|
|
2809
|
+
* frame a device event has. Absent when no marker landed.
|
|
2810
|
+
*/
|
|
2811
|
+
function incomingFromSensorEvent(event, markerTrackId) {
|
|
2812
|
+
return {
|
|
2813
|
+
subject: subjectFromSensorEvent(event, markerTrackId),
|
|
2814
|
+
kind: "device-event",
|
|
2815
|
+
origin: "sensor",
|
|
2816
|
+
log: () => ({
|
|
2817
|
+
tags: { deviceId: event.deviceId },
|
|
2818
|
+
meta: {
|
|
2819
|
+
sensorEventId: event.id,
|
|
2820
|
+
kind: event.kind,
|
|
2821
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
|
|
2822
|
+
}
|
|
2823
|
+
})
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
/** Audio-event persist (classified episode). */
|
|
2827
|
+
function incomingFromAudioEvent(event) {
|
|
2828
|
+
return {
|
|
2829
|
+
subject: subjectFromAudioEvent(event),
|
|
2830
|
+
kind: "audio-event",
|
|
2831
|
+
origin: "pipeline",
|
|
2832
|
+
log: () => ({
|
|
2833
|
+
tags: { deviceId: event.deviceId },
|
|
2834
|
+
meta: {
|
|
2835
|
+
audioEventId: event.id,
|
|
2836
|
+
class: event.classification?.className
|
|
2837
|
+
}
|
|
2838
|
+
})
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
/** Package object-event persist (delivered / picked-up). */
|
|
2842
|
+
function incomingFromPackageEvent(event, phase) {
|
|
2843
|
+
return {
|
|
2844
|
+
subject: subjectFromPackageEvent(event, phase),
|
|
2845
|
+
kind: "package-event",
|
|
2846
|
+
origin: "pipeline",
|
|
2847
|
+
log: () => ({
|
|
2848
|
+
tags: { deviceId: event.deviceId },
|
|
2849
|
+
meta: {
|
|
2850
|
+
eventId: event.id,
|
|
2851
|
+
phase
|
|
2852
|
+
}
|
|
2853
|
+
})
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
/** A committed ZoneAnalytics occupancy edge. */
|
|
2857
|
+
function incomingFromOccupancyEdge(edge) {
|
|
2858
|
+
return {
|
|
2859
|
+
subject: subjectFromOccupancyEvent(edge),
|
|
2860
|
+
kind: "occupancy-event",
|
|
2861
|
+
origin: "pipeline",
|
|
2862
|
+
log: () => ({
|
|
2863
|
+
tags: { deviceId: edge.deviceId },
|
|
2864
|
+
meta: {
|
|
2865
|
+
zoneId: edge.zoneId ?? "@frame",
|
|
2866
|
+
className: edge.className ?? "@all",
|
|
2867
|
+
count: edge.count,
|
|
2868
|
+
threshold: edge.threshold,
|
|
2869
|
+
occupied: edge.occupied
|
|
2870
|
+
}
|
|
2871
|
+
})
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
2875
|
+
* record — same subject, honest origin in the log. */
|
|
2876
|
+
function asReconcile(event) {
|
|
2877
|
+
return {
|
|
2878
|
+
...event,
|
|
2879
|
+
origin: "reconcile"
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
/**
|
|
2883
|
+
* The delivery an intake kind rides. `object-event` and `audio-event` both ride
|
|
2884
|
+
* `immediate` (classified audio has no delivery of its own — it is gated by the
|
|
2885
|
+
* audio-class opt-in instead); `occupancy-event` rides `device-event`.
|
|
2886
|
+
*
|
|
2887
|
+
* Extracted from the centre so the tester can tell an operator which rules a
|
|
2888
|
+
* given simulated trigger will even be considered against, without evaluating.
|
|
2889
|
+
*/
|
|
2890
|
+
function deliveryForKind(kind) {
|
|
2891
|
+
if (kind === "object-event" || kind === "audio-event") return "immediate";
|
|
2892
|
+
if (kind === "occupancy-event") return "device-event";
|
|
2893
|
+
return kind;
|
|
2894
|
+
}
|
|
3149
2895
|
//#endregion
|
|
3150
2896
|
//#region src/notification-center/occupancy-watcher.ts
|
|
3151
2897
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
@@ -4002,60 +3748,6 @@ function rowToEntry$1(id, data) {
|
|
|
4002
3748
|
};
|
|
4003
3749
|
}
|
|
4004
3750
|
//#endregion
|
|
4005
|
-
//#region src/notification-center/action-buttons.ts
|
|
4006
|
-
/** Sequence names the rule actually declares. A button may only name one. */
|
|
4007
|
-
function declaredSequences(actions) {
|
|
4008
|
-
const names = /* @__PURE__ */ new Set();
|
|
4009
|
-
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
4010
|
-
return names;
|
|
4011
|
-
}
|
|
4012
|
-
/**
|
|
4013
|
-
* Build the notification's buttons.
|
|
4014
|
-
*
|
|
4015
|
-
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
4016
|
-
* to set the field, and an empty array is the honest answer to "which buttons
|
|
4017
|
-
* survived", which `undefined` would conflate with "the rule declared none".
|
|
4018
|
-
*/
|
|
4019
|
-
function buildActionButtons(input) {
|
|
4020
|
-
const actions = input.actions;
|
|
4021
|
-
if (actions === void 0) return [];
|
|
4022
|
-
const buttons = actions.buttons ?? [];
|
|
4023
|
-
if (buttons.length === 0) return [];
|
|
4024
|
-
const known = declaredSequences(actions);
|
|
4025
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4026
|
-
const out = [];
|
|
4027
|
-
for (const button of buttons) {
|
|
4028
|
-
if (!known.has(button.sequence)) continue;
|
|
4029
|
-
if (seen.has(button.id)) continue;
|
|
4030
|
-
seen.add(button.id);
|
|
4031
|
-
out.push({
|
|
4032
|
-
id: button.id,
|
|
4033
|
-
label: button.label,
|
|
4034
|
-
url: input.mintUrl({
|
|
4035
|
-
sequence: button.sequence,
|
|
4036
|
-
actionId: button.id
|
|
4037
|
-
}),
|
|
4038
|
-
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
4039
|
-
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
4040
|
-
});
|
|
4041
|
-
}
|
|
4042
|
-
return out;
|
|
4043
|
-
}
|
|
4044
|
-
/**
|
|
4045
|
-
* Which of a rule's buttons name a sequence it does not have.
|
|
4046
|
-
*
|
|
4047
|
-
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
4048
|
-
* notification is the exact shape of failure this repo keeps paying for — the
|
|
4049
|
-
* operator authored it, it never appeared, and nothing said why.
|
|
4050
|
-
*/
|
|
4051
|
-
function unresolvableButtons(actions) {
|
|
4052
|
-
if (actions === void 0) return [];
|
|
4053
|
-
const buttons = actions.buttons ?? [];
|
|
4054
|
-
if (buttons.length === 0) return [];
|
|
4055
|
-
const known = declaredSequences(actions);
|
|
4056
|
-
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
4057
|
-
}
|
|
4058
|
-
//#endregion
|
|
4059
3751
|
//#region src/notification-center/rule-actions.ts
|
|
4060
3752
|
var NcRuleActionRunner = class {
|
|
4061
3753
|
deps;
|
|
@@ -4368,53 +4060,6 @@ var NcRuleStore = class {
|
|
|
4368
4060
|
});
|
|
4369
4061
|
}
|
|
4370
4062
|
};
|
|
4371
|
-
var NcDeviceStateCache = class {
|
|
4372
|
-
deps;
|
|
4373
|
-
states = /* @__PURE__ */ new Map();
|
|
4374
|
-
lastOkAt = 0;
|
|
4375
|
-
ids = [];
|
|
4376
|
-
now;
|
|
4377
|
-
constructor(deps) {
|
|
4378
|
-
this.deps = deps;
|
|
4379
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4380
|
-
}
|
|
4381
|
-
/**
|
|
4382
|
-
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
4383
|
-
* with no gated rule does no work at all.
|
|
4384
|
-
*/
|
|
4385
|
-
setWatched(ids) {
|
|
4386
|
-
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
4387
|
-
}
|
|
4388
|
-
watched() {
|
|
4389
|
-
return this.ids;
|
|
4390
|
-
}
|
|
4391
|
-
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
4392
|
-
get(deviceId) {
|
|
4393
|
-
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
4394
|
-
return this.states.get(deviceId);
|
|
4395
|
-
}
|
|
4396
|
-
/**
|
|
4397
|
-
* Re-read every watched state. Never throws: a gate that could fail the
|
|
4398
|
-
* evaluation tick would take the notification pipeline with it.
|
|
4399
|
-
*/
|
|
4400
|
-
async refresh() {
|
|
4401
|
-
if (this.ids.length === 0) {
|
|
4402
|
-
this.states = /* @__PURE__ */ new Map();
|
|
4403
|
-
return;
|
|
4404
|
-
}
|
|
4405
|
-
try {
|
|
4406
|
-
const next = await this.deps.readStates(this.ids);
|
|
4407
|
-
this.states = new Map(next);
|
|
4408
|
-
this.lastOkAt = this.now();
|
|
4409
|
-
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 } });
|
|
4410
|
-
} catch (err) {
|
|
4411
|
-
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
4412
|
-
watched: this.ids.length,
|
|
4413
|
-
error: String(err)
|
|
4414
|
-
} });
|
|
4415
|
-
}
|
|
4416
|
-
}
|
|
4417
|
-
};
|
|
4418
4063
|
//#endregion
|
|
4419
4064
|
//#region src/notification-center/snooze-digest.ts
|
|
4420
4065
|
/** `14:03` in the node's local zone — a digest is read by a person, at home. */
|
|
@@ -4531,119 +4176,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
|
|
|
4531
4176
|
return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
|
|
4532
4177
|
}
|
|
4533
4178
|
//#endregion
|
|
4534
|
-
//#region src/notification-center/device-mute-store.ts
|
|
4535
|
-
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
4536
|
-
var NC_DEVICE_MUTES_COLUMNS = [
|
|
4537
|
-
(
|
|
4538
|
-
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
4539
|
-
{
|
|
4540
|
-
name: "id",
|
|
4541
|
-
type: "TEXT",
|
|
4542
|
-
primaryKey: true,
|
|
4543
|
-
notNull: true
|
|
4544
|
-
}),
|
|
4545
|
-
{
|
|
4546
|
-
name: "deviceId",
|
|
4547
|
-
type: "INTEGER",
|
|
4548
|
-
notNull: true
|
|
4549
|
-
},
|
|
4550
|
-
(
|
|
4551
|
-
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
4552
|
-
{
|
|
4553
|
-
name: "mutedAt",
|
|
4554
|
-
type: "INTEGER",
|
|
4555
|
-
notNull: true
|
|
4556
|
-
})
|
|
4557
|
-
];
|
|
4558
|
-
/**
|
|
4559
|
-
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
4560
|
-
* the table proportional to what the operator actually silenced, and makes the
|
|
4561
|
-
* hot-path read a `Set.has`.
|
|
4562
|
-
*/
|
|
4563
|
-
var NcDeviceMuteStore = class {
|
|
4564
|
-
muted = /* @__PURE__ */ new Set();
|
|
4565
|
-
store;
|
|
4566
|
-
logger;
|
|
4567
|
-
now;
|
|
4568
|
-
constructor(deps) {
|
|
4569
|
-
this.store = deps.store;
|
|
4570
|
-
this.logger = deps.logger;
|
|
4571
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4572
|
-
}
|
|
4573
|
-
static async declare(store) {
|
|
4574
|
-
await store.declareCollection.mutate({
|
|
4575
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4576
|
-
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
4577
|
-
});
|
|
4578
|
-
}
|
|
4579
|
-
/**
|
|
4580
|
-
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
4581
|
-
* clearing it: dropping every mute on a transient store error would start
|
|
4582
|
-
* notifying for a camera the operator silenced, without them asking — the
|
|
4583
|
-
* same reasoning as `NcSnoozeStore.load`.
|
|
4584
|
-
*/
|
|
4585
|
-
async load() {
|
|
4586
|
-
try {
|
|
4587
|
-
const rows = await this.store.query.query({
|
|
4588
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4589
|
-
filter: { limit: 1e4 }
|
|
4590
|
-
});
|
|
4591
|
-
const next = /* @__PURE__ */ new Set();
|
|
4592
|
-
let skipped = 0;
|
|
4593
|
-
for (const row of rows) {
|
|
4594
|
-
const raw = row.data["deviceId"];
|
|
4595
|
-
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
4596
|
-
skipped += 1;
|
|
4597
|
-
continue;
|
|
4598
|
-
}
|
|
4599
|
-
next.add(raw);
|
|
4600
|
-
}
|
|
4601
|
-
this.muted.clear();
|
|
4602
|
-
for (const id of next) this.muted.add(id);
|
|
4603
|
-
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
4604
|
-
} catch (err) {
|
|
4605
|
-
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
4606
|
-
error: String(err),
|
|
4607
|
-
muted: this.muted.size
|
|
4608
|
-
} });
|
|
4609
|
-
}
|
|
4610
|
-
}
|
|
4611
|
-
/** Hot-path read, called once per evaluated subject. */
|
|
4612
|
-
isMuted(deviceId) {
|
|
4613
|
-
return this.muted.has(deviceId);
|
|
4614
|
-
}
|
|
4615
|
-
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
4616
|
-
listMuted() {
|
|
4617
|
-
return [...this.muted].toSorted((a, b) => a - b);
|
|
4618
|
-
}
|
|
4619
|
-
/**
|
|
4620
|
-
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
4621
|
-
* updated only after the write lands, so a failed write cannot leave the
|
|
4622
|
-
* hot path believing a camera is silenced when the next boot will not.
|
|
4623
|
-
*/
|
|
4624
|
-
async setMuted(deviceId, muted) {
|
|
4625
|
-
const id = String(deviceId);
|
|
4626
|
-
if (muted) {
|
|
4627
|
-
await this.store.set.mutate({
|
|
4628
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4629
|
-
key: id,
|
|
4630
|
-
value: {
|
|
4631
|
-
id,
|
|
4632
|
-
deviceId,
|
|
4633
|
-
mutedAt: this.now()
|
|
4634
|
-
}
|
|
4635
|
-
});
|
|
4636
|
-
this.muted.add(deviceId);
|
|
4637
|
-
} else {
|
|
4638
|
-
await this.store.delete.mutate({
|
|
4639
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4640
|
-
key: id
|
|
4641
|
-
});
|
|
4642
|
-
this.muted.delete(deviceId);
|
|
4643
|
-
}
|
|
4644
|
-
}
|
|
4645
|
-
};
|
|
4646
|
-
//#endregion
|
|
4647
4179
|
//#region src/notification-center/snooze-store.ts
|
|
4648
4180
|
/**
|
|
4649
4181
|
* NcSnoozeStore — the durable half of snooze.
|
|
@@ -4997,6 +4529,306 @@ var NcSnoozeStore = class {
|
|
|
4997
4529
|
}
|
|
4998
4530
|
};
|
|
4999
4531
|
//#endregion
|
|
4532
|
+
//#region src/notification-center/test-event.ts
|
|
4533
|
+
/** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
|
|
4534
|
+
var BboxSchema = object({
|
|
4535
|
+
x: number().min(0).max(1),
|
|
4536
|
+
y: number().min(0).max(1),
|
|
4537
|
+
w: number().min(0).max(1),
|
|
4538
|
+
h: number().min(0).max(1)
|
|
4539
|
+
});
|
|
4540
|
+
/**
|
|
4541
|
+
* What the operator typed. Every field maps onto exactly one field of
|
|
4542
|
+
* {@link NcRuleSubject} — there is no test-only condition input, because a
|
|
4543
|
+
* condition the tester could set and a real record could not is a condition the
|
|
4544
|
+
* test result would lie about.
|
|
4545
|
+
*/
|
|
4546
|
+
var NcTestEventInputSchema = object({
|
|
4547
|
+
trigger: _enum([
|
|
4548
|
+
"object-event",
|
|
4549
|
+
"track-end",
|
|
4550
|
+
"device-event",
|
|
4551
|
+
"package-event",
|
|
4552
|
+
"audio-event",
|
|
4553
|
+
"occupancy-event"
|
|
4554
|
+
]),
|
|
4555
|
+
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
4556
|
+
deviceId: number().int(),
|
|
4557
|
+
/** DEVICE-EVENT: the linked device whose state changed — the contact, the
|
|
4558
|
+
* button, the doorbell — when that is not the camera. */
|
|
4559
|
+
sourceDeviceId: number().int().optional(),
|
|
4560
|
+
/** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
|
|
4561
|
+
classNames: array(string()).default([]),
|
|
4562
|
+
/** Collapsed display label — an identity name, a plate, a subclass. */
|
|
4563
|
+
label: string().optional(),
|
|
4564
|
+
/** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
|
|
4565
|
+
* condition FAILS on an absent confidence, and the tester must be able to
|
|
4566
|
+
* reproduce that. */
|
|
4567
|
+
confidence: number().min(0).max(1).optional(),
|
|
4568
|
+
/** Identity/plate MATCH confidence — distinct from `confidence`. */
|
|
4569
|
+
labelConfidence: number().min(0).max(1).optional(),
|
|
4570
|
+
/** Key-event importance [0,1] (track-end only, in practice). */
|
|
4571
|
+
importance: number().min(0).max(1).optional(),
|
|
4572
|
+
/** Track lifespan in seconds (track-end only). */
|
|
4573
|
+
dwellSeconds: number().min(0).optional(),
|
|
4574
|
+
/** Admin zone ids the subject is in (object) / visited (track). */
|
|
4575
|
+
zones: array(string()).default([]),
|
|
4576
|
+
/** OBJECT-EVENT: simulate a confirmed zone crossing. */
|
|
4577
|
+
crossing: object({
|
|
4578
|
+
zoneId: string(),
|
|
4579
|
+
direction: _enum(["enter", "exit"])
|
|
4580
|
+
}).optional(),
|
|
4581
|
+
/** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
|
|
4582
|
+
sensorKind: string().optional(),
|
|
4583
|
+
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
4584
|
+
* event-emitter slice. */
|
|
4585
|
+
eventType: string().optional(),
|
|
4586
|
+
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
4587
|
+
packagePhase: _enum(["delivered", "picked-up"]).optional(),
|
|
4588
|
+
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
4589
|
+
* source reads as). */
|
|
4590
|
+
source: _enum([
|
|
4591
|
+
"pipeline",
|
|
4592
|
+
"onboard",
|
|
4593
|
+
"sensor",
|
|
4594
|
+
"audio"
|
|
4595
|
+
]).optional(),
|
|
4596
|
+
/** Normalized bbox — feeds `customZones`, AND is what lets the still be
|
|
4597
|
+
* cropped / boxed the way the rule's media policy asks. */
|
|
4598
|
+
bbox: BboxSchema.optional(),
|
|
4599
|
+
/** OCCUPANCY-EVENT: the committed count edge. */
|
|
4600
|
+
occupancy: object({
|
|
4601
|
+
zoneId: string().optional(),
|
|
4602
|
+
zoneName: string().optional(),
|
|
4603
|
+
className: string().optional(),
|
|
4604
|
+
count: number().int().min(0),
|
|
4605
|
+
previousCount: number().int().min(0).default(0),
|
|
4606
|
+
occupied: boolean(),
|
|
4607
|
+
threshold: number().int().min(1)
|
|
4608
|
+
}).optional(),
|
|
4609
|
+
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
4610
|
+
* condition without waiting for the hour. */
|
|
4611
|
+
timestamp: number().int().optional(),
|
|
4612
|
+
/** Evaluate only this rule (what the per-rule Test button sends). */
|
|
4613
|
+
onlyRuleId: string().optional(),
|
|
4614
|
+
bypassCooldown: boolean().default(false),
|
|
4615
|
+
bypassMute: boolean().default(false),
|
|
4616
|
+
/** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
|
|
4617
|
+
* not open a gate the operator did not ask to open. */
|
|
4618
|
+
runActions: boolean().default(false),
|
|
4619
|
+
/** Where the still comes from. `live` takes a snapshot of the camera NOW;
|
|
4620
|
+
* `none` sends the notification without one. */
|
|
4621
|
+
still: _enum(["live", "none"]).default("live")
|
|
4622
|
+
});
|
|
4623
|
+
/** Per-rule verdict, as the tester reports it. */
|
|
4624
|
+
var NcTestEventRuleOutcomeSchema = object({
|
|
4625
|
+
ruleId: string(),
|
|
4626
|
+
ruleName: string(),
|
|
4627
|
+
matched: boolean(),
|
|
4628
|
+
failedCondition: string().optional(),
|
|
4629
|
+
matchedOn: array(string()).optional(),
|
|
4630
|
+
cooldown: _enum([
|
|
4631
|
+
"n/a",
|
|
4632
|
+
"clear",
|
|
4633
|
+
"suppressed",
|
|
4634
|
+
"bypassed"
|
|
4635
|
+
]),
|
|
4636
|
+
enqueued: number().int(),
|
|
4637
|
+
targetIds: array(string())
|
|
4638
|
+
});
|
|
4639
|
+
var NcTestEventResultSchema = object({
|
|
4640
|
+
/** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
|
|
4641
|
+
* for in the logs, in the outbox and in the delivery history. */
|
|
4642
|
+
recordId: string(),
|
|
4643
|
+
/** Always `synthetic` — the marker the whole path carried. */
|
|
4644
|
+
origin: literal("synthetic"),
|
|
4645
|
+
/** The delivery the chosen trigger rides. A rule declaring anything else was
|
|
4646
|
+
* never a candidate, which is the answer to most "it did not fire". */
|
|
4647
|
+
delivery: string(),
|
|
4648
|
+
candidates: number().int(),
|
|
4649
|
+
outcomes: array(NcTestEventRuleOutcomeSchema),
|
|
4650
|
+
/** What the still ended up being. `unattachable` means the chosen TRIGGER
|
|
4651
|
+
* cannot carry one at all — an audio episode and an occupancy edge freeze no
|
|
4652
|
+
* media owner, so those notifications ship text-only in production too, and
|
|
4653
|
+
* that is a property of production, not of the test. A DEVICE event is no
|
|
4654
|
+
* longer one of them: its press projects a marker track that owns the frame. */
|
|
4655
|
+
still: _enum([
|
|
4656
|
+
"live",
|
|
4657
|
+
"none",
|
|
4658
|
+
"unavailable",
|
|
4659
|
+
"unattachable"
|
|
4660
|
+
]),
|
|
4661
|
+
/** Everything the operator has to know to read the result honestly. */
|
|
4662
|
+
notes: array(string()),
|
|
4663
|
+
droppedBy: string().optional()
|
|
4664
|
+
});
|
|
4665
|
+
/** The id shape every synthetic record carries. One prefix, everywhere. */
|
|
4666
|
+
var SYNTHETIC_ID_PREFIX = "synthetic:";
|
|
4667
|
+
/** True for a record id this module minted. Used by the media shim. */
|
|
4668
|
+
function isSyntheticId(id) {
|
|
4669
|
+
return id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
4670
|
+
}
|
|
4671
|
+
/**
|
|
4672
|
+
* Whether a subject of this kind can carry a still AT ALL, in production.
|
|
4673
|
+
*
|
|
4674
|
+
* The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
|
|
4675
|
+
* owner onto the payload only for the kinds that have one:
|
|
4676
|
+
*
|
|
4677
|
+
* - an `eventId` for object and package events;
|
|
4678
|
+
* - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
|
|
4679
|
+
* DEVICE event too: the same state change projects a synthetic marker track
|
|
4680
|
+
* carrying the camera's frame (`sensor-marker-projector.ts`), and the
|
|
4681
|
+
* notification names it as its owner.
|
|
4682
|
+
*
|
|
4683
|
+
* An audio episode and an occupancy edge still freeze neither. Audio HAS a
|
|
4684
|
+
* marker projection, but its id does not reach the notification subject yet;
|
|
4685
|
+
* occupancy has no projection at all. Those two therefore remain honestly
|
|
4686
|
+
* unattachable — a tester that invented a picture for them would be advertising
|
|
4687
|
+
* a feature that does not exist.
|
|
4688
|
+
*/
|
|
4689
|
+
function triggerCanCarryStill(kind) {
|
|
4690
|
+
return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
|
|
4691
|
+
}
|
|
4692
|
+
/**
|
|
4693
|
+
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
4694
|
+
* event, so the red-green test can assert that this subject and a
|
|
4695
|
+
* producer-built one are indistinguishable.
|
|
4696
|
+
*/
|
|
4697
|
+
function buildSyntheticEvent(input, recordId, now) {
|
|
4698
|
+
const kind = input.trigger;
|
|
4699
|
+
const timestamp = input.timestamp ?? now;
|
|
4700
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
|
|
4701
|
+
return {
|
|
4702
|
+
subject: {
|
|
4703
|
+
kind,
|
|
4704
|
+
recordId,
|
|
4705
|
+
deviceId: input.deviceId,
|
|
4706
|
+
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
4707
|
+
timestamp,
|
|
4708
|
+
classNames: input.classNames,
|
|
4709
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
4710
|
+
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
4711
|
+
zones: input.zones,
|
|
4712
|
+
...carriesTrackId ? { trackId: recordId } : {},
|
|
4713
|
+
source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
|
|
4714
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
4715
|
+
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
4716
|
+
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
4717
|
+
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
4718
|
+
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
4719
|
+
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
4720
|
+
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
4721
|
+
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
4722
|
+
...input.occupancy !== void 0 ? { occupancy: {
|
|
4723
|
+
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
4724
|
+
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
4725
|
+
...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
|
|
4726
|
+
count: input.occupancy.count,
|
|
4727
|
+
previousCount: input.occupancy.previousCount,
|
|
4728
|
+
occupied: input.occupancy.occupied,
|
|
4729
|
+
threshold: input.occupancy.threshold
|
|
4730
|
+
} } : {}
|
|
4731
|
+
},
|
|
4732
|
+
kind,
|
|
4733
|
+
origin: "synthetic",
|
|
4734
|
+
controls: {
|
|
4735
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
|
|
4736
|
+
bypassCooldown: input.bypassCooldown,
|
|
4737
|
+
bypassMute: input.bypassMute,
|
|
4738
|
+
runActions: input.runActions
|
|
4739
|
+
},
|
|
4740
|
+
log: () => ({
|
|
4741
|
+
tags: { deviceId: input.deviceId },
|
|
4742
|
+
meta: {
|
|
4743
|
+
eventId: recordId,
|
|
4744
|
+
synthetic: true,
|
|
4745
|
+
trigger: input.trigger,
|
|
4746
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
4747
|
+
}
|
|
4748
|
+
})
|
|
4749
|
+
};
|
|
4750
|
+
}
|
|
4751
|
+
/**
|
|
4752
|
+
* Turn one live JPEG into the media set the dispatcher's ladders look for.
|
|
4753
|
+
*
|
|
4754
|
+
* Every entry is the SAME photograph, differing only in how it was cut — which
|
|
4755
|
+
* is exactly what the ladders mean by their kind names, so nothing here claims
|
|
4756
|
+
* a frame it does not have:
|
|
4757
|
+
*
|
|
4758
|
+
* - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
|
|
4759
|
+
* - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
|
|
4760
|
+
* - `crop` / `thumbnail` — that bbox, cut out.
|
|
4761
|
+
*
|
|
4762
|
+
* With NO bbox only the clean-scene kinds are produced, so a rule asking for
|
|
4763
|
+
* `frame: 'cropped'` correctly finds nothing rather than being handed a whole
|
|
4764
|
+
* scene it did not ask for. sharp failures degrade the same way: the clean
|
|
4765
|
+
* scene always survives.
|
|
4766
|
+
*/
|
|
4767
|
+
async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
4768
|
+
const base64 = Buffer.from(jpeg).toString("base64");
|
|
4769
|
+
const clean = (kind) => ({
|
|
4770
|
+
key: `${SYNTHETIC_ID_PREFIX}${kind}`,
|
|
4771
|
+
kind,
|
|
4772
|
+
base64,
|
|
4773
|
+
sizeBytes: jpeg.byteLength,
|
|
4774
|
+
timestamp
|
|
4775
|
+
});
|
|
4776
|
+
const files = [
|
|
4777
|
+
clean("fullFrame"),
|
|
4778
|
+
clean("keyFrame"),
|
|
4779
|
+
clean("firstFrame")
|
|
4780
|
+
];
|
|
4781
|
+
if (bbox === void 0) return files;
|
|
4782
|
+
try {
|
|
4783
|
+
const { default: sharp } = await import("sharp");
|
|
4784
|
+
const meta = await sharp(Buffer.from(jpeg)).metadata();
|
|
4785
|
+
const W = meta.width ?? 0;
|
|
4786
|
+
const H = meta.height ?? 0;
|
|
4787
|
+
if (W === 0 || H === 0) return files;
|
|
4788
|
+
const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
|
|
4789
|
+
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
4790
|
+
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
4791
|
+
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
4792
|
+
const cropBuf = await sharp(Buffer.from(jpeg)).extract({
|
|
4793
|
+
left,
|
|
4794
|
+
top,
|
|
4795
|
+
width,
|
|
4796
|
+
height
|
|
4797
|
+
}).jpeg({ quality: 85 }).toBuffer();
|
|
4798
|
+
const cropB64 = cropBuf.toString("base64");
|
|
4799
|
+
files.push({
|
|
4800
|
+
key: `${SYNTHETIC_ID_PREFIX}crop`,
|
|
4801
|
+
kind: "crop",
|
|
4802
|
+
base64: cropB64,
|
|
4803
|
+
sizeBytes: cropBuf.byteLength,
|
|
4804
|
+
timestamp
|
|
4805
|
+
}, {
|
|
4806
|
+
key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
|
|
4807
|
+
kind: "thumbnail",
|
|
4808
|
+
base64: cropB64,
|
|
4809
|
+
sizeBytes: cropBuf.byteLength,
|
|
4810
|
+
timestamp
|
|
4811
|
+
});
|
|
4812
|
+
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
4813
|
+
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>`);
|
|
4814
|
+
const boxedBuf = await sharp(Buffer.from(jpeg)).composite([{
|
|
4815
|
+
input: overlay,
|
|
4816
|
+
top: 0,
|
|
4817
|
+
left: 0
|
|
4818
|
+
}]).jpeg({ quality: 85 }).toBuffer();
|
|
4819
|
+
files.push({
|
|
4820
|
+
key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
|
|
4821
|
+
kind: "fullFrameBoxed",
|
|
4822
|
+
base64: boxedBuf.toString("base64"),
|
|
4823
|
+
sizeBytes: boxedBuf.byteLength,
|
|
4824
|
+
timestamp
|
|
4825
|
+
});
|
|
4826
|
+
} catch {
|
|
4827
|
+
return files;
|
|
4828
|
+
}
|
|
4829
|
+
return files;
|
|
4830
|
+
}
|
|
4831
|
+
//#endregion
|
|
5000
4832
|
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
5001
4833
|
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
5002
4834
|
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
@@ -5303,6 +5135,16 @@ var WATERMARK_EVERY_TICKS = 15;
|
|
|
5303
5135
|
var OUTBOX_RETENTION_MS = 168 * 36e5;
|
|
5304
5136
|
var TEST_RULE_MAX_RESULTS = 200;
|
|
5305
5137
|
/**
|
|
5138
|
+
* How long a synthetic injection's live snapshot stays in RAM.
|
|
5139
|
+
*
|
|
5140
|
+
* Long enough to cover the drain tick plus the first handful of outbox
|
|
5141
|
+
* backoffs (5 s doubling to a 300 s cap), short enough that a forgotten test
|
|
5142
|
+
* cannot hold a JPEG for the life of the process. A row still retrying after
|
|
5143
|
+
* this loses its picture and ships text-only — the same degradation a real
|
|
5144
|
+
* event gets when its media never landed.
|
|
5145
|
+
*/
|
|
5146
|
+
var SYNTHETIC_MEDIA_TTL_MS = 10 * 6e4;
|
|
5147
|
+
/**
|
|
5306
5148
|
* The className every durable package event carries (mirrors
|
|
5307
5149
|
* `PackageDropDetector.PACKAGE_EVENT_CLASS` — declared locally so the NC module
|
|
5308
5150
|
* stays free of a cross-module import into the pipeline). Delivery events use
|
|
@@ -5416,6 +5258,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5416
5258
|
alarmPanel = null;
|
|
5417
5259
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
5418
5260
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
5261
|
+
/**
|
|
5262
|
+
* The live snapshot a synthetic injection took, held for as long as the
|
|
5263
|
+
* outbox may still be retrying its rows. In RAM and only in RAM: a test that
|
|
5264
|
+
* left a media blob behind would be a test that persisted something, which
|
|
5265
|
+
* is the one thing the synthetic producer promises not to do.
|
|
5266
|
+
*/
|
|
5267
|
+
syntheticMedia = /* @__PURE__ */ new Map();
|
|
5419
5268
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
5420
5269
|
lastNoMatchReportAt = /* @__PURE__ */ new Map();
|
|
5421
5270
|
noMatchSuppressed = /* @__PURE__ */ new Map();
|
|
@@ -5573,6 +5422,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5573
5422
|
this.dispatcher = new NcDispatcher({
|
|
5574
5423
|
...deps.dispatcher,
|
|
5575
5424
|
logger: this.logger.child("dispatch"),
|
|
5425
|
+
getMediaForOwner: async (ownerKind, ownerId) => {
|
|
5426
|
+
const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
|
|
5427
|
+
if (held !== void 0) return held.files;
|
|
5428
|
+
return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
|
|
5429
|
+
},
|
|
5576
5430
|
...deps.now !== void 0 ? { now: deps.now } : {},
|
|
5577
5431
|
isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
|
|
5578
5432
|
snoozeGate: (input) => this.snoozeGate(input)
|
|
@@ -5651,19 +5505,157 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5651
5505
|
this.evaluationActive = false;
|
|
5652
5506
|
}
|
|
5653
5507
|
/**
|
|
5508
|
+
* Consume ONE event. THE entry point of the notification path — every
|
|
5509
|
+
* producer arrives here and nothing else does.
|
|
5510
|
+
*
|
|
5511
|
+
* Fire-and-forget: the frame path must never await a notification. The
|
|
5512
|
+
* evaluation is appended to the serialized chain, so consumption order is
|
|
5513
|
+
* persist order (see {@link evalChain}).
|
|
5514
|
+
*
|
|
5515
|
+
* It persists nothing about the event itself. Whether a durable row exists is
|
|
5516
|
+
* the PRODUCER's business — the tracker writes its tracks, the sensor ingest
|
|
5517
|
+
* its rows, the synthetic tester nothing at all — and the only thing written
|
|
5518
|
+
* from here on is the outbox row, which is the notification's own delivery
|
|
5519
|
+
* queue and not a record of the event.
|
|
5520
|
+
*/
|
|
5521
|
+
consumeEvent(event) {
|
|
5522
|
+
if (!this.evaluationActive) return;
|
|
5523
|
+
this.scheduleEvaluation(event);
|
|
5524
|
+
}
|
|
5525
|
+
/**
|
|
5526
|
+
* The same consumption, awaited, with the verdict returned. Identical code
|
|
5527
|
+
* path — {@link consumeEvent} is this function with the report discarded — so
|
|
5528
|
+
* a producer that reads the report is testing what the live producers do, not
|
|
5529
|
+
* a parallel implementation of it.
|
|
5530
|
+
*/
|
|
5531
|
+
async consumeEventAndReport(event) {
|
|
5532
|
+
if (!this.evaluationActive) return {
|
|
5533
|
+
recordId: event.subject.recordId,
|
|
5534
|
+
kind: event.kind,
|
|
5535
|
+
delivery: deliveryForKind(event.kind),
|
|
5536
|
+
origin: event.origin,
|
|
5537
|
+
candidates: 0,
|
|
5538
|
+
outcomes: [],
|
|
5539
|
+
droppedBy: "evaluation-inactive"
|
|
5540
|
+
};
|
|
5541
|
+
const result = this.evalChain.then(() => this.evaluateAndEnqueue(event));
|
|
5542
|
+
this.evalChain = result.then(() => void 0, () => void 0);
|
|
5543
|
+
return result;
|
|
5544
|
+
}
|
|
5545
|
+
/**
|
|
5546
|
+
* The SYNTHETIC producer — an operator-driven event, through the whole path.
|
|
5547
|
+
*
|
|
5548
|
+
* It builds an envelope and calls {@link consumeEventAndReport}. That is the
|
|
5549
|
+
* entire integration: no bypass, no second matcher, no shortcut into the
|
|
5550
|
+
* dispatcher. What the operator receives on their phone was matched by
|
|
5551
|
+
* `evaluateRule`, enqueued into the durable outbox, drained by the ordinary
|
|
5552
|
+
* tick, given media by the ordinary ladders, degraded by the ordinary engine
|
|
5553
|
+
* and delivered by the ordinary notifier.
|
|
5554
|
+
*
|
|
5555
|
+
* What it does NOT do is persist the event. No track row, no object-event
|
|
5556
|
+
* row, no sensor row, no media blob — {@link syntheticMedia} is RAM with an
|
|
5557
|
+
* expiry. The outbox row is the exception that proves the rule: it is the
|
|
5558
|
+
* notification's delivery queue, not a record of the event, and it carries
|
|
5559
|
+
* `synthetic: true` so it can never be mistaken for one.
|
|
5560
|
+
*/
|
|
5561
|
+
async injectTestEvent(input, deps = {}) {
|
|
5562
|
+
const now = this.now();
|
|
5563
|
+
this.pruneSyntheticMedia(now);
|
|
5564
|
+
const recordId = `${SYNTHETIC_ID_PREFIX}${randomUUID()}`;
|
|
5565
|
+
const event = buildSyntheticEvent(input, recordId, now);
|
|
5566
|
+
const notes = [];
|
|
5567
|
+
let still = "none";
|
|
5568
|
+
if (!triggerCanCarryStill(event.kind)) {
|
|
5569
|
+
still = "unattachable";
|
|
5570
|
+
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`);
|
|
5571
|
+
} else if (input.still === "live") {
|
|
5572
|
+
const jpeg = await this.captureLiveStill(input.deviceId, deps);
|
|
5573
|
+
if (jpeg === null) {
|
|
5574
|
+
still = "unavailable";
|
|
5575
|
+
notes.push("the camera returned no snapshot — the notification ships without a still");
|
|
5576
|
+
} else {
|
|
5577
|
+
const files = await deriveSyntheticMedia(jpeg, input.bbox, now);
|
|
5578
|
+
this.syntheticMedia.set(recordId, {
|
|
5579
|
+
files,
|
|
5580
|
+
expiresAt: now + SYNTHETIC_MEDIA_TTL_MS
|
|
5581
|
+
});
|
|
5582
|
+
still = "live";
|
|
5583
|
+
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");
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
if (input.bypassCooldown) notes.push("cooldown bypassed, and NOT consumed");
|
|
5587
|
+
if (input.bypassMute) notes.push("per-camera notification mute bypassed");
|
|
5588
|
+
if (!input.runActions) notes.push("the rule’s onTrigger sequences were NOT run (nothing was actuated)");
|
|
5589
|
+
this.logger.info("synthetic event injected", {
|
|
5590
|
+
tags: { deviceId: input.deviceId },
|
|
5591
|
+
meta: {
|
|
5592
|
+
eventId: recordId,
|
|
5593
|
+
synthetic: true,
|
|
5594
|
+
trigger: input.trigger,
|
|
5595
|
+
classes: input.classNames,
|
|
5596
|
+
zones: input.zones,
|
|
5597
|
+
still,
|
|
5598
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
5599
|
+
}
|
|
5600
|
+
});
|
|
5601
|
+
const report = await this.consumeEventAndReport(event);
|
|
5602
|
+
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}"`);
|
|
5603
|
+
if (report.droppedBy === "muted") notes.push("this camera is muted — nothing was evaluated. Re-run with the mute bypass to see the rule verdicts");
|
|
5604
|
+
if (report.droppedBy === "evaluation-inactive") notes.push("this node does not run rule evaluation — inject on the post-processing node");
|
|
5605
|
+
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");
|
|
5606
|
+
return {
|
|
5607
|
+
recordId,
|
|
5608
|
+
origin: "synthetic",
|
|
5609
|
+
delivery: report.delivery,
|
|
5610
|
+
candidates: report.candidates,
|
|
5611
|
+
outcomes: report.outcomes.map((o) => ({
|
|
5612
|
+
ruleId: o.ruleId,
|
|
5613
|
+
ruleName: o.ruleName,
|
|
5614
|
+
matched: o.matched,
|
|
5615
|
+
...o.failedCondition !== void 0 ? { failedCondition: o.failedCondition } : {},
|
|
5616
|
+
...o.matchedOn !== void 0 ? { matchedOn: [...o.matchedOn] } : {},
|
|
5617
|
+
cooldown: o.cooldown,
|
|
5618
|
+
enqueued: o.enqueued,
|
|
5619
|
+
targetIds: [...o.targetIds]
|
|
5620
|
+
})),
|
|
5621
|
+
still,
|
|
5622
|
+
notes,
|
|
5623
|
+
...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
|
|
5624
|
+
};
|
|
5625
|
+
}
|
|
5626
|
+
/** One live frame, or null. Never throws into an injection. */
|
|
5627
|
+
async captureLiveStill(deviceId, deps) {
|
|
5628
|
+
const capture = deps.getSnapshot;
|
|
5629
|
+
if (capture === void 0) return null;
|
|
5630
|
+
try {
|
|
5631
|
+
const shot = await capture(deviceId);
|
|
5632
|
+
if (shot === null || shot.base64.length === 0) return null;
|
|
5633
|
+
const buf = Buffer.from(shot.base64, "base64");
|
|
5634
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
5635
|
+
bytes.set(buf);
|
|
5636
|
+
return bytes;
|
|
5637
|
+
} catch (err) {
|
|
5638
|
+
this.logger.warn("synthetic injection could not photograph the camera", {
|
|
5639
|
+
tags: { deviceId },
|
|
5640
|
+
meta: { error: String(err) }
|
|
5641
|
+
});
|
|
5642
|
+
return null;
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
/** Drop expired synthetic stills. Called on every injection — there is no
|
|
5646
|
+
* timer, because the map is empty except while a test is in flight. */
|
|
5647
|
+
pruneSyntheticMedia(now) {
|
|
5648
|
+
for (const [id, held] of this.syntheticMedia) if (held.expiresAt <= now) this.syntheticMedia.delete(id);
|
|
5649
|
+
}
|
|
5650
|
+
/**
|
|
5654
5651
|
* Called at the object-event persist site, in the SAME moment as the
|
|
5655
5652
|
* durable insert. Fire-and-forget from the frame path (never blocks the
|
|
5656
5653
|
* pipeline); the boot reconcile covers a crash between persist and
|
|
5657
5654
|
* outbox insert.
|
|
5658
5655
|
*/
|
|
5659
5656
|
onObjectEventPersisted(event, _track) {
|
|
5660
|
-
if (!this.evaluationActive) return;
|
|
5661
5657
|
if (packagePhaseOf(event) !== null) return;
|
|
5662
|
-
|
|
5663
|
-
this.scheduleEvaluation(subject, "object-event", () => ({
|
|
5664
|
-
tags: { deviceId: event.deviceId },
|
|
5665
|
-
meta: { eventId: event.id }
|
|
5666
|
-
}));
|
|
5658
|
+
this.consumeEvent(incomingFromObjectEvent(event));
|
|
5667
5659
|
}
|
|
5668
5660
|
/**
|
|
5669
5661
|
* Called from `TrackCloser.closeExpired` (the ONE close point, D-3) via
|
|
@@ -5672,18 +5664,19 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5672
5664
|
* post-close promotions are picked up).
|
|
5673
5665
|
*/
|
|
5674
5666
|
onTrackClosed(track, _ownedMedia, info) {
|
|
5675
|
-
|
|
5676
|
-
const subject = subjectFromTrack(track, info);
|
|
5677
|
-
this.scheduleEvaluation(subject, "track-end", () => ({
|
|
5678
|
-
tags: { deviceId: track.deviceId },
|
|
5679
|
-
meta: { trackId: track.trackId }
|
|
5680
|
-
}));
|
|
5667
|
+
this.consumeEvent(incomingFromTrack(track, info));
|
|
5681
5668
|
}
|
|
5682
5669
|
/**
|
|
5683
|
-
* Called
|
|
5684
|
-
*
|
|
5685
|
-
* `device-event` trigger (doorbell press / sensor state
|
|
5686
|
-
* forget from the ingest loop; the outbox owns delivery
|
|
5670
|
+
* Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
|
|
5671
|
+
* camera), once the same state change has had its chance to project a marker
|
|
5672
|
+
* track. Feeds the `device-event` trigger (doorbell press / sensor state
|
|
5673
|
+
* change). Fire-and-forget from the ingest loop; the outbox owns delivery.
|
|
5674
|
+
*
|
|
5675
|
+
* `markerTrackId` is that marker — the synthetic track carrying the camera's
|
|
5676
|
+
* frame — and it is what gives this notification a picture: the payload
|
|
5677
|
+
* freezes it as the media owner and the dispatcher resolves it through the
|
|
5678
|
+
* ladder every other attachment uses. Absent means no marker landed, and the
|
|
5679
|
+
* notification is text-only; that is logged, per camera, at the enqueue.
|
|
5687
5680
|
*
|
|
5688
5681
|
* Delivery-grade boundary (honest): the SensorEventStore is itself fed from
|
|
5689
5682
|
* the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
|
|
@@ -5692,16 +5685,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5692
5685
|
* device-event notification — the durable guarantee begins at this hook, not
|
|
5693
5686
|
* before it.
|
|
5694
5687
|
*/
|
|
5695
|
-
onSensorEventPersisted(event) {
|
|
5696
|
-
|
|
5697
|
-
const subject = subjectFromSensorEvent(event);
|
|
5698
|
-
this.scheduleEvaluation(subject, "device-event", () => ({
|
|
5699
|
-
tags: { deviceId: event.deviceId },
|
|
5700
|
-
meta: {
|
|
5701
|
-
sensorEventId: event.id,
|
|
5702
|
-
kind: event.kind
|
|
5703
|
-
}
|
|
5704
|
-
}));
|
|
5688
|
+
onSensorEventPersisted(event, markerTrackId) {
|
|
5689
|
+
this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
|
|
5705
5690
|
}
|
|
5706
5691
|
/**
|
|
5707
5692
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -5716,15 +5701,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5716
5701
|
* durable guarantee begins at this hook, matching the device-event boundary.
|
|
5717
5702
|
*/
|
|
5718
5703
|
onAudioEventPersisted(event) {
|
|
5719
|
-
|
|
5720
|
-
const subject = subjectFromAudioEvent(event);
|
|
5721
|
-
this.scheduleEvaluation(subject, "audio-event", () => ({
|
|
5722
|
-
tags: { deviceId: event.deviceId },
|
|
5723
|
-
meta: {
|
|
5724
|
-
audioEventId: event.id,
|
|
5725
|
-
class: event.classification?.className
|
|
5726
|
-
}
|
|
5727
|
-
}));
|
|
5704
|
+
this.consumeEvent(incomingFromAudioEvent(event));
|
|
5728
5705
|
}
|
|
5729
5706
|
/**
|
|
5730
5707
|
* Called at the package object-event persist site (`PackageDropDetector` —
|
|
@@ -5733,15 +5710,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5733
5710
|
* crash-gap reconcile re-covers them (routed by className in {@link reconcile}).
|
|
5734
5711
|
*/
|
|
5735
5712
|
onPackageEventPersisted(event, phase) {
|
|
5736
|
-
|
|
5737
|
-
const subject = subjectFromPackageEvent(event, phase);
|
|
5738
|
-
this.scheduleEvaluation(subject, "package-event", () => ({
|
|
5739
|
-
tags: { deviceId: event.deviceId },
|
|
5740
|
-
meta: {
|
|
5741
|
-
eventId: event.id,
|
|
5742
|
-
phase
|
|
5743
|
-
}
|
|
5744
|
-
}));
|
|
5713
|
+
this.consumeEvent(incomingFromPackageEvent(event, phase));
|
|
5745
5714
|
}
|
|
5746
5715
|
/**
|
|
5747
5716
|
* Feed one ZoneAnalytics occupancy snapshot into the debounced watcher
|
|
@@ -5777,18 +5746,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5777
5746
|
* Public so a test / a future out-of-band edge source can drive it directly.
|
|
5778
5747
|
*/
|
|
5779
5748
|
onOccupancyEdge(edge) {
|
|
5780
|
-
|
|
5781
|
-
const subject = subjectFromOccupancyEvent(edge);
|
|
5782
|
-
this.scheduleEvaluation(subject, "occupancy-event", () => ({
|
|
5783
|
-
tags: { deviceId: edge.deviceId },
|
|
5784
|
-
meta: {
|
|
5785
|
-
zoneId: edge.zoneId ?? "@frame",
|
|
5786
|
-
className: edge.className ?? "@all",
|
|
5787
|
-
count: edge.count,
|
|
5788
|
-
threshold: edge.threshold,
|
|
5789
|
-
occupied: edge.occupied
|
|
5790
|
-
}
|
|
5791
|
-
}));
|
|
5749
|
+
this.consumeEvent(incomingFromOccupancyEdge(edge));
|
|
5792
5750
|
}
|
|
5793
5751
|
buildProvider() {
|
|
5794
5752
|
return {
|
|
@@ -5885,7 +5843,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5885
5843
|
}
|
|
5886
5844
|
/**
|
|
5887
5845
|
* Adopt the panel this node owns. Called once by the addon after
|
|
5888
|
-
* `
|
|
5846
|
+
* `declareAlarmPanel`; a node without one never calls it and the cap then
|
|
5889
5847
|
* answers `deviceId: null`.
|
|
5890
5848
|
*/
|
|
5891
5849
|
setAlarmPanel(panel) {
|
|
@@ -5963,48 +5921,74 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5963
5921
|
});
|
|
5964
5922
|
}
|
|
5965
5923
|
/** Append one evaluation to the serialized chain (see {@link evalChain}). */
|
|
5966
|
-
scheduleEvaluation(
|
|
5924
|
+
scheduleEvaluation(event) {
|
|
5967
5925
|
this.evalChain = this.evalChain.then(async () => {
|
|
5968
5926
|
try {
|
|
5969
|
-
await this.evaluateAndEnqueue(
|
|
5927
|
+
await this.evaluateAndEnqueue(event);
|
|
5970
5928
|
} catch (err) {
|
|
5971
|
-
const ctx =
|
|
5929
|
+
const ctx = event.log();
|
|
5972
5930
|
this.logger.warn("rule evaluation failed", {
|
|
5973
5931
|
tags: ctx.tags,
|
|
5974
5932
|
meta: {
|
|
5975
5933
|
...ctx.meta,
|
|
5976
|
-
kind,
|
|
5934
|
+
kind: event.kind,
|
|
5935
|
+
origin: event.origin,
|
|
5977
5936
|
error: String(err)
|
|
5978
5937
|
}
|
|
5979
5938
|
});
|
|
5980
5939
|
}
|
|
5981
5940
|
});
|
|
5982
5941
|
}
|
|
5983
|
-
async evaluateAndEnqueue(
|
|
5984
|
-
|
|
5942
|
+
async evaluateAndEnqueue(event) {
|
|
5943
|
+
const { subject, kind, origin } = event;
|
|
5944
|
+
const controls = event.controls ?? {};
|
|
5945
|
+
const outcomes = [];
|
|
5946
|
+
const report = (extra = {}) => ({
|
|
5947
|
+
recordId: subject.recordId,
|
|
5948
|
+
kind,
|
|
5949
|
+
delivery: deliveryForKind(kind),
|
|
5950
|
+
origin,
|
|
5951
|
+
candidates: 0,
|
|
5952
|
+
outcomes,
|
|
5953
|
+
...extra
|
|
5954
|
+
});
|
|
5955
|
+
if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
|
|
5985
5956
|
this.reportMutedDrop(subject, kind, this.now());
|
|
5986
|
-
return;
|
|
5957
|
+
return report({ droppedBy: "muted" });
|
|
5987
5958
|
}
|
|
5988
|
-
const delivery = kind
|
|
5989
|
-
const
|
|
5959
|
+
const delivery = deliveryForKind(kind);
|
|
5960
|
+
const enabled = this.rules.listEnabled(delivery);
|
|
5961
|
+
const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
|
|
5990
5962
|
if (candidates.length === 0) {
|
|
5991
5963
|
this.logger.debug("no enabled rule for this trigger", {
|
|
5992
5964
|
tags: { deviceId: subject.deviceId },
|
|
5993
5965
|
meta: {
|
|
5994
5966
|
delivery,
|
|
5995
5967
|
kind,
|
|
5968
|
+
origin,
|
|
5996
5969
|
rulesLoaded: this.rules.list().length
|
|
5997
5970
|
}
|
|
5998
5971
|
});
|
|
5999
|
-
return;
|
|
5972
|
+
return report({ droppedBy: "no-candidate-rule" });
|
|
6000
5973
|
}
|
|
6001
5974
|
const now = this.now();
|
|
6002
5975
|
let anyMatched = false;
|
|
5976
|
+
/** One "no media owner" line per EVENT, not per matching rule. */
|
|
5977
|
+
let noOwnerReported = false;
|
|
6003
5978
|
const rejections = [];
|
|
6004
5979
|
for (const rule of candidates) {
|
|
6005
5980
|
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
|
|
6006
5981
|
if (!evaluation.matched) {
|
|
6007
5982
|
rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
|
|
5983
|
+
outcomes.push({
|
|
5984
|
+
ruleId: rule.id,
|
|
5985
|
+
ruleName: rule.name,
|
|
5986
|
+
matched: false,
|
|
5987
|
+
...evaluation.failedCondition !== void 0 ? { failedCondition: evaluation.failedCondition } : {},
|
|
5988
|
+
cooldown: "n/a",
|
|
5989
|
+
enqueued: 0,
|
|
5990
|
+
targetIds: []
|
|
5991
|
+
});
|
|
6008
5992
|
this.logger.debug("rule did not match", {
|
|
6009
5993
|
tags: { deviceId: subject.deviceId },
|
|
6010
5994
|
meta: {
|
|
@@ -6020,7 +6004,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6020
6004
|
continue;
|
|
6021
6005
|
}
|
|
6022
6006
|
const key = cooldownKey(rule, subject);
|
|
6023
|
-
|
|
6007
|
+
const cooling = isCoolingDown(rule, this.lastFiredAt.get(key), now);
|
|
6008
|
+
if (cooling && controls.bypassCooldown !== true) {
|
|
6024
6009
|
this.logger.debug("rule matched but is cooling down", {
|
|
6025
6010
|
tags: { deviceId: subject.deviceId },
|
|
6026
6011
|
meta: {
|
|
@@ -6031,6 +6016,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6031
6016
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
6032
6017
|
}
|
|
6033
6018
|
});
|
|
6019
|
+
outcomes.push({
|
|
6020
|
+
ruleId: rule.id,
|
|
6021
|
+
ruleName: rule.name,
|
|
6022
|
+
matched: true,
|
|
6023
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6024
|
+
cooldown: "suppressed",
|
|
6025
|
+
enqueued: 0,
|
|
6026
|
+
targetIds: []
|
|
6027
|
+
});
|
|
6034
6028
|
continue;
|
|
6035
6029
|
}
|
|
6036
6030
|
this.logger.info("rule matched — enqueueing", {
|
|
@@ -6039,6 +6033,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6039
6033
|
ruleId: rule.id,
|
|
6040
6034
|
rule: rule.name,
|
|
6041
6035
|
kind,
|
|
6036
|
+
origin,
|
|
6042
6037
|
targets: rule.targets.length,
|
|
6043
6038
|
eventId: subject.recordId,
|
|
6044
6039
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
|
|
@@ -6047,7 +6042,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6047
6042
|
}
|
|
6048
6043
|
});
|
|
6049
6044
|
const sequences = rule.actions?.onTrigger;
|
|
6050
|
-
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
|
|
6045
|
+
if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null && controls.runActions !== false) this.actionRunner.run({
|
|
6051
6046
|
ruleId: rule.id,
|
|
6052
6047
|
ruleName: rule.name,
|
|
6053
6048
|
deviceId: subject.deviceId,
|
|
@@ -6062,11 +6057,35 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6062
6057
|
buttons: unresolvable.join(", ")
|
|
6063
6058
|
}
|
|
6064
6059
|
});
|
|
6060
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
6061
|
+
noOwnerReported = true;
|
|
6062
|
+
this.logger.warn("device event has no marker track — this notification ships text-only", {
|
|
6063
|
+
tags: { deviceId: subject.deviceId },
|
|
6064
|
+
meta: {
|
|
6065
|
+
recordId: subject.recordId,
|
|
6066
|
+
...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
|
|
6067
|
+
ruleId: rule.id,
|
|
6068
|
+
rule: rule.name
|
|
6069
|
+
}
|
|
6070
|
+
});
|
|
6071
|
+
}
|
|
6065
6072
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6066
|
-
|
|
6073
|
+
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
|
|
6074
|
+
const inserted = await this.outbox.enqueue(entries);
|
|
6075
|
+
if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
|
|
6076
|
+
outcomes.push({
|
|
6077
|
+
ruleId: rule.id,
|
|
6078
|
+
ruleName: rule.name,
|
|
6079
|
+
matched: true,
|
|
6080
|
+
...evaluation.matchedOn !== void 0 ? { matchedOn: [...evaluation.matchedOn] } : {},
|
|
6081
|
+
cooldown: cooling ? "bypassed" : "clear",
|
|
6082
|
+
enqueued: inserted,
|
|
6083
|
+
targetIds: entries.map((e) => e.targetId)
|
|
6084
|
+
});
|
|
6067
6085
|
anyMatched = true;
|
|
6068
6086
|
}
|
|
6069
6087
|
if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
|
|
6088
|
+
return report({ candidates: candidates.length });
|
|
6070
6089
|
}
|
|
6071
6090
|
/**
|
|
6072
6091
|
* One INFO line when a MUTED camera's event is dropped, bounded to one per
|
|
@@ -6118,7 +6137,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6118
6137
|
}
|
|
6119
6138
|
});
|
|
6120
6139
|
}
|
|
6121
|
-
buildEntries(rule, subject, kind, matchedOn, userTargets = []) {
|
|
6140
|
+
buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline") {
|
|
6122
6141
|
const hasEventMedia = kind === "object-event" || kind === "package-event";
|
|
6123
6142
|
const isTrackScoped = kind === "object-event" || kind === "track-end";
|
|
6124
6143
|
const direct = new Set(rule.targets.map((t) => t.targetId));
|
|
@@ -6126,6 +6145,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6126
6145
|
const payload = {
|
|
6127
6146
|
ruleName: rule.name,
|
|
6128
6147
|
delivery: rule.delivery,
|
|
6148
|
+
...origin === "synthetic" ? { synthetic: true } : {},
|
|
6129
6149
|
priority: rule.priority,
|
|
6130
6150
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
6131
6151
|
media: rule.media.attach,
|
|
@@ -6309,21 +6329,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6309
6329
|
const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
|
|
6310
6330
|
for (const event of ordered) {
|
|
6311
6331
|
const phase = packagePhaseOf(event);
|
|
6312
|
-
|
|
6313
|
-
tags: { deviceId: event.deviceId },
|
|
6314
|
-
meta: {
|
|
6315
|
-
eventId: event.id,
|
|
6316
|
-
phase,
|
|
6317
|
-
reconcile: true
|
|
6318
|
-
}
|
|
6319
|
-
}));
|
|
6320
|
-
else this.scheduleEvaluation(subjectFromObjectEvent(event), "object-event", () => ({
|
|
6321
|
-
tags: { deviceId: event.deviceId },
|
|
6322
|
-
meta: {
|
|
6323
|
-
eventId: event.id,
|
|
6324
|
-
reconcile: true
|
|
6325
|
-
}
|
|
6326
|
-
}));
|
|
6332
|
+
this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
|
|
6327
6333
|
}
|
|
6328
6334
|
await this.evalChain;
|
|
6329
6335
|
if (ordered.length > 0) this.logger.info("notification reconcile scanned missed events", { meta: {
|
|
@@ -6491,7 +6497,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6491
6497
|
}
|
|
6492
6498
|
} else if (rule.delivery === "track-end") {
|
|
6493
6499
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
6494
|
-
for (const track of tracks) subjects.push(
|
|
6500
|
+
for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
|
|
6495
6501
|
}
|
|
6496
6502
|
let lookup = (id) => this.deviceStates.get(id);
|
|
6497
6503
|
const gated = rule.conditions.deviceState;
|
|
@@ -6594,6 +6600,24 @@ var ncActions = defineCustomActions({
|
|
|
6594
6600
|
"nc.cancelSnooze": customAction(object({ snoozeId: string() }), object({ success: literal(true) }), {
|
|
6595
6601
|
kind: "mutation",
|
|
6596
6602
|
caller: "required"
|
|
6603
|
+
}),
|
|
6604
|
+
/**
|
|
6605
|
+
* Inject a SYNTHETIC event through the whole notification path — match,
|
|
6606
|
+
* media, delivery — without producing a track, an event row or anything else
|
|
6607
|
+
* durable. See `test-event.ts` for what it does and does not do.
|
|
6608
|
+
*
|
|
6609
|
+
* A bridge action rather than a cap method, deliberately: it needs no
|
|
6610
|
+
* codegen, no `@camstack/system` republish and no place on a release train,
|
|
6611
|
+
* so the tester can ship and change at the speed of the addon it tests.
|
|
6612
|
+
*
|
|
6613
|
+
* `auth: 'admin'` because an injection DELIVERS: it reaches every target the
|
|
6614
|
+
* matched rule addresses, on other people's phones. `caller: 'required'` so
|
|
6615
|
+
* the log line names who pressed it.
|
|
6616
|
+
*/
|
|
6617
|
+
"nc.injectTestEvent": customAction(NcTestEventInputSchema, NcTestEventResultSchema, {
|
|
6618
|
+
kind: "mutation",
|
|
6619
|
+
auth: "admin",
|
|
6620
|
+
caller: "required"
|
|
6597
6621
|
})
|
|
6598
6622
|
});
|
|
6599
6623
|
/** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
|
|
@@ -6708,6 +6732,22 @@ function makeNcActionHandlers(deps) {
|
|
|
6708
6732
|
await deps.snooze.cancel(input.snoozeId, c);
|
|
6709
6733
|
return { success: true };
|
|
6710
6734
|
},
|
|
6735
|
+
"nc.injectTestEvent": async (input, caller) => {
|
|
6736
|
+
const c = requireCaller(caller);
|
|
6737
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required to inject a test event");
|
|
6738
|
+
const inject = deps.injectTestEvent;
|
|
6739
|
+
if (inject === void 0) throw new Error("this node does not host the notification centre — inject on the hub");
|
|
6740
|
+
const parsed = NcTestEventInputSchema.parse(input);
|
|
6741
|
+
deps.logger.info("synthetic notification test requested", {
|
|
6742
|
+
tags: { deviceId: parsed.deviceId },
|
|
6743
|
+
meta: {
|
|
6744
|
+
by: c.userId,
|
|
6745
|
+
trigger: parsed.trigger,
|
|
6746
|
+
...parsed.onlyRuleId !== void 0 ? { ruleId: parsed.onlyRuleId } : {}
|
|
6747
|
+
}
|
|
6748
|
+
});
|
|
6749
|
+
return inject(parsed);
|
|
6750
|
+
},
|
|
6711
6751
|
"nc.setRuleTargetEnabled": async (input, caller) => {
|
|
6712
6752
|
const c = requireCaller(caller);
|
|
6713
6753
|
assertRuleVisible(input.ruleId, c);
|
|
@@ -6717,6 +6757,844 @@ function makeNcActionHandlers(deps) {
|
|
|
6717
6757
|
}
|
|
6718
6758
|
};
|
|
6719
6759
|
}
|
|
6760
|
+
var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
|
|
6761
|
+
var DebugFindingSchema = object({
|
|
6762
|
+
code: string(),
|
|
6763
|
+
severity: _enum([
|
|
6764
|
+
"error",
|
|
6765
|
+
"warn",
|
|
6766
|
+
"info"
|
|
6767
|
+
]),
|
|
6768
|
+
detail: string()
|
|
6769
|
+
});
|
|
6770
|
+
var DebugTrackListItemSchema = object({
|
|
6771
|
+
trackId: string(),
|
|
6772
|
+
deviceId: number(),
|
|
6773
|
+
className: string(),
|
|
6774
|
+
label: string().optional(),
|
|
6775
|
+
firstSeen: number(),
|
|
6776
|
+
lastSeen: number(),
|
|
6777
|
+
durationMs: number(),
|
|
6778
|
+
active: boolean()
|
|
6779
|
+
});
|
|
6780
|
+
var DebugTrackReportSchema = object({
|
|
6781
|
+
trackId: string(),
|
|
6782
|
+
deviceId: number(),
|
|
6783
|
+
className: string(),
|
|
6784
|
+
label: string().optional(),
|
|
6785
|
+
firstSeen: number(),
|
|
6786
|
+
lastSeen: number(),
|
|
6787
|
+
durationMs: number(),
|
|
6788
|
+
active: boolean(),
|
|
6789
|
+
mediaCount: number(),
|
|
6790
|
+
mediaKinds: array(string()).readonly(),
|
|
6791
|
+
eventCount: number(),
|
|
6792
|
+
positionCount: number(),
|
|
6793
|
+
findings: array(DebugFindingSchema).readonly()
|
|
6794
|
+
});
|
|
6795
|
+
var ListTracksInputSchema = object({
|
|
6796
|
+
/** Empty or absent means every camera that has tracks. */
|
|
6797
|
+
deviceIds: array(number()).optional(),
|
|
6798
|
+
limit: number().int().positive().max(200).optional()
|
|
6799
|
+
});
|
|
6800
|
+
var ListTracksOutputSchema = object({
|
|
6801
|
+
tracks: array(DebugTrackListItemSchema).readonly(),
|
|
6802
|
+
/**
|
|
6803
|
+
* True when a camera hit `limit`. Said rather than implied: a silently capped
|
|
6804
|
+
* list is how an operator concludes a mark did not take.
|
|
6805
|
+
*/
|
|
6806
|
+
truncated: boolean()
|
|
6807
|
+
});
|
|
6808
|
+
var AnalyzeInputSchema = object({ trackIds: array(string()).min(1).max(200) });
|
|
6809
|
+
var AnalyzeOutputSchema = object({
|
|
6810
|
+
reports: array(DebugTrackReportSchema).readonly(),
|
|
6811
|
+
/**
|
|
6812
|
+
* Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
|
|
6813
|
+
* pin retention, so a marked track can be evicted between the list and the
|
|
6814
|
+
* analysis — reported as a number the tab can show rather than as silence.
|
|
6815
|
+
*/
|
|
6816
|
+
vanished: array(string()).readonly()
|
|
6817
|
+
});
|
|
6818
|
+
var debugActions = defineCustomActions({
|
|
6819
|
+
"debug.listTracks": customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
|
|
6820
|
+
"debug.analyze": customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
|
|
6821
|
+
});
|
|
6822
|
+
//#endregion
|
|
6823
|
+
//#region src/pipeline-analytics/debug/zip-stream.ts
|
|
6824
|
+
/**
|
|
6825
|
+
* A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
|
|
6826
|
+
* entry, without ever holding the archive in memory.
|
|
6827
|
+
*
|
|
6828
|
+
* ## Why hand-rolled, and why ZIP rather than the tar next door
|
|
6829
|
+
*
|
|
6830
|
+
* The archive the operator asked for is a ZIP, because it is the format that
|
|
6831
|
+
* opens with a double click on every desktop they use; the training exports use
|
|
6832
|
+
* ustar and that stays as it is. Both are hand-written here for the same two
|
|
6833
|
+
* reasons the {@link ../training-export/tar-stream.js tar writer} records:
|
|
6834
|
+
*
|
|
6835
|
+
* 1. **A dependency would not arrive.** A hub addon is deployed as its built
|
|
6836
|
+
* `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
|
|
6837
|
+
* `archiver` to `package.json` gets a module that is missing at runtime on
|
|
6838
|
+
* the node that actually runs the code. The only libraries an addon can rely
|
|
6839
|
+
* on are Node's builtins and what its bundler inlines.
|
|
6840
|
+
* 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
|
|
6841
|
+
* STORE (method 0) spends no CPU on the hub's one thread for a percent or
|
|
6842
|
+
* two, and — unlike DEFLATE — the entry's size and CRC are known before the
|
|
6843
|
+
* header is written, so no data descriptors and no seeking back: the writer
|
|
6844
|
+
* is append-only and therefore streamable.
|
|
6845
|
+
*
|
|
6846
|
+
* So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
|
|
6847
|
+
* plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
|
|
6848
|
+
* asserted against the system `unzip` binary in the spec next door rather than
|
|
6849
|
+
* against my reading of the format.
|
|
6850
|
+
*
|
|
6851
|
+
* ## What this deliberately does NOT do
|
|
6852
|
+
*
|
|
6853
|
+
* No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
|
|
6854
|
+
* archive is a hand-marked debug set — tens of tracks, not a dataset.
|
|
6855
|
+
* {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
|
|
6856
|
+
* so the caller can skip it and SAY it skipped it, rather than emitting an
|
|
6857
|
+
* archive with a wrapped offset that reads as corrupt.
|
|
6858
|
+
*
|
|
6859
|
+
* ## Entry names are ASCII, by construction
|
|
6860
|
+
*
|
|
6861
|
+
* The writer sets the UTF-8 name flag (bit 11) because that is what the format
|
|
6862
|
+
* says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
|
|
6863
|
+
* 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
|
|
6864
|
+
* comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
|
|
6865
|
+
* valid for the filesystem it refuses to extract the entry at all. Measured, not
|
|
6866
|
+
* assumed; the spec next door pins it.
|
|
6867
|
+
*
|
|
6868
|
+
* So {@link toAsciiZipName} is applied to every path, and — more importantly —
|
|
6869
|
+
* no free text goes into a path in the first place: an entry is addressed by
|
|
6870
|
+
* `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
|
|
6871
|
+
* its encoding is JSON's problem and not the archive's.
|
|
6872
|
+
*/
|
|
6873
|
+
var LOCAL_SIG = 67324752;
|
|
6874
|
+
var CENTRAL_SIG = 33639248;
|
|
6875
|
+
var EOCD_SIG = 101010256;
|
|
6876
|
+
/** Bit 11 — the entry name is UTF-8. */
|
|
6877
|
+
var FLAG_UTF8 = 2048;
|
|
6878
|
+
/** STORE. */
|
|
6879
|
+
var METHOD_STORE = 0;
|
|
6880
|
+
/** PKZIP 2.0, the floor for STORE + UTF-8 names. */
|
|
6881
|
+
var VERSION = 20;
|
|
6882
|
+
/** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
|
|
6883
|
+
var ZIP_MAX_BYTES = 4294967295;
|
|
6884
|
+
var CRC_TABLE = (() => {
|
|
6885
|
+
const table = new Array(256);
|
|
6886
|
+
for (let i = 0; i < 256; i++) {
|
|
6887
|
+
let c = i;
|
|
6888
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
6889
|
+
table[i] = c >>> 0;
|
|
6890
|
+
}
|
|
6891
|
+
return table;
|
|
6892
|
+
})();
|
|
6893
|
+
/**
|
|
6894
|
+
* Reduce a path to the ASCII set every ZIP reader agrees on.
|
|
6895
|
+
*
|
|
6896
|
+
* Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
|
|
6897
|
+
* transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
|
|
6898
|
+
* only name this archive can promise, and a lossy-but-stable mapping is better
|
|
6899
|
+
* than a pretty one that a reader mangles differently from the manifest.
|
|
6900
|
+
*/
|
|
6901
|
+
function toAsciiZipName(name) {
|
|
6902
|
+
return name.replace(/[^A-Za-z0-9._/-]/g, "_");
|
|
6903
|
+
}
|
|
6904
|
+
/** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
|
|
6905
|
+
function crc32(buf) {
|
|
6906
|
+
let c = 4294967295;
|
|
6907
|
+
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
|
|
6908
|
+
return (c ^ 4294967295) >>> 0;
|
|
6909
|
+
}
|
|
6910
|
+
/**
|
|
6911
|
+
* MS-DOS date/time, the only timestamp a classic ZIP record carries.
|
|
6912
|
+
*
|
|
6913
|
+
* Two-second resolution and an epoch of 1980 — a date before that is clamped
|
|
6914
|
+
* rather than allowed to write a negative year field, which some readers show as
|
|
6915
|
+
* 2107 and others reject outright.
|
|
6916
|
+
*/
|
|
6917
|
+
function dosDateTime(mtimeMs) {
|
|
6918
|
+
const d = new Date(mtimeMs);
|
|
6919
|
+
const year = Math.max(1980, d.getFullYear());
|
|
6920
|
+
return {
|
|
6921
|
+
time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
|
|
6922
|
+
date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
|
|
6923
|
+
};
|
|
6924
|
+
}
|
|
6925
|
+
/**
|
|
6926
|
+
* The 30-byte local header + name for one stored file.
|
|
6927
|
+
*
|
|
6928
|
+
* Sizes and CRC are written into the header itself (never a trailing data
|
|
6929
|
+
* descriptor) — STORE knows both before the body moves, and a header that tells
|
|
6930
|
+
* the truth is what lets a reader index the archive without scanning it.
|
|
6931
|
+
*/
|
|
6932
|
+
function zipLocalHeader(entry, crc, nameBytes) {
|
|
6933
|
+
const head = Buffer.alloc(30);
|
|
6934
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
6935
|
+
head.writeUInt32LE(LOCAL_SIG, 0);
|
|
6936
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6937
|
+
head.writeUInt16LE(FLAG_UTF8, 6);
|
|
6938
|
+
head.writeUInt16LE(METHOD_STORE, 8);
|
|
6939
|
+
head.writeUInt16LE(time, 10);
|
|
6940
|
+
head.writeUInt16LE(date, 12);
|
|
6941
|
+
head.writeUInt32LE(crc, 14);
|
|
6942
|
+
head.writeUInt32LE(entry.body.length, 18);
|
|
6943
|
+
head.writeUInt32LE(entry.body.length, 22);
|
|
6944
|
+
head.writeUInt16LE(nameBytes.length, 26);
|
|
6945
|
+
head.writeUInt16LE(0, 28);
|
|
6946
|
+
return Buffer.concat([head, nameBytes]);
|
|
6947
|
+
}
|
|
6948
|
+
function centralHeader(entry) {
|
|
6949
|
+
const head = Buffer.alloc(46);
|
|
6950
|
+
head.writeUInt32LE(CENTRAL_SIG, 0);
|
|
6951
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6952
|
+
head.writeUInt16LE(VERSION, 6);
|
|
6953
|
+
head.writeUInt16LE(FLAG_UTF8, 8);
|
|
6954
|
+
head.writeUInt16LE(METHOD_STORE, 10);
|
|
6955
|
+
head.writeUInt16LE(entry.dosTime, 12);
|
|
6956
|
+
head.writeUInt16LE(entry.dosDate, 14);
|
|
6957
|
+
head.writeUInt32LE(entry.crc, 16);
|
|
6958
|
+
head.writeUInt32LE(entry.size, 20);
|
|
6959
|
+
head.writeUInt32LE(entry.size, 24);
|
|
6960
|
+
head.writeUInt16LE(entry.nameBytes.length, 28);
|
|
6961
|
+
head.writeUInt16LE(0, 30);
|
|
6962
|
+
head.writeUInt16LE(0, 32);
|
|
6963
|
+
head.writeUInt16LE(0, 34);
|
|
6964
|
+
head.writeUInt16LE(0, 36);
|
|
6965
|
+
head.writeUInt32LE(0, 38);
|
|
6966
|
+
head.writeUInt32LE(entry.offset, 42);
|
|
6967
|
+
return Buffer.concat([head, entry.nameBytes]);
|
|
6968
|
+
}
|
|
6969
|
+
function endOfCentralDirectory(count, size, offset) {
|
|
6970
|
+
const eocd = Buffer.alloc(22);
|
|
6971
|
+
eocd.writeUInt32LE(EOCD_SIG, 0);
|
|
6972
|
+
eocd.writeUInt16LE(0, 4);
|
|
6973
|
+
eocd.writeUInt16LE(0, 6);
|
|
6974
|
+
eocd.writeUInt16LE(count, 8);
|
|
6975
|
+
eocd.writeUInt16LE(count, 10);
|
|
6976
|
+
eocd.writeUInt32LE(size, 12);
|
|
6977
|
+
eocd.writeUInt32LE(offset, 16);
|
|
6978
|
+
eocd.writeUInt16LE(0, 20);
|
|
6979
|
+
return eocd;
|
|
6980
|
+
}
|
|
6981
|
+
/**
|
|
6982
|
+
* True when one more entry still fits inside the classic (non-ZIP64) ceilings.
|
|
6983
|
+
*
|
|
6984
|
+
* Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
|
|
6985
|
+
* be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
|
|
6986
|
+
* 32-bit offset, producing an archive that looks complete and unpacks as
|
|
6987
|
+
* garbage) is precisely the one a test must be able to reach.
|
|
6988
|
+
*/
|
|
6989
|
+
function zipEntryFits(query) {
|
|
6990
|
+
if (query.entryIndex >= 65535) return false;
|
|
6991
|
+
const nameLen = toAsciiZipName(query.name).length;
|
|
6992
|
+
return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
|
|
6993
|
+
}
|
|
6994
|
+
/**
|
|
6995
|
+
* An append-only ZIP writer.
|
|
6996
|
+
*
|
|
6997
|
+
* The caller supplies `write` — the real `res.write`, awaited, so backpressure
|
|
6998
|
+
* is the socket's. The writer holds only the central directory (one small record
|
|
6999
|
+
* per entry), never a body: {@link add} takes one file at a time and forgets it.
|
|
7000
|
+
*/
|
|
7001
|
+
var ZipStreamWriter = class {
|
|
7002
|
+
write;
|
|
7003
|
+
entries = [];
|
|
7004
|
+
offset = 0;
|
|
7005
|
+
constructor(write) {
|
|
7006
|
+
this.write = write;
|
|
7007
|
+
}
|
|
7008
|
+
/** Entries accepted so far — what the manifest should claim, not what was asked. */
|
|
7009
|
+
get entryCount() {
|
|
7010
|
+
return this.entries.length;
|
|
7011
|
+
}
|
|
7012
|
+
/** Bytes emitted so far, central directory excluded. */
|
|
7013
|
+
get bytesWritten() {
|
|
7014
|
+
return this.offset;
|
|
7015
|
+
}
|
|
7016
|
+
/**
|
|
7017
|
+
* True when this entry still fits inside the classic ZIP ceilings.
|
|
7018
|
+
*
|
|
7019
|
+
* Checked BEFORE the header goes out, because a 32-bit offset that wraps
|
|
7020
|
+
* produces an archive that looks complete and unpacks as garbage — the failure
|
|
7021
|
+
* this writer most needs to make impossible.
|
|
7022
|
+
*/
|
|
7023
|
+
fits(entry) {
|
|
7024
|
+
return zipEntryFits({
|
|
7025
|
+
offset: this.offset,
|
|
7026
|
+
entryIndex: this.entries.length,
|
|
7027
|
+
name: entry.name,
|
|
7028
|
+
bodyLength: entry.body.length
|
|
7029
|
+
});
|
|
7030
|
+
}
|
|
7031
|
+
/** Append one stored file. Returns false (and writes nothing) when it does not fit. */
|
|
7032
|
+
async add(entry) {
|
|
7033
|
+
if (!this.fits(entry)) return false;
|
|
7034
|
+
const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
|
|
7035
|
+
const crc = crc32(entry.body);
|
|
7036
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
7037
|
+
const header = zipLocalHeader(entry, crc, nameBytes);
|
|
7038
|
+
this.entries.push({
|
|
7039
|
+
nameBytes,
|
|
7040
|
+
crc,
|
|
7041
|
+
size: entry.body.length,
|
|
7042
|
+
offset: this.offset,
|
|
7043
|
+
dosTime: time,
|
|
7044
|
+
dosDate: date
|
|
7045
|
+
});
|
|
7046
|
+
await this.write(header);
|
|
7047
|
+
await this.write(entry.body);
|
|
7048
|
+
this.offset += header.length + entry.body.length;
|
|
7049
|
+
return true;
|
|
7050
|
+
}
|
|
7051
|
+
/** Write the central directory + EOCD. The archive is unreadable without it. */
|
|
7052
|
+
async finish() {
|
|
7053
|
+
const start = this.offset;
|
|
7054
|
+
let size = 0;
|
|
7055
|
+
for (const entry of this.entries) {
|
|
7056
|
+
const block = centralHeader(entry);
|
|
7057
|
+
await this.write(block);
|
|
7058
|
+
size += block.length;
|
|
7059
|
+
}
|
|
7060
|
+
await this.write(endOfCentralDirectory(this.entries.length, size, start));
|
|
7061
|
+
this.offset = start + size + 22;
|
|
7062
|
+
}
|
|
7063
|
+
};
|
|
7064
|
+
var DEBUG_MANIFEST_ENTRY = "manifest.json";
|
|
7065
|
+
var DEBUG_TRACK_ENTRY = "track.json";
|
|
7066
|
+
var DEBUG_ANALYSIS_ENTRY = "analysis.json";
|
|
7067
|
+
var DEBUG_EVENTS_ENTRY = "events.json";
|
|
7068
|
+
/** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
|
|
7069
|
+
function debugTrackDir(deviceId, trackId) {
|
|
7070
|
+
return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
|
|
7071
|
+
}
|
|
7072
|
+
/**
|
|
7073
|
+
* Resolve every media row of one track to an archive-relative name.
|
|
7074
|
+
*
|
|
7075
|
+
* Deterministic: the input order (the store returns `timestamp asc`) fully
|
|
7076
|
+
* determines the output, so re-exporting an unchanged track produces the same
|
|
7077
|
+
* names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
|
|
7078
|
+
* carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
|
|
7079
|
+
*/
|
|
7080
|
+
function planTrackMediaNames(media) {
|
|
7081
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
7082
|
+
for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
|
|
7083
|
+
const used = /* @__PURE__ */ new Set();
|
|
7084
|
+
const out = [];
|
|
7085
|
+
for (const m of media) {
|
|
7086
|
+
const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
|
|
7087
|
+
let name = `media/${base}.jpg`;
|
|
7088
|
+
for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
|
|
7089
|
+
used.add(name);
|
|
7090
|
+
out.push({
|
|
7091
|
+
key: m.key,
|
|
7092
|
+
name: toAsciiZipName(name),
|
|
7093
|
+
kind: m.kind,
|
|
7094
|
+
timestamp: m.timestamp,
|
|
7095
|
+
sizeBytes: m.sizeBytes
|
|
7096
|
+
});
|
|
7097
|
+
}
|
|
7098
|
+
return out;
|
|
7099
|
+
}
|
|
7100
|
+
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.";
|
|
7101
|
+
/** The archive filename, stamped so two downloads never collide in ~/Downloads. */
|
|
7102
|
+
function debugArchiveFilename(nowMs) {
|
|
7103
|
+
return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
|
|
7104
|
+
}
|
|
7105
|
+
/** Parse `?deviceIds=615,617` — empty means every camera. */
|
|
7106
|
+
function parseDebugDeviceIds(url) {
|
|
7107
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
7108
|
+
const raw = new URLSearchParams(query).get("deviceIds");
|
|
7109
|
+
if (raw === null || raw.trim() === "") return [];
|
|
7110
|
+
const out = [];
|
|
7111
|
+
for (const part of raw.split(",")) {
|
|
7112
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
7113
|
+
if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
|
|
7114
|
+
}
|
|
7115
|
+
return out;
|
|
7116
|
+
}
|
|
7117
|
+
//#endregion
|
|
7118
|
+
//#region src/pipeline-analytics/data-plane-access.ts
|
|
7119
|
+
/**
|
|
7120
|
+
* Prefix literals for the export planes, mirrored here so the policy reads as
|
|
7121
|
+
* a table without importing three handler modules into the addon's boot path.
|
|
7122
|
+
* `data-plane-access.spec.ts` asserts they still match the constants their
|
|
7123
|
+
* handlers register with — a mirror nothing checks is a lie waiting to happen.
|
|
7124
|
+
*/
|
|
7125
|
+
var TRAINING_EXPORT_PREFIX_REF = "training-export";
|
|
7126
|
+
var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
|
|
7127
|
+
var DEBUG_EXPORT_PREFIX_REF = "debug-export";
|
|
7128
|
+
/**
|
|
7129
|
+
* Every data plane this addon serves, and the level the HUB enforces before it
|
|
7130
|
+
* proxies. Exhaustive by test: a new plane that forgets to appear here fails
|
|
7131
|
+
* `data-plane-access.spec.ts`.
|
|
7132
|
+
*/
|
|
7133
|
+
var PIPELINE_ANALYTICS_PLANE_ACCESS = {
|
|
7134
|
+
/** One stored crop/thumbnail by an unguessable id the caller already holds. */
|
|
7135
|
+
"event-media": "authenticated",
|
|
7136
|
+
/** Self-authenticating single-use action token (D47). */
|
|
7137
|
+
"nc-action": "public",
|
|
7138
|
+
/** Self-authenticating single-use artifact token (D47). */
|
|
7139
|
+
"nc-artifact": "public",
|
|
7140
|
+
/** Every frame behind a marked selection — a corpus, not an item. */
|
|
7141
|
+
[TRAINING_EXPORT_PREFIX_REF]: "admin",
|
|
7142
|
+
[RETRAIN_EXPORT_PREFIX_REF]: "admin",
|
|
7143
|
+
/** Diagnostic bundle: logs, config and frames. */
|
|
7144
|
+
[DEBUG_EXPORT_PREFIX_REF]: "admin"
|
|
7145
|
+
};
|
|
7146
|
+
/** The declared level for a plane prefix. Typed, so a prefix with no declared
|
|
7147
|
+
* policy is a compile error rather than a silent default. */
|
|
7148
|
+
function planeAccess(prefix) {
|
|
7149
|
+
return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
|
|
7150
|
+
}
|
|
7151
|
+
//#endregion
|
|
7152
|
+
//#region src/pipeline-analytics/debug/debug-export-handler.ts
|
|
7153
|
+
/**
|
|
7154
|
+
* Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
|
|
7155
|
+
* it yields twice: between tracks, and between individual media files.
|
|
7156
|
+
*
|
|
7157
|
+
* The per-file yield is the one that matters. A track can own twenty blobs, and
|
|
7158
|
+
* reading them back to back — each a base64 decode of a few hundred KB —
|
|
7159
|
+
* occupies the loop for long enough that snapshot resizes start timing out. One
|
|
7160
|
+
* file at a time, with a gap, keeps the export off the critical path; it is slow
|
|
7161
|
+
* on purpose.
|
|
7162
|
+
*/
|
|
7163
|
+
var TRACK_PACING_MS = 25;
|
|
7164
|
+
var MEDIA_PACING_MS = 15;
|
|
7165
|
+
function json(value) {
|
|
7166
|
+
return Buffer.from(JSON.stringify(value, null, 2), "utf8");
|
|
7167
|
+
}
|
|
7168
|
+
/** Total media rows the plan hopes to ship — the number HEAD reports. */
|
|
7169
|
+
function plannedMediaCount(plan) {
|
|
7170
|
+
let total = 0;
|
|
7171
|
+
for (const t of plan.tracks) total += t.media.length;
|
|
7172
|
+
return total;
|
|
7173
|
+
}
|
|
7174
|
+
function createDebugExportHandler(deps) {
|
|
7175
|
+
return async (req, res) => {
|
|
7176
|
+
const method = req.method ?? "GET";
|
|
7177
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
7178
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
7179
|
+
res.end();
|
|
7180
|
+
return;
|
|
7181
|
+
}
|
|
7182
|
+
const deviceIds = parseDebugDeviceIds(req.url ?? "");
|
|
7183
|
+
let plan;
|
|
7184
|
+
try {
|
|
7185
|
+
plan = await deps.buildPlan(deviceIds);
|
|
7186
|
+
} catch (err) {
|
|
7187
|
+
deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
|
|
7188
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
7189
|
+
res.end("debug export plan failed");
|
|
7190
|
+
return;
|
|
7191
|
+
}
|
|
7192
|
+
res.writeHead(200, {
|
|
7193
|
+
"content-type": "application/zip",
|
|
7194
|
+
"content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
|
|
7195
|
+
"cache-control": "no-store",
|
|
7196
|
+
"x-camstack-debug-tracks": String(plan.tracks.length),
|
|
7197
|
+
"x-camstack-debug-media": String(plannedMediaCount(plan))
|
|
7198
|
+
});
|
|
7199
|
+
if (method === "HEAD") {
|
|
7200
|
+
res.end();
|
|
7201
|
+
return;
|
|
7202
|
+
}
|
|
7203
|
+
const write = (chunk) => new Promise((resolve, reject) => {
|
|
7204
|
+
if (!res.write(chunk, (err) => {
|
|
7205
|
+
if (err) reject(err);
|
|
7206
|
+
else resolve();
|
|
7207
|
+
})) res.once("drain", resolve);
|
|
7208
|
+
});
|
|
7209
|
+
const zip = new ZipStreamWriter(write);
|
|
7210
|
+
const mtimeMs = deps.now();
|
|
7211
|
+
const startedAt = deps.now();
|
|
7212
|
+
const manifestTracks = [];
|
|
7213
|
+
let included = 0;
|
|
7214
|
+
let missing = 0;
|
|
7215
|
+
let stoppedBecause = plan.capped ? "track-cap" : null;
|
|
7216
|
+
for (const entry of plan.tracks) {
|
|
7217
|
+
if (zip.bytesWritten >= 268435456) {
|
|
7218
|
+
stoppedBecause ??= "byte-budget";
|
|
7219
|
+
deps.logger.warn("debug export stopped — byte budget reached", { meta: {
|
|
7220
|
+
budget: MAX_EXPORT_BYTES,
|
|
7221
|
+
written: zip.bytesWritten
|
|
7222
|
+
} });
|
|
7223
|
+
break;
|
|
7224
|
+
}
|
|
7225
|
+
const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
|
|
7226
|
+
const deviceId = entry.report.deviceId;
|
|
7227
|
+
for (const [name, body] of [
|
|
7228
|
+
[DEBUG_TRACK_ENTRY, json(entry.track)],
|
|
7229
|
+
[DEBUG_ANALYSIS_ENTRY, json(entry.report)],
|
|
7230
|
+
[DEBUG_EVENTS_ENTRY, json(entry.events)]
|
|
7231
|
+
]) if (!await zip.add({
|
|
7232
|
+
name: `${dir}/${name}`,
|
|
7233
|
+
body,
|
|
7234
|
+
mtimeMs
|
|
7235
|
+
})) stoppedBecause ??= "zip-ceiling";
|
|
7236
|
+
const mediaIncluded = [];
|
|
7237
|
+
const mediaMissing = [];
|
|
7238
|
+
for (const media of entry.media) {
|
|
7239
|
+
const bytes = await deps.readMedia(media.key);
|
|
7240
|
+
if (bytes === null) {
|
|
7241
|
+
missing += 1;
|
|
7242
|
+
mediaMissing.push(media.key);
|
|
7243
|
+
deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
|
|
7244
|
+
tags: { deviceId },
|
|
7245
|
+
meta: {
|
|
7246
|
+
trackId: entry.report.trackId,
|
|
7247
|
+
mediaKey: media.key,
|
|
7248
|
+
kind: media.kind
|
|
7249
|
+
}
|
|
7250
|
+
});
|
|
7251
|
+
continue;
|
|
7252
|
+
}
|
|
7253
|
+
if (!await zip.add({
|
|
7254
|
+
name: `${dir}/${media.name}`,
|
|
7255
|
+
body: bytes,
|
|
7256
|
+
mtimeMs
|
|
7257
|
+
})) {
|
|
7258
|
+
stoppedBecause ??= "zip-ceiling";
|
|
7259
|
+
missing += 1;
|
|
7260
|
+
mediaMissing.push(media.key);
|
|
7261
|
+
deps.logger.warn("debug export skipped a media file — the archive is full", {
|
|
7262
|
+
tags: { deviceId },
|
|
7263
|
+
meta: {
|
|
7264
|
+
trackId: entry.report.trackId,
|
|
7265
|
+
mediaKey: media.key,
|
|
7266
|
+
kind: media.kind
|
|
7267
|
+
}
|
|
7268
|
+
});
|
|
7269
|
+
continue;
|
|
7270
|
+
}
|
|
7271
|
+
included += 1;
|
|
7272
|
+
mediaIncluded.push(media.name);
|
|
7273
|
+
await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
|
|
7274
|
+
}
|
|
7275
|
+
manifestTracks.push({
|
|
7276
|
+
trackId: entry.report.trackId,
|
|
7277
|
+
deviceId,
|
|
7278
|
+
dir,
|
|
7279
|
+
className: entry.report.className,
|
|
7280
|
+
...entry.report.label === void 0 ? {} : { label: entry.report.label },
|
|
7281
|
+
firstSeen: entry.report.firstSeen,
|
|
7282
|
+
lastSeen: entry.report.lastSeen,
|
|
7283
|
+
durationMs: entry.report.durationMs,
|
|
7284
|
+
eventCount: entry.report.eventCount,
|
|
7285
|
+
findings: entry.report.findings.map((f) => f.code),
|
|
7286
|
+
mediaIncluded,
|
|
7287
|
+
mediaMissing
|
|
7288
|
+
});
|
|
7289
|
+
await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
|
|
7290
|
+
}
|
|
7291
|
+
const manifest = {
|
|
7292
|
+
generatedAt: plan.generatedAt,
|
|
7293
|
+
deviceIds: plan.deviceIds,
|
|
7294
|
+
trackCount: manifestTracks.length,
|
|
7295
|
+
mediaIncluded: included,
|
|
7296
|
+
mediaMissing: missing,
|
|
7297
|
+
truncated: stoppedBecause !== null,
|
|
7298
|
+
stoppedBecause,
|
|
7299
|
+
retentionNote: DEBUG_RETENTION_NOTE,
|
|
7300
|
+
tracks: manifestTracks
|
|
7301
|
+
};
|
|
7302
|
+
await zip.add({
|
|
7303
|
+
name: DEBUG_MANIFEST_ENTRY,
|
|
7304
|
+
body: json(manifest),
|
|
7305
|
+
mtimeMs
|
|
7306
|
+
});
|
|
7307
|
+
await zip.finish();
|
|
7308
|
+
await new Promise((resolve) => {
|
|
7309
|
+
res.end(resolve);
|
|
7310
|
+
});
|
|
7311
|
+
deps.logger.info("debug export complete", { meta: {
|
|
7312
|
+
tracks: manifestTracks.length,
|
|
7313
|
+
mediaIncluded: included,
|
|
7314
|
+
mediaMissing: missing,
|
|
7315
|
+
stoppedBecause,
|
|
7316
|
+
bytes: zip.bytesWritten,
|
|
7317
|
+
elapsedMs: deps.now() - startedAt
|
|
7318
|
+
} });
|
|
7319
|
+
};
|
|
7320
|
+
}
|
|
7321
|
+
//#endregion
|
|
7322
|
+
//#region src/pipeline-analytics/debug/debug-track-analysis.ts
|
|
7323
|
+
/** A track is "short" below this — the pipeline rarely produces a full media set. */
|
|
7324
|
+
var SHORT_TRACK_MS = 2e3;
|
|
7325
|
+
/** Below this, the label the track settled on is weak enough to be worth saying. */
|
|
7326
|
+
var LOW_BEST_SCORE = .5;
|
|
7327
|
+
/** Duration in ms. Never negative — a clock that went backwards reads as 0. */
|
|
7328
|
+
function trackDurationMs(facts) {
|
|
7329
|
+
return Math.max(0, facts.lastSeen - facts.firstSeen);
|
|
7330
|
+
}
|
|
7331
|
+
function hasKind(media, kind) {
|
|
7332
|
+
return media.some((m) => m.kind === kind);
|
|
7333
|
+
}
|
|
7334
|
+
function latestMediaAt(media) {
|
|
7335
|
+
let latest = null;
|
|
7336
|
+
for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
|
|
7337
|
+
return latest;
|
|
7338
|
+
}
|
|
7339
|
+
function finding(code, severity, detail) {
|
|
7340
|
+
return {
|
|
7341
|
+
code,
|
|
7342
|
+
severity,
|
|
7343
|
+
detail
|
|
7344
|
+
};
|
|
7345
|
+
}
|
|
7346
|
+
/**
|
|
7347
|
+
* Analyse one track. Pure — same inventory in, same findings out.
|
|
7348
|
+
*
|
|
7349
|
+
* Order is stable and roughly "worst first": the media the whole rest of the
|
|
7350
|
+
* system reads, then the track's own substance, then the softer signals.
|
|
7351
|
+
*/
|
|
7352
|
+
function analyseDebugTrack(inventory) {
|
|
7353
|
+
const { facts, media, eventCount } = inventory;
|
|
7354
|
+
const durationMs = trackDurationMs(facts);
|
|
7355
|
+
const closed = !facts.active;
|
|
7356
|
+
const out = [];
|
|
7357
|
+
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)."));
|
|
7358
|
+
else if (closed) {
|
|
7359
|
+
if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
|
|
7360
|
+
if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
|
|
7361
|
+
if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
|
|
7362
|
+
if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
|
|
7363
|
+
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."));
|
|
7364
|
+
}
|
|
7365
|
+
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.`));
|
|
7366
|
+
if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
|
|
7367
|
+
if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
|
|
7368
|
+
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.`));
|
|
7369
|
+
if (closed && hasKind(media, "lastFrame")) {
|
|
7370
|
+
const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
|
|
7371
|
+
const lag = facts.lastSeen - lastFrameAt;
|
|
7372
|
+
if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
|
|
7373
|
+
}
|
|
7374
|
+
if (closed && durationMs >= 1e4 && media.length > 0) {
|
|
7375
|
+
const latest = latestMediaAt(media);
|
|
7376
|
+
if (latest !== null) {
|
|
7377
|
+
const covered = (latest - facts.firstSeen) / durationMs;
|
|
7378
|
+
if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
|
|
7379
|
+
}
|
|
7380
|
+
}
|
|
7381
|
+
if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
|
|
7382
|
+
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)}.`));
|
|
7383
|
+
return out;
|
|
7384
|
+
}
|
|
7385
|
+
/** Fold an inventory into the report shape, findings included. */
|
|
7386
|
+
function buildDebugTrackReport(inventory) {
|
|
7387
|
+
const { facts, media, eventCount } = inventory;
|
|
7388
|
+
const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
|
|
7389
|
+
return {
|
|
7390
|
+
trackId: facts.trackId,
|
|
7391
|
+
deviceId: facts.deviceId,
|
|
7392
|
+
className: facts.className,
|
|
7393
|
+
...facts.label === void 0 ? {} : { label: facts.label },
|
|
7394
|
+
firstSeen: facts.firstSeen,
|
|
7395
|
+
lastSeen: facts.lastSeen,
|
|
7396
|
+
durationMs: trackDurationMs(facts),
|
|
7397
|
+
active: facts.active,
|
|
7398
|
+
mediaCount: media.length,
|
|
7399
|
+
mediaKinds: kinds,
|
|
7400
|
+
eventCount,
|
|
7401
|
+
positionCount: facts.positionCount,
|
|
7402
|
+
findings: analyseDebugTrack(inventory)
|
|
7403
|
+
};
|
|
7404
|
+
}
|
|
7405
|
+
//#endregion
|
|
7406
|
+
//#region src/pipeline-analytics/debug/debug-service.ts
|
|
7407
|
+
/**
|
|
7408
|
+
* The three media owners one track's files hang off.
|
|
7409
|
+
*
|
|
7410
|
+
* A track's face and plate crops are NOT owned by `track:<trackId>` — they live
|
|
7411
|
+
* under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
|
|
7412
|
+
* first owner reports MISSING_BEST_CROP on every track that produced a face,
|
|
7413
|
+
* which is the opposite of the truth.
|
|
7414
|
+
*/
|
|
7415
|
+
var MEDIA_OWNERS = [
|
|
7416
|
+
{
|
|
7417
|
+
kind: "track",
|
|
7418
|
+
prefix: ""
|
|
7419
|
+
},
|
|
7420
|
+
{
|
|
7421
|
+
kind: "face",
|
|
7422
|
+
prefix: "face-"
|
|
7423
|
+
},
|
|
7424
|
+
{
|
|
7425
|
+
kind: "plate",
|
|
7426
|
+
prefix: "plate-"
|
|
7427
|
+
}
|
|
7428
|
+
];
|
|
7429
|
+
function toFacts(track) {
|
|
7430
|
+
return {
|
|
7431
|
+
trackId: track.trackId,
|
|
7432
|
+
deviceId: track.deviceId,
|
|
7433
|
+
className: track.className,
|
|
7434
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7435
|
+
...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
|
|
7436
|
+
firstSeen: track.firstSeen,
|
|
7437
|
+
lastSeen: track.lastSeen,
|
|
7438
|
+
active: track.active,
|
|
7439
|
+
positionCount: track.positions.length,
|
|
7440
|
+
snapshotCount: track.snapshots.length,
|
|
7441
|
+
...track.importance === void 0 ? {} : { importance: track.importance },
|
|
7442
|
+
...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
|
|
7443
|
+
};
|
|
7444
|
+
}
|
|
7445
|
+
function toListItem(track) {
|
|
7446
|
+
return {
|
|
7447
|
+
trackId: track.trackId,
|
|
7448
|
+
deviceId: track.deviceId,
|
|
7449
|
+
className: track.className,
|
|
7450
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7451
|
+
firstSeen: track.firstSeen,
|
|
7452
|
+
lastSeen: track.lastSeen,
|
|
7453
|
+
durationMs: trackDurationMs(toFacts(track)),
|
|
7454
|
+
active: track.active
|
|
7455
|
+
};
|
|
7456
|
+
}
|
|
7457
|
+
/** Which cameras to sweep — the caller's list, else every camera with tracks. */
|
|
7458
|
+
async function resolveDeviceIds(deps, requested) {
|
|
7459
|
+
if (requested !== void 0 && requested.length > 0) return requested;
|
|
7460
|
+
return deps.listDeviceIds();
|
|
7461
|
+
}
|
|
7462
|
+
/**
|
|
7463
|
+
* The marked set, newest first per camera.
|
|
7464
|
+
*
|
|
7465
|
+
* One indexed query per camera and nothing else — no media, no events, and no
|
|
7466
|
+
* trajectory: `listDebug` reads `slim` rows, so nothing here parses the
|
|
7467
|
+
* `positions` JSON. The counts an operator wants (media, events, positions) come
|
|
7468
|
+
* from `debug.analyze`, per track, by id.
|
|
7469
|
+
*
|
|
7470
|
+
* A camera that hits `limit` sets `truncated`, because a list that silently
|
|
7471
|
+
* stops at 200 is how an operator concludes a mark did not take.
|
|
7472
|
+
*/
|
|
7473
|
+
async function listDebugTracks(deps, input) {
|
|
7474
|
+
const limit = input.limit ?? 200;
|
|
7475
|
+
const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
|
|
7476
|
+
const tracks = [];
|
|
7477
|
+
let truncated = false;
|
|
7478
|
+
for (const deviceId of deviceIds) {
|
|
7479
|
+
const rows = await deps.listDebugTracks(deviceId, limit);
|
|
7480
|
+
if (rows.length >= limit) {
|
|
7481
|
+
truncated = true;
|
|
7482
|
+
deps.logger.warn("debug listTracks hit the per-camera cap", {
|
|
7483
|
+
tags: { deviceId },
|
|
7484
|
+
meta: {
|
|
7485
|
+
deviceId,
|
|
7486
|
+
limit
|
|
7487
|
+
}
|
|
7488
|
+
});
|
|
7489
|
+
}
|
|
7490
|
+
for (const row of rows) tracks.push(toListItem(row));
|
|
7491
|
+
}
|
|
7492
|
+
return {
|
|
7493
|
+
tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
|
|
7494
|
+
truncated
|
|
7495
|
+
};
|
|
7496
|
+
}
|
|
7497
|
+
/**
|
|
7498
|
+
* Gather one track's whole inventory: three media queries plus its events.
|
|
7499
|
+
*
|
|
7500
|
+
* The events are RETURNED as well as counted — the bundle writes them to
|
|
7501
|
+
* `events.json`, and fetching them a second time there would double the query
|
|
7502
|
+
* count of an export for a number the caller already holds.
|
|
7503
|
+
*/
|
|
7504
|
+
async function gatherTrack(deps, track) {
|
|
7505
|
+
const media = [];
|
|
7506
|
+
for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
|
|
7507
|
+
const events = await deps.listEvents(track.trackId);
|
|
7508
|
+
return {
|
|
7509
|
+
inventory: {
|
|
7510
|
+
facts: toFacts(track),
|
|
7511
|
+
media,
|
|
7512
|
+
eventCount: events.length
|
|
7513
|
+
},
|
|
7514
|
+
events
|
|
7515
|
+
};
|
|
7516
|
+
}
|
|
7517
|
+
/**
|
|
7518
|
+
* Analyse the named tracks.
|
|
7519
|
+
*
|
|
7520
|
+
* An id that no longer resolves is reported in `vanished` rather than dropped: a
|
|
7521
|
+
* `debug` mark does not pin retention (D81), so a track CAN disappear between
|
|
7522
|
+
* the list and the analysis, and the tab has to be able to say so instead of
|
|
7523
|
+
* quietly showing fewer rows than it listed.
|
|
7524
|
+
*/
|
|
7525
|
+
async function analyzeDebugTracks(deps, input) {
|
|
7526
|
+
const reports = [];
|
|
7527
|
+
const vanished = [];
|
|
7528
|
+
for (const trackId of input.trackIds) {
|
|
7529
|
+
const track = await deps.getTrack(trackId);
|
|
7530
|
+
if (track === null) {
|
|
7531
|
+
vanished.push(trackId);
|
|
7532
|
+
deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
|
|
7533
|
+
continue;
|
|
7534
|
+
}
|
|
7535
|
+
const gathered = await gatherTrack(deps, track);
|
|
7536
|
+
reports.push(buildDebugTrackReport(gathered.inventory));
|
|
7537
|
+
}
|
|
7538
|
+
return {
|
|
7539
|
+
reports,
|
|
7540
|
+
vanished
|
|
7541
|
+
};
|
|
7542
|
+
}
|
|
7543
|
+
/**
|
|
7544
|
+
* Plan the bundle — INDEX READS ONLY, no blob touched.
|
|
7545
|
+
*
|
|
7546
|
+
* The plan is what the manifest is built from and what the handler streams
|
|
7547
|
+
* against, so it must be produced in one pass: a plan that re-queried while
|
|
7548
|
+
* streaming could describe a set the archive does not contain.
|
|
7549
|
+
*
|
|
7550
|
+
* **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
|
|
7551
|
+
* index rows; exporting is every blob those rows point at, base64-decoded and
|
|
7552
|
+
* pushed through the hub's one thread. An unbounded "export all" is a
|
|
7553
|
+
* self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
|
|
7554
|
+
* rather than quietly shipping less than the button offered.
|
|
7555
|
+
*
|
|
7556
|
+
* Each row is re-read at full projection: `listDebugTracks` deliberately returns
|
|
7557
|
+
* `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
|
|
7558
|
+
* every track and write an empty `positions` array into `track.json`.
|
|
7559
|
+
*/
|
|
7560
|
+
async function buildDebugBundlePlan(deps, requestedDeviceIds) {
|
|
7561
|
+
const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
|
|
7562
|
+
const tracks = [];
|
|
7563
|
+
const covered = [];
|
|
7564
|
+
let capped = false;
|
|
7565
|
+
for (const deviceId of deviceIds) {
|
|
7566
|
+
if (tracks.length >= 25) {
|
|
7567
|
+
capped = true;
|
|
7568
|
+
break;
|
|
7569
|
+
}
|
|
7570
|
+
const rows = await deps.listDebugTracks(deviceId, 200);
|
|
7571
|
+
if (rows.length > 0) covered.push(deviceId);
|
|
7572
|
+
for (const row of rows) {
|
|
7573
|
+
if (tracks.length >= 25) {
|
|
7574
|
+
capped = true;
|
|
7575
|
+
break;
|
|
7576
|
+
}
|
|
7577
|
+
const full = await deps.getTrack(row.trackId) ?? row;
|
|
7578
|
+
const { inventory, events } = await gatherTrack(deps, full);
|
|
7579
|
+
tracks.push({
|
|
7580
|
+
report: buildDebugTrackReport(inventory),
|
|
7581
|
+
track: full,
|
|
7582
|
+
events,
|
|
7583
|
+
media: planTrackMediaNames(inventory.media)
|
|
7584
|
+
});
|
|
7585
|
+
}
|
|
7586
|
+
}
|
|
7587
|
+
if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
|
|
7588
|
+
limit: 25,
|
|
7589
|
+
deviceIds: covered
|
|
7590
|
+
} });
|
|
7591
|
+
return {
|
|
7592
|
+
generatedAt: deps.now(),
|
|
7593
|
+
deviceIds: covered,
|
|
7594
|
+
tracks,
|
|
7595
|
+
capped
|
|
7596
|
+
};
|
|
7597
|
+
}
|
|
6720
7598
|
//#endregion
|
|
6721
7599
|
//#region src/pipeline-analytics/embedding-actions.ts
|
|
6722
7600
|
/**
|
|
@@ -10963,6 +11841,13 @@ function eventsToClips(events, pad) {
|
|
|
10963
11841
|
...events.audio.map((e) => make(e, "audio"))
|
|
10964
11842
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
10965
11843
|
}
|
|
11844
|
+
/** Keep only clips whose window overlaps at least one recorded range — a clip
|
|
11845
|
+
* is a window over footage, so a window with nothing behind it is unplayable
|
|
11846
|
+
* and must not be listed (a camera that never recorded showed 15 dead audio
|
|
11847
|
+
* "clips" in the viewer ribbon, every tap a 500). Pure. */
|
|
11848
|
+
function filterClipsByFootage(clips, ranges) {
|
|
11849
|
+
return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
|
|
11850
|
+
}
|
|
10966
11851
|
function createVideoclipsProvider(deps) {
|
|
10967
11852
|
return {
|
|
10968
11853
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
@@ -10972,7 +11857,20 @@ function createVideoclipsProvider(deps) {
|
|
|
10972
11857
|
until,
|
|
10973
11858
|
limit
|
|
10974
11859
|
}), deps.defaultPad);
|
|
10975
|
-
|
|
11860
|
+
let filtered = clips;
|
|
11861
|
+
if (deps.fetchAvailability) try {
|
|
11862
|
+
filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
|
|
11863
|
+
deviceId,
|
|
11864
|
+
since,
|
|
11865
|
+
until
|
|
11866
|
+
}));
|
|
11867
|
+
} catch (err) {
|
|
11868
|
+
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
11869
|
+
deviceId,
|
|
11870
|
+
error: err instanceof Error ? err.message : String(err)
|
|
11871
|
+
});
|
|
11872
|
+
}
|
|
11873
|
+
return limit != null ? filtered.slice(0, limit) : filtered;
|
|
10976
11874
|
},
|
|
10977
11875
|
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
10978
11876
|
const parsed = parseClipId(clipId);
|
|
@@ -10985,6 +11883,230 @@ function createVideoclipsProvider(deps) {
|
|
|
10985
11883
|
}
|
|
10986
11884
|
};
|
|
10987
11885
|
}
|
|
11886
|
+
/**
|
|
11887
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
11888
|
+
*
|
|
11889
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
11890
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
11891
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
11892
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
11893
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
11894
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
11895
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
11896
|
+
* The re-encode is smaller than what it replaces.
|
|
11897
|
+
*
|
|
11898
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
11899
|
+
* clip, one rate, two containers.
|
|
11900
|
+
*/
|
|
11901
|
+
var DEFAULT_SPEED = 4;
|
|
11902
|
+
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
11903
|
+
* at or below 1080p is an identity scale and the broker copies it. */
|
|
11904
|
+
var RING_MP4_MAX_WIDTH = 1920;
|
|
11905
|
+
/** MP4 keeps the source cadence; this only bounds the muxer. The cap caps at 15. */
|
|
11906
|
+
var RING_MP4_FPS = 15;
|
|
11907
|
+
var NO_MEDIA = {
|
|
11908
|
+
mp4: null,
|
|
11909
|
+
gif: null,
|
|
11910
|
+
source: "none",
|
|
11911
|
+
startOffsetMs: null,
|
|
11912
|
+
endOffsetMs: null,
|
|
11913
|
+
profile: null,
|
|
11914
|
+
video: null
|
|
11915
|
+
};
|
|
11916
|
+
var EventMediaService = class {
|
|
11917
|
+
deps;
|
|
11918
|
+
constructor(deps) {
|
|
11919
|
+
this.deps = deps;
|
|
11920
|
+
}
|
|
11921
|
+
/**
|
|
11922
|
+
* Cut this event. Never throws — a notification that lost its media is still
|
|
11923
|
+
* a notification, and every branch that drops it logs why.
|
|
11924
|
+
*/
|
|
11925
|
+
async cut(request) {
|
|
11926
|
+
if (!request.wantMp4 && !request.wantGif) return NO_MEDIA;
|
|
11927
|
+
const produced = await this.produce(request);
|
|
11928
|
+
if (produced !== null) return produced;
|
|
11929
|
+
return this.fallbackToRing(request);
|
|
11930
|
+
}
|
|
11931
|
+
async produce(request) {
|
|
11932
|
+
const deviceId = request.deviceId;
|
|
11933
|
+
const log = this.deps.logger;
|
|
11934
|
+
const kinds = [];
|
|
11935
|
+
if (request.wantMp4) kinds.push("mp4");
|
|
11936
|
+
if (request.wantGif) kinds.push("gif");
|
|
11937
|
+
let production;
|
|
11938
|
+
try {
|
|
11939
|
+
production = await this.deps.produce({
|
|
11940
|
+
deviceId,
|
|
11941
|
+
aroundMs: request.aroundMs,
|
|
11942
|
+
preSeconds: request.preRollSec,
|
|
11943
|
+
postSeconds: request.postRollSec,
|
|
11944
|
+
kinds,
|
|
11945
|
+
gifMaxWidth: 640,
|
|
11946
|
+
gifFps: 12,
|
|
11947
|
+
gifSpeed: 4,
|
|
11948
|
+
speed: request.speed ?? DEFAULT_SPEED,
|
|
11949
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
11950
|
+
});
|
|
11951
|
+
} catch (err) {
|
|
11952
|
+
log.warn("nc media: the broker could not produce this event — falling back to the clip ring", {
|
|
11953
|
+
tags: { deviceId },
|
|
11954
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
11955
|
+
});
|
|
11956
|
+
return null;
|
|
11957
|
+
}
|
|
11958
|
+
const mp4 = await this.redeem(deviceId, production.media, "mp4");
|
|
11959
|
+
const gif = await this.redeem(deviceId, production.media, "gif");
|
|
11960
|
+
if (mp4 === null && gif === null) {
|
|
11961
|
+
log.warn("nc media: the production carried no artifact — falling back to the clip ring", {
|
|
11962
|
+
tags: { deviceId },
|
|
11963
|
+
meta: {
|
|
11964
|
+
kinds: kinds.join(","),
|
|
11965
|
+
profile: production.profile
|
|
11966
|
+
}
|
|
11967
|
+
});
|
|
11968
|
+
return null;
|
|
11969
|
+
}
|
|
11970
|
+
const startOffsetMs = production.coverage.fromTs - request.aroundMs;
|
|
11971
|
+
const endOffsetMs = production.coverage.toTs - request.aroundMs;
|
|
11972
|
+
log.info("nc media: one production, every attachment", {
|
|
11973
|
+
tags: { deviceId },
|
|
11974
|
+
meta: {
|
|
11975
|
+
profile: production.profile,
|
|
11976
|
+
video: production.video,
|
|
11977
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
11978
|
+
gifBytes: gif?.byteLength ?? null,
|
|
11979
|
+
startOffsetMs,
|
|
11980
|
+
endOffsetMs,
|
|
11981
|
+
sharedWindow: true
|
|
11982
|
+
}
|
|
11983
|
+
});
|
|
11984
|
+
return {
|
|
11985
|
+
mp4,
|
|
11986
|
+
gif,
|
|
11987
|
+
source: "produced",
|
|
11988
|
+
startOffsetMs,
|
|
11989
|
+
endOffsetMs,
|
|
11990
|
+
profile: production.profile,
|
|
11991
|
+
video: production.video
|
|
11992
|
+
};
|
|
11993
|
+
}
|
|
11994
|
+
/**
|
|
11995
|
+
* Redeem one artifact handle at the node that produced it.
|
|
11996
|
+
*
|
|
11997
|
+
* `null` is not an error here — a production simply may not carry the kind
|
|
11998
|
+
* (a gif whose derive failed says so on the broker's own log line). A handle
|
|
11999
|
+
* that FAILS to redeem is different and is logged, because it means the bytes
|
|
12000
|
+
* existed and did not arrive.
|
|
12001
|
+
*/
|
|
12002
|
+
async redeem(deviceId, media, kind) {
|
|
12003
|
+
const artifact = media.find((m) => m.kind === kind);
|
|
12004
|
+
if (artifact === void 0) return null;
|
|
12005
|
+
try {
|
|
12006
|
+
const res = await this.deps.fetch(artifact.handle, artifact.nodeId);
|
|
12007
|
+
if (res === null) {
|
|
12008
|
+
this.deps.logger.warn("nc media: an artifact handle expired before it could be fetched", {
|
|
12009
|
+
tags: { deviceId },
|
|
12010
|
+
meta: {
|
|
12011
|
+
kind,
|
|
12012
|
+
handle: artifact.handle,
|
|
12013
|
+
nodeId: artifact.nodeId
|
|
12014
|
+
}
|
|
12015
|
+
});
|
|
12016
|
+
return null;
|
|
12017
|
+
}
|
|
12018
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
12019
|
+
if (buf.byteLength === 0) return null;
|
|
12020
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
12021
|
+
bytes.set(buf);
|
|
12022
|
+
return bytes;
|
|
12023
|
+
} catch (err) {
|
|
12024
|
+
this.deps.logger.warn("nc media: fetching an artifact failed — that attachment is dropped", {
|
|
12025
|
+
tags: { deviceId },
|
|
12026
|
+
meta: {
|
|
12027
|
+
kind,
|
|
12028
|
+
handle: artifact.handle,
|
|
12029
|
+
nodeId: artifact.nodeId,
|
|
12030
|
+
error: err instanceof Error ? err.message : String(err)
|
|
12031
|
+
}
|
|
12032
|
+
});
|
|
12033
|
+
return null;
|
|
12034
|
+
}
|
|
12035
|
+
}
|
|
12036
|
+
/**
|
|
12037
|
+
* The pre-`produceEventMedia` path: `renderPreBufferClip`, once per container.
|
|
12038
|
+
*
|
|
12039
|
+
* Weaker than a production and deliberately so — the coherence here rests on
|
|
12040
|
+
* the two calls being given IDENTICAL parameters rather than on there being
|
|
12041
|
+
* one render. That is defensible because the ring only ever grows forward and
|
|
12042
|
+
* `windowAround` selects by wall clock around the same instant, so two calls
|
|
12043
|
+
* seconds apart choose the same packets; the one thing that could differ is
|
|
12044
|
+
* the rendition, so the profile is pinned to whatever the FIRST call actually
|
|
12045
|
+
* used and handed to the second.
|
|
12046
|
+
*
|
|
12047
|
+
* It exists for one reason: a hub whose `@camstack/server` predates the
|
|
12048
|
+
* production method must not stop attaching footage, and three of the four
|
|
12049
|
+
* live rules on this install ask for a gif and nothing else.
|
|
12050
|
+
*/
|
|
12051
|
+
async fallbackToRing(request) {
|
|
12052
|
+
const deviceId = request.deviceId;
|
|
12053
|
+
const speed = request.speed ?? DEFAULT_SPEED;
|
|
12054
|
+
const window = {
|
|
12055
|
+
deviceId,
|
|
12056
|
+
aroundMs: request.aroundMs,
|
|
12057
|
+
preRollSec: request.preRollSec,
|
|
12058
|
+
postRollSec: request.postRollSec,
|
|
12059
|
+
speed,
|
|
12060
|
+
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
12061
|
+
};
|
|
12062
|
+
try {
|
|
12063
|
+
const mp4 = request.wantMp4 ? await this.deps.renderRingClip({
|
|
12064
|
+
...window,
|
|
12065
|
+
format: "mp4",
|
|
12066
|
+
maxWidth: RING_MP4_MAX_WIDTH,
|
|
12067
|
+
fps: RING_MP4_FPS
|
|
12068
|
+
}) : null;
|
|
12069
|
+
const gif = request.wantGif ? await this.deps.renderRingClip({
|
|
12070
|
+
...window,
|
|
12071
|
+
format: "gif",
|
|
12072
|
+
maxWidth: 640,
|
|
12073
|
+
fps: 12 / 4,
|
|
12074
|
+
speed: 4
|
|
12075
|
+
}) : null;
|
|
12076
|
+
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
12077
|
+
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
12078
|
+
tags: { deviceId },
|
|
12079
|
+
meta: { aroundMs: request.aroundMs }
|
|
12080
|
+
});
|
|
12081
|
+
return NO_MEDIA;
|
|
12082
|
+
}
|
|
12083
|
+
this.deps.logger.info("nc media: served by the clip-ring FALLBACK", {
|
|
12084
|
+
tags: { deviceId },
|
|
12085
|
+
meta: {
|
|
12086
|
+
mp4Bytes: mp4?.byteLength ?? null,
|
|
12087
|
+
gifBytes: gif?.byteLength ?? null,
|
|
12088
|
+
profile: request.profile ?? null,
|
|
12089
|
+
sharedWindow: "by-parameters"
|
|
12090
|
+
}
|
|
12091
|
+
});
|
|
12092
|
+
return {
|
|
12093
|
+
mp4: mp4 !== null && mp4.byteLength > 0 ? mp4 : null,
|
|
12094
|
+
gif: gif !== null && gif.byteLength > 0 ? gif : null,
|
|
12095
|
+
source: "ring-fallback",
|
|
12096
|
+
startOffsetMs: -Math.max(0, request.preRollSec) * 1e3,
|
|
12097
|
+
endOffsetMs: Math.max(0, request.postRollSec) * 1e3,
|
|
12098
|
+
profile: request.profile ?? null,
|
|
12099
|
+
video: null
|
|
12100
|
+
};
|
|
12101
|
+
} catch (err) {
|
|
12102
|
+
this.deps.logger.warn("nc media: the clip ring failed too — this event ships no footage", {
|
|
12103
|
+
tags: { deviceId },
|
|
12104
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
12105
|
+
});
|
|
12106
|
+
return NO_MEDIA;
|
|
12107
|
+
}
|
|
12108
|
+
}
|
|
12109
|
+
};
|
|
10988
12110
|
//#endregion
|
|
10989
12111
|
//#region src/shared/frame/crop-extractor.ts
|
|
10990
12112
|
/**
|
|
@@ -21873,6 +22995,10 @@ var TRACKS_COLUMNS = [
|
|
|
21873
22995
|
name: "debug",
|
|
21874
22996
|
type: "BOOLEAN"
|
|
21875
22997
|
},
|
|
22998
|
+
{
|
|
22999
|
+
name: "hasFace",
|
|
23000
|
+
type: "BOOLEAN"
|
|
23001
|
+
},
|
|
21876
23002
|
{
|
|
21877
23003
|
name: "envMinX",
|
|
21878
23004
|
type: "REAL"
|
|
@@ -21953,7 +23079,8 @@ function cloneTrack(t) {
|
|
|
21953
23079
|
retrainStatus: t.retrainStatus,
|
|
21954
23080
|
markForTrain: t.retrainStatus === "staging"
|
|
21955
23081
|
} : {},
|
|
21956
|
-
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
23082
|
+
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
23083
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
|
|
21957
23084
|
};
|
|
21958
23085
|
}
|
|
21959
23086
|
var TrackStore = class {
|
|
@@ -22316,6 +23443,49 @@ var TrackStore = class {
|
|
|
22316
23443
|
}
|
|
22317
23444
|
}
|
|
22318
23445
|
/**
|
|
23446
|
+
* Record that a face DETECTOR found a face on this track. Set-once, never
|
|
23447
|
+
* cleared, and deliberately NOT part of {@link setFlags}: that method is the
|
|
23448
|
+
* operator's write surface and this is the pipeline's.
|
|
23449
|
+
*
|
|
23450
|
+
* **The signal is detection, not recognition.** The caller is the detail-route
|
|
23451
|
+
* fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
|
|
23452
|
+
* `minFacePx` gate, before the recognizer is consulted, and regardless of
|
|
23453
|
+
* whether face recognition is enabled at all. That is the whole point: the
|
|
23454
|
+
* viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
|
|
23455
|
+
* and a flag that only fired when the recognizer answered would be true
|
|
23456
|
+
* exactly when the badge must not show.
|
|
23457
|
+
*
|
|
23458
|
+
* **Idempotent, and it does not write per detail.** A face detail lands many
|
|
23459
|
+
* times over a track's life. While the track is LIVE the flag is set in RAM
|
|
23460
|
+
* only and rides `persistCompleted` at expiry — one row write, not one per
|
|
23461
|
+
* frame. Once the flag is set, every later call returns immediately.
|
|
23462
|
+
*
|
|
23463
|
+
* The persisted patch is for the LATE detail: enrichment answers routinely
|
|
23464
|
+
* arrive after the track has expired, when there is no RAM entry left and the
|
|
23465
|
+
* row is the only thing to correct. Best-effort — a failed patch costs a badge,
|
|
23466
|
+
* never a track.
|
|
23467
|
+
*/
|
|
23468
|
+
async markFaceDetected(trackId) {
|
|
23469
|
+
const active = this.active.get(trackId);
|
|
23470
|
+
if (active) {
|
|
23471
|
+
if (active.hasFace === true) return;
|
|
23472
|
+
active.hasFace = true;
|
|
23473
|
+
return;
|
|
23474
|
+
}
|
|
23475
|
+
try {
|
|
23476
|
+
await this.store.update.mutate({
|
|
23477
|
+
collection: TRACKS_COLLECTION,
|
|
23478
|
+
id: trackId,
|
|
23479
|
+
data: { hasFace: true }
|
|
23480
|
+
});
|
|
23481
|
+
} catch (err) {
|
|
23482
|
+
this.logger.warn("markFaceDetected persist failed", { meta: {
|
|
23483
|
+
trackId,
|
|
23484
|
+
error: String(err)
|
|
23485
|
+
} });
|
|
23486
|
+
}
|
|
23487
|
+
}
|
|
23488
|
+
/**
|
|
22319
23489
|
* Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
|
|
22320
23490
|
* express.
|
|
22321
23491
|
*
|
|
@@ -22529,6 +23699,71 @@ var TrackStore = class {
|
|
|
22529
23699
|
}
|
|
22530
23700
|
}
|
|
22531
23701
|
/**
|
|
23702
|
+
* The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
|
|
23703
|
+
*
|
|
23704
|
+
* Two things about this query are load-bearing.
|
|
23705
|
+
*
|
|
23706
|
+
* **`where: { debug: true }` matches only the explicit `true`.** The column is
|
|
23707
|
+
* nullable with no default (unlike `retrainStatus`), so a row that was never
|
|
23708
|
+
* marked holds NULL and positive equality skips it — which is exactly the set
|
|
23709
|
+
* wanted here, and the reason the column was left nullable in the first place.
|
|
23710
|
+
*
|
|
23711
|
+
* **The result is re-checked in memory.** `compileFilter` DROPS a predicate
|
|
23712
|
+
* whose column does not exist in SQLite — an older hub whose `declareCollection`
|
|
23713
|
+
* fingerprint never re-ran would answer this query with EVERY track on the
|
|
23714
|
+
* camera, silently, and the tab would present the whole history as
|
|
23715
|
+
* operator-marked. A skipped predicate is indistinguishable from a matching one
|
|
23716
|
+
* at the wire, so the guard is the only thing that can tell them apart.
|
|
23717
|
+
*
|
|
23718
|
+
* **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
|
|
23719
|
+
* JSON column averaging ~10 KB a row, and `'full'` parses it for every row
|
|
23720
|
+
* returned. That is affordable for a handful of marked tracks and ruinous in
|
|
23721
|
+
* the case this method already defends against: if the predicate is dropped,
|
|
23722
|
+
* the store hands back `limit` rows PER CAMERA and the trajectory of the
|
|
23723
|
+
* camera's whole recent history is parsed before the guard below throws it
|
|
23724
|
+
* away. The cheap question comes first (D56) — the list carries no trajectory,
|
|
23725
|
+
* and the per-track analysis reads the one row it needs through
|
|
23726
|
+
* `getPersistedByTrackId`.
|
|
23727
|
+
*/
|
|
23728
|
+
async listDebug(deviceId, limit) {
|
|
23729
|
+
try {
|
|
23730
|
+
const tracks = (await this.store.query.query({
|
|
23731
|
+
collection: TRACKS_COLLECTION,
|
|
23732
|
+
filter: {
|
|
23733
|
+
where: {
|
|
23734
|
+
deviceId,
|
|
23735
|
+
debug: true
|
|
23736
|
+
},
|
|
23737
|
+
orderBy: {
|
|
23738
|
+
field: "lastSeen",
|
|
23739
|
+
direction: "desc"
|
|
23740
|
+
},
|
|
23741
|
+
limit
|
|
23742
|
+
}
|
|
23743
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
|
|
23744
|
+
const marked = tracks.filter((t) => t.debug === true);
|
|
23745
|
+
if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
|
|
23746
|
+
tags: { deviceId },
|
|
23747
|
+
meta: {
|
|
23748
|
+
deviceId,
|
|
23749
|
+
returned: tracks.length,
|
|
23750
|
+
marked: marked.length
|
|
23751
|
+
}
|
|
23752
|
+
});
|
|
23753
|
+
return marked;
|
|
23754
|
+
} catch (err) {
|
|
23755
|
+
this.logger.warn("TrackStore.listDebug failed", {
|
|
23756
|
+
tags: { deviceId },
|
|
23757
|
+
meta: {
|
|
23758
|
+
deviceId,
|
|
23759
|
+
limit,
|
|
23760
|
+
error: String(err)
|
|
23761
|
+
}
|
|
23762
|
+
});
|
|
23763
|
+
return [];
|
|
23764
|
+
}
|
|
23765
|
+
}
|
|
23766
|
+
/**
|
|
22532
23767
|
* How many of this device's tracks are STAGING — optionally only those older
|
|
22533
23768
|
* than a cutoff, i.e. exactly the rows a sweep just declined to touch.
|
|
22534
23769
|
*
|
|
@@ -22935,6 +24170,7 @@ var TrackStore = class {
|
|
|
22935
24170
|
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
22936
24171
|
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
22937
24172
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
24173
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
22938
24174
|
...envelope !== null && dims !== void 0 ? {
|
|
22939
24175
|
envMinX: envelope.minX,
|
|
22940
24176
|
envMinY: envelope.minY,
|
|
@@ -22969,6 +24205,7 @@ var TrackStore = class {
|
|
|
22969
24205
|
const audioLabels = data["audioLabels"];
|
|
22970
24206
|
const retrainParsed = RetrainStatusSchema.safeParse(data["retrainStatus"]);
|
|
22971
24207
|
const debug = data["debug"];
|
|
24208
|
+
const hasFace = data["hasFace"];
|
|
22972
24209
|
const envMinX = data["envMinX"];
|
|
22973
24210
|
const envMinY = data["envMinY"];
|
|
22974
24211
|
const envMaxX = data["envMaxX"];
|
|
@@ -23004,6 +24241,7 @@ var TrackStore = class {
|
|
|
23004
24241
|
markForTrain: retrainParsed.data === "staging"
|
|
23005
24242
|
} : {},
|
|
23006
24243
|
...typeof debug === "boolean" ? { debug } : {},
|
|
24244
|
+
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
23007
24245
|
...envelope !== null ? { envelope } : {}
|
|
23008
24246
|
};
|
|
23009
24247
|
}
|
|
@@ -25374,6 +26612,31 @@ var LinkedCamerasCache = class {
|
|
|
25374
26612
|
}
|
|
25375
26613
|
};
|
|
25376
26614
|
/**
|
|
26615
|
+
* THE camera-set derivation for a sensor-cap state change — one function, so
|
|
26616
|
+
* every consumer of a device-event attributes it to the same cameras.
|
|
26617
|
+
*
|
|
26618
|
+
* It is shared on purpose. The self-camera half (a doorbell wrapper bound on
|
|
26619
|
+
* camera 615 makes 615 both source and subject) was added to the history-row
|
|
26620
|
+
* ingest on 2026-08-07 and NOT to the synthetic-track projection beside it,
|
|
26621
|
+
* which kept resolving `camerasFor` alone. The result: camera 615's real
|
|
26622
|
+
* doorbell press on 2026-08-08 06:59Z produced a `SensorEvent` row and no
|
|
26623
|
+
* timeline marker, for weeks, with the divergence invisible in both codepaths.
|
|
26624
|
+
* Two derivations of "which cameras" is one too many.
|
|
26625
|
+
*/
|
|
26626
|
+
async function resolveSensorEventCameras(cache, data) {
|
|
26627
|
+
const linkedCameraIds = await cache.camerasFor(data.deviceId);
|
|
26628
|
+
const isCamera = await cache.isCamera(data.deviceId);
|
|
26629
|
+
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
26630
|
+
if (cameraIds.length === 0) return {
|
|
26631
|
+
cameraIds,
|
|
26632
|
+
dropReason: isCamera ? "camera-native-cap" : "no-camera"
|
|
26633
|
+
};
|
|
26634
|
+
return {
|
|
26635
|
+
cameraIds,
|
|
26636
|
+
dropReason: null
|
|
26637
|
+
};
|
|
26638
|
+
}
|
|
26639
|
+
/**
|
|
25377
26640
|
* One `DeviceStateChanged` → N history rows, one per camera the event belongs
|
|
25378
26641
|
* to: the emitting device's LINKED cameras, plus the emitting device ITSELF
|
|
25379
26642
|
* when it is a camera carrying the sensor cap (a doorbell wrapper bound on
|
|
@@ -25386,11 +26649,9 @@ var LinkedCamerasCache = class {
|
|
|
25386
26649
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
25387
26650
|
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
25388
26651
|
if (descriptor === void 0) return 0;
|
|
25389
|
-
const
|
|
25390
|
-
|
|
25391
|
-
|
|
25392
|
-
if (cameraIds.length === 0) {
|
|
25393
|
-
deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
|
|
26652
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
26653
|
+
if (dropReason !== null) {
|
|
26654
|
+
deps.onDropped?.(dropReason, descriptor.kind);
|
|
25394
26655
|
return 0;
|
|
25395
26656
|
}
|
|
25396
26657
|
const slice = data.slice;
|
|
@@ -26129,6 +27390,70 @@ var EventMediaDispatcher = class {
|
|
|
26129
27390
|
}
|
|
26130
27391
|
}
|
|
26131
27392
|
};
|
|
27393
|
+
/**
|
|
27394
|
+
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
27395
|
+
* synthetic track per camera the event belongs to. Returns the number of
|
|
27396
|
+
* markers that landed. Best-effort (telemetry, D8) — a materializer that
|
|
27397
|
+
* throws propagates to the caller, which logs it.
|
|
27398
|
+
*/
|
|
27399
|
+
async function projectSensorMarkers(deps, data, timestamp) {
|
|
27400
|
+
const descriptor = EVENT_KIND_BY_CAP[data.capName];
|
|
27401
|
+
if (descriptor === void 0) {
|
|
27402
|
+
deps.onSkipped({
|
|
27403
|
+
cameraId: null,
|
|
27404
|
+
sourceDeviceId: data.deviceId,
|
|
27405
|
+
capName: data.capName,
|
|
27406
|
+
kind: null,
|
|
27407
|
+
reason: "unmapped-cap"
|
|
27408
|
+
});
|
|
27409
|
+
return 0;
|
|
27410
|
+
}
|
|
27411
|
+
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
27412
|
+
if (dropReason !== null) {
|
|
27413
|
+
deps.onSkipped({
|
|
27414
|
+
cameraId: null,
|
|
27415
|
+
sourceDeviceId: data.deviceId,
|
|
27416
|
+
capName: data.capName,
|
|
27417
|
+
kind: descriptor.kind,
|
|
27418
|
+
reason: dropReason
|
|
27419
|
+
});
|
|
27420
|
+
return 0;
|
|
27421
|
+
}
|
|
27422
|
+
const producingDeviceName = await deps.producingDeviceName(data.deviceId);
|
|
27423
|
+
let landed = 0;
|
|
27424
|
+
for (const cameraId of cameraIds) {
|
|
27425
|
+
const track = await deps.markers.materialize({
|
|
27426
|
+
cameraId,
|
|
27427
|
+
sourceDeviceId: data.deviceId,
|
|
27428
|
+
source: "sensor",
|
|
27429
|
+
kind: descriptor.kind,
|
|
27430
|
+
timestamp,
|
|
27431
|
+
snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
|
|
27432
|
+
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
27433
|
+
});
|
|
27434
|
+
if (track === null) {
|
|
27435
|
+
deps.onSkipped({
|
|
27436
|
+
cameraId,
|
|
27437
|
+
sourceDeviceId: data.deviceId,
|
|
27438
|
+
capName: data.capName,
|
|
27439
|
+
kind: descriptor.kind,
|
|
27440
|
+
reason: "not-materialized"
|
|
27441
|
+
});
|
|
27442
|
+
continue;
|
|
27443
|
+
}
|
|
27444
|
+
landed++;
|
|
27445
|
+
deps.onProjected({
|
|
27446
|
+
cameraId,
|
|
27447
|
+
sourceDeviceId: data.deviceId,
|
|
27448
|
+
capName: data.capName,
|
|
27449
|
+
kind: descriptor.kind,
|
|
27450
|
+
trackId: track.trackId,
|
|
27451
|
+
selfCamera: cameraId === data.deviceId,
|
|
27452
|
+
hasMedia: track.snapshots.length > 0
|
|
27453
|
+
});
|
|
27454
|
+
}
|
|
27455
|
+
return landed;
|
|
27456
|
+
}
|
|
26132
27457
|
/** JPEG quality for the downscaled full frame — matches the crop path. */
|
|
26133
27458
|
var FULL_FRAME_QUALITY = 80;
|
|
26134
27459
|
/**
|
|
@@ -26239,13 +27564,13 @@ var SyntheticTrackMaterializer = class {
|
|
|
26239
27564
|
* answers: ≤10 s old on a mains camera, and on a battery camera the
|
|
26240
27565
|
* intentionally stale-but-honest frame that costs no wake.
|
|
26241
27566
|
*/
|
|
26242
|
-
async fetchSnapshot(cameraId) {
|
|
27567
|
+
async fetchSnapshot(cameraId, timeoutMs) {
|
|
26243
27568
|
try {
|
|
26244
|
-
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }),
|
|
26245
|
-
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
27569
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
|
|
27570
|
+
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
|
|
26246
27571
|
return snap;
|
|
26247
27572
|
} catch (err) {
|
|
26248
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
|
|
27573
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
|
|
26249
27574
|
return null;
|
|
26250
27575
|
}
|
|
26251
27576
|
}
|
|
@@ -26262,14 +27587,14 @@ var SyntheticTrackMaterializer = class {
|
|
|
26262
27587
|
const trackId = this.makeId();
|
|
26263
27588
|
const ts = input.timestamp;
|
|
26264
27589
|
let mediaKey = null;
|
|
26265
|
-
const snap = await this.fetchSnapshot(input.cameraId);
|
|
27590
|
+
const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
|
|
26266
27591
|
if (snap !== null) try {
|
|
26267
27592
|
const raw = Buffer.from(snap.base64, "base64");
|
|
26268
27593
|
let data = raw;
|
|
26269
27594
|
try {
|
|
26270
27595
|
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
26271
27596
|
} catch (err) {
|
|
26272
|
-
this.deps.onError?.("downscaleSnapshot", err);
|
|
27597
|
+
this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
|
|
26273
27598
|
}
|
|
26274
27599
|
mediaKey = await this.deps.media.put({
|
|
26275
27600
|
deviceId: input.cameraId,
|
|
@@ -26280,7 +27605,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26280
27605
|
data
|
|
26281
27606
|
});
|
|
26282
27607
|
} catch (err) {
|
|
26283
|
-
this.deps.onError?.("snapshotMedia", err);
|
|
27608
|
+
this.deps.onError?.("snapshotMedia", err, input.cameraId);
|
|
26284
27609
|
}
|
|
26285
27610
|
const track = {
|
|
26286
27611
|
trackId,
|
|
@@ -26305,7 +27630,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26305
27630
|
try {
|
|
26306
27631
|
await this.deps.tracks.persistSyntheticTrack(track);
|
|
26307
27632
|
} catch (err) {
|
|
26308
|
-
this.deps.onError?.("persistSyntheticTrack", err);
|
|
27633
|
+
this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
|
|
26309
27634
|
return null;
|
|
26310
27635
|
}
|
|
26311
27636
|
return track;
|
|
@@ -29005,8 +30330,6 @@ var REBUILD_PAGE_SIZE = 200;
|
|
|
29005
30330
|
*/
|
|
29006
30331
|
var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
29007
30332
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
29008
|
-
/** Operator-visible name of the fixed integration. */
|
|
29009
|
-
var NC_INTEGRATION_NAME = "Notification Center";
|
|
29010
30333
|
/**
|
|
29011
30334
|
* The FULL action catalog for this addon.
|
|
29012
30335
|
*
|
|
@@ -29017,7 +30340,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
|
|
|
29017
30340
|
*/
|
|
29018
30341
|
var customActions = {
|
|
29019
30342
|
...ncActions,
|
|
29020
|
-
...embeddingActions
|
|
30343
|
+
...embeddingActions,
|
|
30344
|
+
...debugActions
|
|
29021
30345
|
};
|
|
29022
30346
|
/**
|
|
29023
30347
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -29100,15 +30424,6 @@ var KEY_EVENT_DEFAULT_LIMIT = 50;
|
|
|
29100
30424
|
* Absent / empty / non-string all fall back to the hub default — the exact
|
|
29101
30425
|
* narrowing the old raw read applied inline. */
|
|
29102
30426
|
var PostProcessingNodeIdSchema = string().min(1);
|
|
29103
|
-
/**
|
|
29104
|
-
* Footage-attachment window + geometry, used when the rule states none. The
|
|
29105
|
-
* window is CENTRED on the event, so the recipient sees the approach and what
|
|
29106
|
-
* followed rather than one side of it.
|
|
29107
|
-
*/
|
|
29108
|
-
var NC_FOOTAGE_PRE_ROLL_SEC = 3;
|
|
29109
|
-
var NC_FOOTAGE_POST_ROLL_SEC = 5;
|
|
29110
|
-
var NC_FOOTAGE_MAX_WIDTH = 480;
|
|
29111
|
-
var NC_FOOTAGE_FPS = 5;
|
|
29112
30427
|
/** Per-install HMAC secret behind the signed artifact links (minted once). */
|
|
29113
30428
|
var NcArtifactSecretSchema = string();
|
|
29114
30429
|
var EmbeddingEnabledSchema = boolean();
|
|
@@ -29273,6 +30588,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29273
30588
|
trainingExportDataPlane = null;
|
|
29274
30589
|
/** Handle for the retrain-export data-plane listener (dispose on shutdown). */
|
|
29275
30590
|
retrainExportDataPlane = null;
|
|
30591
|
+
/** Handle for the debug-export data-plane listener (dispose on shutdown). */
|
|
30592
|
+
debugExportDataPlane = null;
|
|
29276
30593
|
/** The retrain curation surface — see `retrain/retrain-service.ts`. Null
|
|
29277
30594
|
* until the stores are built. */
|
|
29278
30595
|
retrain = null;
|
|
@@ -29282,38 +30599,83 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29282
30599
|
* data-plane handle. Null until served / when the facility is absent. */
|
|
29283
30600
|
ncArtifactPlane = null;
|
|
29284
30601
|
ncArtifactDataPlane = null;
|
|
29285
|
-
/** Roadmap 4b: notification clips cut from the live fMP4 fragment seam.
|
|
29286
|
-
* Built on first use — a hub whose rules never ask for an mp4 never
|
|
29287
|
-
* constructs it, and constructing it costs nothing until `renderClip`. */
|
|
29288
|
-
ncSeamClips = null;
|
|
29289
30602
|
/**
|
|
29290
|
-
*
|
|
29291
|
-
* the REASON is always logged with `tags: { deviceId }`.
|
|
30603
|
+
* Roadmap 4b: ONE cut of an event, in every container the rule asked for.
|
|
29292
30604
|
*
|
|
29293
|
-
*
|
|
29294
|
-
*
|
|
29295
|
-
* precisely the thing an operator cannot diagnose from a phone.
|
|
30605
|
+
* Built on first use — a hub whose rules never attach footage never
|
|
30606
|
+
* constructs it, and constructing it costs nothing until `cut`.
|
|
29296
30607
|
*/
|
|
29297
|
-
|
|
29298
|
-
|
|
30608
|
+
ncEventMedia = null;
|
|
30609
|
+
/**
|
|
30610
|
+
* The dispatcher's single media dep.
|
|
30611
|
+
*
|
|
30612
|
+
* ONE call for the gif and the mp4, deliberately: rendering them separately
|
|
30613
|
+
* is what produced a gif around the event and a video seven seconds after it
|
|
30614
|
+
* (D85's `eventToDialMs=7054`). The service picks the camera's best H.264
|
|
30615
|
+
* slot, asks the broker's own prebuffer ring for the window at that slot's
|
|
30616
|
+
* own width in real time — which is what makes the broker COPY rather than
|
|
30617
|
+
* re-encode — and derives the gif from those same bytes.
|
|
30618
|
+
*/
|
|
30619
|
+
async renderEventMedia(req) {
|
|
29299
30620
|
const api = this.ctx.api;
|
|
29300
|
-
this.
|
|
29301
|
-
logger:
|
|
29302
|
-
|
|
30621
|
+
this.ncEventMedia ??= new EventMediaService({
|
|
30622
|
+
logger: this.ctx.logger.child("nc-event-media"),
|
|
30623
|
+
produce: (input) => api.streamBroker.produceEventMedia.mutate({
|
|
30624
|
+
deviceId: input.deviceId,
|
|
30625
|
+
aroundMs: input.aroundMs,
|
|
30626
|
+
preSeconds: input.preSeconds,
|
|
30627
|
+
postSeconds: input.postSeconds,
|
|
30628
|
+
kinds: [...input.kinds],
|
|
30629
|
+
gifMaxWidth: input.gifMaxWidth,
|
|
30630
|
+
gifFps: input.gifFps,
|
|
30631
|
+
gifSpeed: input.gifSpeed,
|
|
30632
|
+
speed: input.speed,
|
|
30633
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
30634
|
+
}),
|
|
30635
|
+
fetch: (handle, nodeId) => api.streamBroker.fetchEventMedia.mutate({ handle }, nodeId.length > 0 ? nodePin(nodeId) : void 0),
|
|
30636
|
+
renderRingClip: async (input) => {
|
|
30637
|
+
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
30638
|
+
deviceId: input.deviceId,
|
|
30639
|
+
aroundMs: input.aroundMs,
|
|
30640
|
+
format: input.format,
|
|
30641
|
+
preRollSec: input.preRollSec,
|
|
30642
|
+
postRollSec: input.postRollSec,
|
|
30643
|
+
maxWidth: input.maxWidth,
|
|
30644
|
+
fps: input.fps,
|
|
30645
|
+
speed: input.speed,
|
|
30646
|
+
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
30647
|
+
});
|
|
30648
|
+
const buf = Buffer.from(res.base64, "base64");
|
|
30649
|
+
if (buf.byteLength === 0) return null;
|
|
30650
|
+
const bytes = new Uint8Array(buf.byteLength);
|
|
30651
|
+
bytes.set(buf);
|
|
30652
|
+
return bytes;
|
|
30653
|
+
}
|
|
29303
30654
|
});
|
|
29304
30655
|
try {
|
|
29305
|
-
const
|
|
30656
|
+
const media = await this.ncEventMedia.cut({
|
|
29306
30657
|
deviceId: req.deviceId,
|
|
29307
30658
|
aroundMs: req.aroundMs,
|
|
29308
|
-
|
|
30659
|
+
wantMp4: req.wantMp4,
|
|
30660
|
+
wantGif: req.wantGif,
|
|
30661
|
+
preRollSec: req.preRollSec,
|
|
30662
|
+
postRollSec: req.postRollSec,
|
|
30663
|
+
...req.profile !== void 0 ? { profile: req.profile } : {},
|
|
30664
|
+
...req.speed !== void 0 ? { speed: req.speed } : {}
|
|
29309
30665
|
});
|
|
29310
|
-
return
|
|
30666
|
+
return {
|
|
30667
|
+
mp4: media.mp4,
|
|
30668
|
+
gif: media.gif
|
|
30669
|
+
};
|
|
29311
30670
|
} catch (err) {
|
|
29312
|
-
|
|
30671
|
+
this.ctx.logger.warn("nc media: the cut threw — this notification ships without footage", {
|
|
29313
30672
|
tags: { deviceId: req.deviceId },
|
|
29314
30673
|
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
29315
30674
|
});
|
|
29316
|
-
return
|
|
30675
|
+
return {
|
|
30676
|
+
mp4: null,
|
|
30677
|
+
gif: null
|
|
30678
|
+
};
|
|
29317
30679
|
}
|
|
29318
30680
|
}
|
|
29319
30681
|
/** The operator's marked notification endpoint, or null (AUTO / unavailable). */
|
|
@@ -29391,7 +30753,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29391
30753
|
});
|
|
29392
30754
|
const served = await this.ctx.dataPlane?.serve({
|
|
29393
30755
|
prefix: "nc-action",
|
|
29394
|
-
access: "
|
|
30756
|
+
access: planeAccess("nc-action"),
|
|
29395
30757
|
handler: plane.handler
|
|
29396
30758
|
}) ?? null;
|
|
29397
30759
|
this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
|
|
@@ -29445,7 +30807,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29445
30807
|
});
|
|
29446
30808
|
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
29447
30809
|
prefix: "nc-artifact",
|
|
29448
|
-
access: "
|
|
30810
|
+
access: planeAccess("nc-artifact"),
|
|
29449
30811
|
handler: plane.handler
|
|
29450
30812
|
}) ?? null;
|
|
29451
30813
|
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
@@ -29728,7 +31090,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29728
31090
|
const api = this.ctx.api;
|
|
29729
31091
|
if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
|
|
29730
31092
|
await this.declareCollections(api);
|
|
29731
|
-
await this.
|
|
31093
|
+
await this.declareAlarmPanel(api);
|
|
29732
31094
|
const logger = this.ctx.logger;
|
|
29733
31095
|
const storage = await this.resolveMediaStorage(logger);
|
|
29734
31096
|
const stores = await this.buildStores(api, logger, storage);
|
|
@@ -29790,11 +31152,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29790
31152
|
...isHub && ncHandlers !== void 0 ? {
|
|
29791
31153
|
customActions: {
|
|
29792
31154
|
...ncActions,
|
|
29793
|
-
...embeddingActions
|
|
31155
|
+
...embeddingActions,
|
|
31156
|
+
...debugActions
|
|
29794
31157
|
},
|
|
29795
31158
|
actionHandlers: {
|
|
29796
31159
|
...ncHandlers,
|
|
29797
|
-
...this.buildEmbeddingActionHandlers()
|
|
31160
|
+
...this.buildEmbeddingActionHandlers(),
|
|
31161
|
+
...this.buildDebugActionHandlers()
|
|
29798
31162
|
}
|
|
29799
31163
|
} : {}
|
|
29800
31164
|
};
|
|
@@ -29818,70 +31182,45 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29818
31182
|
},
|
|
29819
31183
|
listCallerTargetIds: async (userId) => {
|
|
29820
31184
|
return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
|
|
29821
|
-
}
|
|
31185
|
+
},
|
|
31186
|
+
injectTestEvent: (input) => center.injectTestEvent(input, { getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }) })
|
|
29822
31187
|
});
|
|
29823
31188
|
}
|
|
29824
31189
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
29825
31190
|
* a CREATE TABLE. Idempotent. */
|
|
29826
31191
|
/**
|
|
29827
|
-
*
|
|
31192
|
+
* DECLARE the Notification Center's alarm panel.
|
|
29828
31193
|
*
|
|
29829
|
-
*
|
|
29830
|
-
*
|
|
29831
|
-
*
|
|
29832
|
-
*
|
|
29833
|
-
|
|
29834
|
-
|
|
29835
|
-
|
|
29836
|
-
|
|
29837
|
-
const
|
|
29838
|
-
|
|
29839
|
-
|
|
29840
|
-
|
|
29841
|
-
|
|
29842
|
-
|
|
29843
|
-
|
|
29844
|
-
|
|
31194
|
+
* The procedure is not here any more — `DeclaredDevices` owns the
|
|
31195
|
+
* get-or-create, the FIXED integration and its every-pass `fixed`
|
|
31196
|
+
* re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
|
|
31197
|
+
* second read that stops a transiently empty index re-stamping the declared
|
|
31198
|
+
* name over the operator's rename. What is left is the declaration and the
|
|
31199
|
+
* ports.
|
|
31200
|
+
*/
|
|
31201
|
+
async declareAlarmPanel(api) {
|
|
31202
|
+
const declared = new DeclaredDevices({
|
|
31203
|
+
logger: this.ctx.logger.child("alarm"),
|
|
31204
|
+
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31205
|
+
devices: this.ctx.kernel.devices,
|
|
31206
|
+
localNodeId: this.ctx.kernel.localNodeId,
|
|
31207
|
+
getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
|
|
31208
|
+
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
31209
|
+
updateIntegration: async ({ id, info }) => {
|
|
31210
|
+
await api.integrations.update.mutate({
|
|
31211
|
+
id,
|
|
31212
|
+
info,
|
|
31213
|
+
skipRestart: true
|
|
31214
|
+
});
|
|
31215
|
+
},
|
|
31216
|
+
listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
|
|
31217
|
+
});
|
|
29845
31218
|
try {
|
|
29846
|
-
const result = await
|
|
29847
|
-
logger: this.ctx.logger.child("alarm"),
|
|
29848
|
-
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31219
|
+
const result = await declared.reconcile({
|
|
29849
31220
|
integrationName: NC_INTEGRATION_NAME,
|
|
29850
|
-
|
|
29851
|
-
const found = await api.integrations.getByAddonId.query({ addonId });
|
|
29852
|
-
return found === null ? null : {
|
|
29853
|
-
id: found.id,
|
|
29854
|
-
fixed: found.info?.["fixed"] === true
|
|
29855
|
-
};
|
|
29856
|
-
},
|
|
29857
|
-
markFixed: async (integrationId) => {
|
|
29858
|
-
await api.integrations.update.mutate({
|
|
29859
|
-
id: integrationId,
|
|
29860
|
-
info: { fixed: true },
|
|
29861
|
-
skipRestart: true
|
|
29862
|
-
});
|
|
29863
|
-
},
|
|
29864
|
-
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
29865
|
-
findDeviceByStableId: async (stableId) => {
|
|
29866
|
-
const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
|
|
29867
|
-
return found === void 0 ? null : { id: found.id };
|
|
29868
|
-
},
|
|
29869
|
-
createDevice: async ({ stableId, integrationId, name }) => {
|
|
29870
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
|
|
29871
|
-
type: NC_ALARM_DEVICE_TYPE,
|
|
29872
|
-
name,
|
|
29873
|
-
integrationId
|
|
29874
|
-
});
|
|
29875
|
-
this.holdAlarmPanel(device);
|
|
29876
|
-
return { id: device.id };
|
|
29877
|
-
},
|
|
29878
|
-
adoptDevice: async ({ stableId }) => {
|
|
29879
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
|
|
29880
|
-
this.holdAlarmPanel(device);
|
|
29881
|
-
return { id: device.id };
|
|
29882
|
-
}
|
|
31221
|
+
devices: [NC_ALARM_DECLARATION]
|
|
29883
31222
|
});
|
|
29884
|
-
|
|
31223
|
+
for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
|
|
29885
31224
|
} catch (err) {
|
|
29886
31225
|
this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
29887
31226
|
}
|
|
@@ -29904,7 +31243,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29904
31243
|
/**
|
|
29905
31244
|
* Join the panel and the Notification Center, once both exist.
|
|
29906
31245
|
*
|
|
29907
|
-
* They are built in that order (`
|
|
31246
|
+
* They are built in that order (`declareAlarmPanel` runs before the centre is
|
|
29908
31247
|
* constructed) and neither can reach the other on its own, so the join is
|
|
29909
31248
|
* here. A node with no panel simply never calls it, and the cap answers
|
|
29910
31249
|
* `deviceId: null` — which is what an agent node should say.
|
|
@@ -29952,7 +31291,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
29952
31291
|
let storage = this.ctx.kernel.storage;
|
|
29953
31292
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
29954
31293
|
if (mediaRoot) {
|
|
29955
|
-
const { FilesystemStorageProvider } = await import("../node-
|
|
31294
|
+
const { FilesystemStorageProvider } = await import("../node-Co0YpEFr.mjs");
|
|
29956
31295
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
29957
31296
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
29958
31297
|
}
|
|
@@ -30410,28 +31749,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30410
31749
|
y: pt.y
|
|
30411
31750
|
})));
|
|
30412
31751
|
},
|
|
30413
|
-
|
|
30414
|
-
if (req.format === "mp4") {
|
|
30415
|
-
const seam = await this.renderSeamClip(req);
|
|
30416
|
-
if (seam !== null) return seam;
|
|
30417
|
-
}
|
|
30418
|
-
const res = await api.streamBroker.renderPreBufferClip.mutate({
|
|
30419
|
-
deviceId: req.deviceId,
|
|
30420
|
-
aroundMs: req.aroundMs,
|
|
30421
|
-
format: req.format,
|
|
30422
|
-
preRollSec: req.preRollSec ?? NC_FOOTAGE_PRE_ROLL_SEC,
|
|
30423
|
-
postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
|
|
30424
|
-
maxWidth: NC_FOOTAGE_MAX_WIDTH,
|
|
30425
|
-
fps: NC_FOOTAGE_FPS,
|
|
30426
|
-
...req.speed !== void 0 ? { speed: req.speed } : {},
|
|
30427
|
-
...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
|
|
30428
|
-
});
|
|
30429
|
-
const buf = Buffer.from(res.base64, "base64");
|
|
30430
|
-
if (buf.byteLength === 0) return null;
|
|
30431
|
-
const bytes = new Uint8Array(buf.byteLength);
|
|
30432
|
-
bytes.set(buf);
|
|
30433
|
-
return bytes;
|
|
30434
|
-
},
|
|
31752
|
+
renderEventMedia: (req) => this.renderEventMedia(req),
|
|
30435
31753
|
publishArtifact: async (bytes, mime) => await this.ncArtifactPlane?.publish(bytes, mime) ?? null,
|
|
30436
31754
|
listTargets: async () => {
|
|
30437
31755
|
return (await api.notificationOutput.listTargets.query({})).map((t) => {
|
|
@@ -30482,7 +31800,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30482
31800
|
}
|
|
30483
31801
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
30484
31802
|
* `/addon/<addonId>/event-media/<eventId>` through the hub's port.
|
|
30485
|
-
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31803
|
+
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31804
|
+
*
|
|
31805
|
+
* `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
|
|
31806
|
+
* the reason it changed: `getTrackMedia` / `getEventMedia` are declared
|
|
31807
|
+
* `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
|
|
31808
|
+
* admin gate protected nothing and cost every non-admin session its tiles. */
|
|
30486
31809
|
async serveEventMediaDataPlane() {
|
|
30487
31810
|
try {
|
|
30488
31811
|
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
@@ -30498,22 +31821,27 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30498
31821
|
} });
|
|
30499
31822
|
this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
30500
31823
|
prefix: "event-media",
|
|
30501
|
-
access: "
|
|
31824
|
+
access: planeAccess("event-media"),
|
|
30502
31825
|
handler
|
|
30503
31826
|
}) ?? null;
|
|
30504
31827
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
30505
31828
|
await this.serveNcArtifactPlane();
|
|
30506
31829
|
await this.serveTrainingExportPlane();
|
|
30507
31830
|
await this.serveRetrainExportPlane();
|
|
30508
|
-
|
|
31831
|
+
await this.serveDebugExportPlane();
|
|
31832
|
+
this.ctx.logger.info("event-media data-plane served", { meta: {
|
|
31833
|
+
baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
|
|
31834
|
+
access: planeAccess("event-media")
|
|
31835
|
+
} });
|
|
30509
31836
|
} catch (err) {
|
|
30510
31837
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
30511
31838
|
}
|
|
30512
31839
|
}
|
|
30513
31840
|
/**
|
|
30514
|
-
* The training-export data plane. Separate listener,
|
|
30515
|
-
* marking a track is a viewer-level
|
|
30516
|
-
* every stored frame behind the
|
|
31841
|
+
* The training-export data plane. Separate listener, admin-only (the level
|
|
31842
|
+
* is declared in `data-plane-access.ts`) — marking a track is a viewer-level
|
|
31843
|
+
* curation act (D81), but walking off with every stored frame behind the
|
|
31844
|
+
* marked set is not.
|
|
30517
31845
|
*/
|
|
30518
31846
|
async serveTrainingExportPlane() {
|
|
30519
31847
|
try {
|
|
@@ -30528,7 +31856,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30528
31856
|
});
|
|
30529
31857
|
this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
30530
31858
|
prefix: "training-export",
|
|
30531
|
-
access: "
|
|
31859
|
+
access: planeAccess("training-export"),
|
|
30532
31860
|
handler
|
|
30533
31861
|
}) ?? null;
|
|
30534
31862
|
} catch (err) {
|
|
@@ -30657,6 +31985,60 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30657
31985
|
return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
|
|
30658
31986
|
}
|
|
30659
31987
|
/**
|
|
31988
|
+
* The store seam the `debug.*` actions and the debug bundle both read through.
|
|
31989
|
+
*
|
|
31990
|
+
* INDEX READS ONLY — three media-inventory queries and an event query per
|
|
31991
|
+
* track, no blob. The bytes are fetched one at a time, by key, by the export
|
|
31992
|
+
* handler, so a bundle of a hundred tracks never holds more than one file.
|
|
31993
|
+
*/
|
|
31994
|
+
buildDebugServiceDeps() {
|
|
31995
|
+
const trackStore = this.trackStore;
|
|
31996
|
+
const mediaStore = this.mediaStore;
|
|
31997
|
+
const eventStore = this.eventStore;
|
|
31998
|
+
if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
|
|
31999
|
+
return {
|
|
32000
|
+
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
32001
|
+
listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
|
|
32002
|
+
getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
|
|
32003
|
+
listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
|
|
32004
|
+
listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
|
|
32005
|
+
logger: this.ctx.logger.child("debug"),
|
|
32006
|
+
now: () => Date.now()
|
|
32007
|
+
};
|
|
32008
|
+
}
|
|
32009
|
+
/** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
|
|
32010
|
+
buildDebugActionHandlers() {
|
|
32011
|
+
return {
|
|
32012
|
+
"debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
|
|
32013
|
+
"debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
|
|
32014
|
+
};
|
|
32015
|
+
}
|
|
32016
|
+
/**
|
|
32017
|
+
* The debug-export data plane. Its own listener, for the same reason the
|
|
32018
|
+
* retrain export has one: a client asking for the debug bundle must never be
|
|
32019
|
+
* able to receive the annotated dataset by changing a query parameter.
|
|
32020
|
+
*/
|
|
32021
|
+
async serveDebugExportPlane() {
|
|
32022
|
+
try {
|
|
32023
|
+
const handler = createDebugExportHandler({
|
|
32024
|
+
buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
|
|
32025
|
+
readMedia: async (key) => {
|
|
32026
|
+
const file = await this.mediaStore?.getByKey(key);
|
|
32027
|
+
return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
|
|
32028
|
+
},
|
|
32029
|
+
logger: this.ctx.logger.child("debug-export"),
|
|
32030
|
+
now: () => Date.now()
|
|
32031
|
+
});
|
|
32032
|
+
this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
32033
|
+
prefix: "debug-export",
|
|
32034
|
+
access: planeAccess("debug-export"),
|
|
32035
|
+
handler
|
|
32036
|
+
}) ?? null;
|
|
32037
|
+
} catch (err) {
|
|
32038
|
+
this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
32039
|
+
}
|
|
32040
|
+
}
|
|
32041
|
+
/**
|
|
30660
32042
|
* The retrain-export data plane. Separate listener from the training export
|
|
30661
32043
|
* next door: different archive, different contents, and a client asking for
|
|
30662
32044
|
* one must never be able to receive the other by changing a query parameter.
|
|
@@ -30678,7 +32060,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30678
32060
|
});
|
|
30679
32061
|
this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
30680
32062
|
prefix: "retrain-export",
|
|
30681
|
-
access: "
|
|
32063
|
+
access: planeAccess("retrain-export"),
|
|
30682
32064
|
handler
|
|
30683
32065
|
}) ?? null;
|
|
30684
32066
|
} catch (err) {
|
|
@@ -30888,6 +32270,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
30888
32270
|
audio
|
|
30889
32271
|
};
|
|
30890
32272
|
},
|
|
32273
|
+
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
32274
|
+
return (await api.recording.getAvailability.query({
|
|
32275
|
+
deviceId,
|
|
32276
|
+
fromMs: since,
|
|
32277
|
+
toMs: until
|
|
32278
|
+
})).ranges;
|
|
32279
|
+
},
|
|
32280
|
+
logWarn: (message, meta) => this.ctx.logger.warn(message, {
|
|
32281
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
32282
|
+
meta
|
|
32283
|
+
}),
|
|
30891
32284
|
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
30892
32285
|
const manifest = await api.recording.getPlaybackManifest.query({
|
|
30893
32286
|
deviceId,
|
|
@@ -31009,6 +32402,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31009
32402
|
this.trainingExportDataPlane = null;
|
|
31010
32403
|
await this.retrainExportDataPlane?.dispose();
|
|
31011
32404
|
this.retrainExportDataPlane = null;
|
|
32405
|
+
await this.debugExportDataPlane?.dispose();
|
|
32406
|
+
this.debugExportDataPlane = null;
|
|
31012
32407
|
}
|
|
31013
32408
|
async handleInferenceResult(data) {
|
|
31014
32409
|
if (this.shuttingDown) return;
|
|
@@ -31937,6 +33332,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
31937
33332
|
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
31938
33333
|
for (const d of details) try {
|
|
31939
33334
|
const isFaceDetail = d.className === "face";
|
|
33335
|
+
if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
|
|
31940
33336
|
if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
|
|
31941
33337
|
x: d.bbox.x,
|
|
31942
33338
|
y: d.bbox.y,
|
|
@@ -33359,11 +34755,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
33359
34755
|
const store = this.sensorEventStore;
|
|
33360
34756
|
const cache = this.linkedCamerasCache;
|
|
33361
34757
|
if (store === null || cache === null) return;
|
|
34758
|
+
const persisted = [];
|
|
33362
34759
|
try {
|
|
33363
34760
|
await ingestSensorStateChange({
|
|
33364
34761
|
sink: store,
|
|
33365
34762
|
cache,
|
|
33366
|
-
onPersisted: (ev) =>
|
|
34763
|
+
onPersisted: (ev) => persisted.push(ev),
|
|
33367
34764
|
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
33368
34765
|
}, data, timestamp);
|
|
33369
34766
|
} catch (err) {
|
|
@@ -33375,30 +34772,40 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
33375
34772
|
}
|
|
33376
34773
|
});
|
|
33377
34774
|
}
|
|
34775
|
+
const markerByCamera = /* @__PURE__ */ new Map();
|
|
33378
34776
|
const materializer = this.syntheticTracks;
|
|
33379
|
-
|
|
33380
|
-
|
|
33381
|
-
|
|
33382
|
-
|
|
33383
|
-
|
|
33384
|
-
|
|
33385
|
-
|
|
34777
|
+
if (materializer !== null) try {
|
|
34778
|
+
await projectSensorMarkers({
|
|
34779
|
+
cache,
|
|
34780
|
+
markers: materializer,
|
|
34781
|
+
producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
|
|
34782
|
+
onProjected: (r) => {
|
|
34783
|
+
markerByCamera.set(r.cameraId, {
|
|
34784
|
+
trackId: r.trackId,
|
|
34785
|
+
hasMedia: r.hasMedia
|
|
34786
|
+
});
|
|
34787
|
+
this.ctx.logger.info("synthetic sensor-track projected", {
|
|
34788
|
+
tags: { deviceId: r.cameraId },
|
|
34789
|
+
meta: {
|
|
34790
|
+
capName: r.capName,
|
|
34791
|
+
kind: r.kind,
|
|
34792
|
+
trackId: r.trackId,
|
|
34793
|
+
sourceDeviceId: r.sourceDeviceId,
|
|
34794
|
+
selfCamera: r.selfCamera,
|
|
34795
|
+
hasMedia: r.hasMedia
|
|
34796
|
+
}
|
|
34797
|
+
});
|
|
34798
|
+
},
|
|
34799
|
+
onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
34800
|
+
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
33386
34801
|
meta: {
|
|
33387
|
-
capName:
|
|
33388
|
-
kind:
|
|
34802
|
+
capName: r.capName,
|
|
34803
|
+
kind: r.kind,
|
|
34804
|
+
reason: r.reason,
|
|
34805
|
+
sourceDeviceId: r.sourceDeviceId
|
|
33389
34806
|
}
|
|
33390
|
-
})
|
|
33391
|
-
|
|
33392
|
-
}
|
|
33393
|
-
const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
|
|
33394
|
-
for (const cameraId of cameraIds) await materializer.materialize({
|
|
33395
|
-
cameraId,
|
|
33396
|
-
sourceDeviceId: data.deviceId,
|
|
33397
|
-
source: "sensor",
|
|
33398
|
-
kind: descriptor.kind,
|
|
33399
|
-
timestamp,
|
|
33400
|
-
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
33401
|
-
});
|
|
34807
|
+
})
|
|
34808
|
+
}, data, timestamp);
|
|
33402
34809
|
} catch (err) {
|
|
33403
34810
|
this.ctx.logger.warn("synthetic sensor-track materialize failed", {
|
|
33404
34811
|
tags: { deviceId: data.deviceId },
|
|
@@ -33408,6 +34815,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
33408
34815
|
}
|
|
33409
34816
|
});
|
|
33410
34817
|
}
|
|
34818
|
+
for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
33411
34819
|
}
|
|
33412
34820
|
/**
|
|
33413
34821
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|