@camstack/addon-post-analysis 1.2.52 → 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-k21GqaWS.js → dist-DGdY1GSf.js} +2003 -1810
- package/dist/{dist-CDLLzMJw.mjs → dist-Da97q0x6.mjs} +1999 -1812
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-B5brepKo.js → node-CpXZkQ6V.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-C-EUVj-u.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-DJUXj7Ya.mjs → hostInit-CyLkuQ7j.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1855 -829
- package/dist/pipeline-analytics/index.mjs +1846 -820
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-PkY2z2Wo.mjs +0 -26
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-DGdY1GSf.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_dist.__toESM(node_path);
|
|
@@ -698,77 +698,34 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
698
698
|
}
|
|
699
699
|
};
|
|
700
700
|
//#endregion
|
|
701
|
-
//#region src/notification-center/alarm/
|
|
701
|
+
//#region src/notification-center/alarm/alarm-panel-declaration.ts
|
|
702
702
|
/**
|
|
703
|
-
*
|
|
703
|
+
* The Notification Center's alarm panel, as a DECLARATION.
|
|
704
704
|
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
705
|
+
* There is no `ensureAlarmPanel` any more. The get-or-create, the `fixed`
|
|
706
|
+
* re-assertion, the adopt-don't-skip rule and the hub-ownership gate all moved
|
|
707
|
+
* to `DeclaredDevices` in `@camstack/types` — the one primitive for a device an
|
|
708
|
+
* addon declares. What is left here is the *declaration*: the stable id, the
|
|
709
|
+
* seed name, the type and the class. Data, not procedure.
|
|
708
710
|
*
|
|
709
|
-
* The
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
711
|
+
* The bug the hand-rolled version carried died in that move: it read the device
|
|
712
|
+
* index once, so a transiently empty answer took the create branch and
|
|
713
|
+
* re-stamped `Alarm` over the operator's rename. `DeclaredDevices` requires a
|
|
714
|
+
* second read to agree before it does anything destructive (D49).
|
|
713
715
|
*/
|
|
714
716
|
/** Stable id of the one panel. Never derived from a name — a rename must not
|
|
715
717
|
* mint a second device. */
|
|
716
718
|
var NC_ALARM_STABLE_ID = "nc-alarm-panel";
|
|
719
|
+
/** Seeded ONCE, at create. A later boot never re-stamps it. */
|
|
717
720
|
var NC_ALARM_DEVICE_NAME = "Alarm";
|
|
718
|
-
/**
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const existingIntegration = await deps.getIntegration(deps.addonId);
|
|
727
|
-
if (existingIntegration !== null && !existingIntegration.fixed) {
|
|
728
|
-
await deps.markFixed(existingIntegration.id);
|
|
729
|
-
deps.logger.info("marked the notification-center integration as fixed", { meta: { integrationId: existingIntegration.id } });
|
|
730
|
-
}
|
|
731
|
-
const integration = existingIntegration ?? await deps.createIntegration({
|
|
732
|
-
addonId: deps.addonId,
|
|
733
|
-
name: deps.integrationName,
|
|
734
|
-
info: { fixed: true }
|
|
735
|
-
});
|
|
736
|
-
if (await deps.findDeviceByStableId("nc-alarm-panel") !== null) {
|
|
737
|
-
const adopted = await deps.adoptDevice({ stableId: NC_ALARM_STABLE_ID });
|
|
738
|
-
deps.logger.info("alarm panel adopted", {
|
|
739
|
-
tags: { deviceId: adopted.id },
|
|
740
|
-
meta: {
|
|
741
|
-
integrationId: integration.id,
|
|
742
|
-
stableId: NC_ALARM_STABLE_ID
|
|
743
|
-
}
|
|
744
|
-
});
|
|
745
|
-
return {
|
|
746
|
-
integrationId: integration.id,
|
|
747
|
-
deviceId: adopted.id,
|
|
748
|
-
created: false
|
|
749
|
-
};
|
|
750
|
-
}
|
|
751
|
-
const device = await deps.createDevice({
|
|
752
|
-
stableId: NC_ALARM_STABLE_ID,
|
|
753
|
-
integrationId: integration.id,
|
|
754
|
-
name: NC_ALARM_DEVICE_NAME
|
|
755
|
-
});
|
|
756
|
-
deps.logger.info("alarm panel created", {
|
|
757
|
-
tags: { deviceId: device.id },
|
|
758
|
-
meta: {
|
|
759
|
-
integrationId: integration.id,
|
|
760
|
-
stableId: NC_ALARM_STABLE_ID
|
|
761
|
-
}
|
|
762
|
-
});
|
|
763
|
-
return {
|
|
764
|
-
integrationId: integration.id,
|
|
765
|
-
deviceId: device.id,
|
|
766
|
-
created: true
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
/** The device class + type the caller passes to `kernel.devices.create`. */
|
|
770
|
-
var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
|
|
771
|
-
var NC_ALARM_DEVICE_TYPE = require_dist.DeviceType.AlarmPanel;
|
|
721
|
+
/** Display name of the FIXED integration the panel hangs from. */
|
|
722
|
+
var NC_INTEGRATION_NAME = "Notification Center";
|
|
723
|
+
var NC_ALARM_DECLARATION = {
|
|
724
|
+
stableId: NC_ALARM_STABLE_ID,
|
|
725
|
+
name: NC_ALARM_DEVICE_NAME,
|
|
726
|
+
type: require_dist.DeviceType.AlarmPanel,
|
|
727
|
+
DeviceClass: NcAlarmPanelDevice
|
|
728
|
+
};
|
|
772
729
|
//#endregion
|
|
773
730
|
//#region src/notification-center/artifact-url.ts
|
|
774
731
|
/**
|
|
@@ -1108,6 +1065,220 @@ function mimeFromExtension(file) {
|
|
|
1108
1065
|
return "application/octet-stream";
|
|
1109
1066
|
}
|
|
1110
1067
|
//#endregion
|
|
1068
|
+
//#region src/notification-center/action-buttons.ts
|
|
1069
|
+
/** Sequence names the rule actually declares. A button may only name one. */
|
|
1070
|
+
function declaredSequences(actions) {
|
|
1071
|
+
const names = /* @__PURE__ */ new Set();
|
|
1072
|
+
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
1073
|
+
return names;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Build the notification's buttons.
|
|
1077
|
+
*
|
|
1078
|
+
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
1079
|
+
* to set the field, and an empty array is the honest answer to "which buttons
|
|
1080
|
+
* survived", which `undefined` would conflate with "the rule declared none".
|
|
1081
|
+
*/
|
|
1082
|
+
function buildActionButtons(input) {
|
|
1083
|
+
const actions = input.actions;
|
|
1084
|
+
if (actions === void 0) return [];
|
|
1085
|
+
const buttons = actions.buttons ?? [];
|
|
1086
|
+
if (buttons.length === 0) return [];
|
|
1087
|
+
const known = declaredSequences(actions);
|
|
1088
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1089
|
+
const out = [];
|
|
1090
|
+
for (const button of buttons) {
|
|
1091
|
+
if (!known.has(button.sequence)) continue;
|
|
1092
|
+
if (seen.has(button.id)) continue;
|
|
1093
|
+
seen.add(button.id);
|
|
1094
|
+
out.push({
|
|
1095
|
+
id: button.id,
|
|
1096
|
+
label: button.label,
|
|
1097
|
+
url: input.mintUrl({
|
|
1098
|
+
sequence: button.sequence,
|
|
1099
|
+
actionId: button.id
|
|
1100
|
+
}),
|
|
1101
|
+
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
1102
|
+
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Which of a rule's buttons name a sequence it does not have.
|
|
1109
|
+
*
|
|
1110
|
+
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
1111
|
+
* notification is the exact shape of failure this repo keeps paying for — the
|
|
1112
|
+
* operator authored it, it never appeared, and nothing said why.
|
|
1113
|
+
*/
|
|
1114
|
+
function unresolvableButtons(actions) {
|
|
1115
|
+
if (actions === void 0) return [];
|
|
1116
|
+
const buttons = actions.buttons ?? [];
|
|
1117
|
+
if (buttons.length === 0) return [];
|
|
1118
|
+
const known = declaredSequences(actions);
|
|
1119
|
+
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
1120
|
+
}
|
|
1121
|
+
//#endregion
|
|
1122
|
+
//#region src/notification-center/device-mute-store.ts
|
|
1123
|
+
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
1124
|
+
var NC_DEVICE_MUTES_COLUMNS = [
|
|
1125
|
+
(
|
|
1126
|
+
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
1127
|
+
{
|
|
1128
|
+
name: "id",
|
|
1129
|
+
type: "TEXT",
|
|
1130
|
+
primaryKey: true,
|
|
1131
|
+
notNull: true
|
|
1132
|
+
}),
|
|
1133
|
+
{
|
|
1134
|
+
name: "deviceId",
|
|
1135
|
+
type: "INTEGER",
|
|
1136
|
+
notNull: true
|
|
1137
|
+
},
|
|
1138
|
+
(
|
|
1139
|
+
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
1140
|
+
{
|
|
1141
|
+
name: "mutedAt",
|
|
1142
|
+
type: "INTEGER",
|
|
1143
|
+
notNull: true
|
|
1144
|
+
})
|
|
1145
|
+
];
|
|
1146
|
+
/**
|
|
1147
|
+
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
1148
|
+
* the table proportional to what the operator actually silenced, and makes the
|
|
1149
|
+
* hot-path read a `Set.has`.
|
|
1150
|
+
*/
|
|
1151
|
+
var NcDeviceMuteStore = class {
|
|
1152
|
+
muted = /* @__PURE__ */ new Set();
|
|
1153
|
+
store;
|
|
1154
|
+
logger;
|
|
1155
|
+
now;
|
|
1156
|
+
constructor(deps) {
|
|
1157
|
+
this.store = deps.store;
|
|
1158
|
+
this.logger = deps.logger;
|
|
1159
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1160
|
+
}
|
|
1161
|
+
static async declare(store) {
|
|
1162
|
+
await store.declareCollection.mutate({
|
|
1163
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1164
|
+
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
1169
|
+
* clearing it: dropping every mute on a transient store error would start
|
|
1170
|
+
* notifying for a camera the operator silenced, without them asking — the
|
|
1171
|
+
* same reasoning as `NcSnoozeStore.load`.
|
|
1172
|
+
*/
|
|
1173
|
+
async load() {
|
|
1174
|
+
try {
|
|
1175
|
+
const rows = await this.store.query.query({
|
|
1176
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1177
|
+
filter: { limit: 1e4 }
|
|
1178
|
+
});
|
|
1179
|
+
const next = /* @__PURE__ */ new Set();
|
|
1180
|
+
let skipped = 0;
|
|
1181
|
+
for (const row of rows) {
|
|
1182
|
+
const raw = row.data["deviceId"];
|
|
1183
|
+
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
1184
|
+
skipped += 1;
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
next.add(raw);
|
|
1188
|
+
}
|
|
1189
|
+
this.muted.clear();
|
|
1190
|
+
for (const id of next) this.muted.add(id);
|
|
1191
|
+
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
1194
|
+
error: String(err),
|
|
1195
|
+
muted: this.muted.size
|
|
1196
|
+
} });
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/** Hot-path read, called once per evaluated subject. */
|
|
1200
|
+
isMuted(deviceId) {
|
|
1201
|
+
return this.muted.has(deviceId);
|
|
1202
|
+
}
|
|
1203
|
+
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
1204
|
+
listMuted() {
|
|
1205
|
+
return [...this.muted].toSorted((a, b) => a - b);
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
1209
|
+
* updated only after the write lands, so a failed write cannot leave the
|
|
1210
|
+
* hot path believing a camera is silenced when the next boot will not.
|
|
1211
|
+
*/
|
|
1212
|
+
async setMuted(deviceId, muted) {
|
|
1213
|
+
const id = String(deviceId);
|
|
1214
|
+
if (muted) {
|
|
1215
|
+
await this.store.set.mutate({
|
|
1216
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1217
|
+
key: id,
|
|
1218
|
+
value: {
|
|
1219
|
+
id,
|
|
1220
|
+
deviceId,
|
|
1221
|
+
mutedAt: this.now()
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
this.muted.add(deviceId);
|
|
1225
|
+
} else {
|
|
1226
|
+
await this.store.delete.mutate({
|
|
1227
|
+
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
1228
|
+
key: id
|
|
1229
|
+
});
|
|
1230
|
+
this.muted.delete(deviceId);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
var NcDeviceStateCache = class {
|
|
1235
|
+
deps;
|
|
1236
|
+
states = /* @__PURE__ */ new Map();
|
|
1237
|
+
lastOkAt = 0;
|
|
1238
|
+
ids = [];
|
|
1239
|
+
now;
|
|
1240
|
+
constructor(deps) {
|
|
1241
|
+
this.deps = deps;
|
|
1242
|
+
this.now = deps.now ?? (() => Date.now());
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
1246
|
+
* with no gated rule does no work at all.
|
|
1247
|
+
*/
|
|
1248
|
+
setWatched(ids) {
|
|
1249
|
+
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
1250
|
+
}
|
|
1251
|
+
watched() {
|
|
1252
|
+
return this.ids;
|
|
1253
|
+
}
|
|
1254
|
+
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
1255
|
+
get(deviceId) {
|
|
1256
|
+
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
1257
|
+
return this.states.get(deviceId);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Re-read every watched state. Never throws: a gate that could fail the
|
|
1261
|
+
* evaluation tick would take the notification pipeline with it.
|
|
1262
|
+
*/
|
|
1263
|
+
async refresh() {
|
|
1264
|
+
if (this.ids.length === 0) {
|
|
1265
|
+
this.states = /* @__PURE__ */ new Map();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
const next = await this.deps.readStates(this.ids);
|
|
1270
|
+
this.states = new Map(next);
|
|
1271
|
+
this.lastOkAt = this.now();
|
|
1272
|
+
for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
1275
|
+
watched: this.ids.length,
|
|
1276
|
+
error: String(err)
|
|
1277
|
+
} });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
};
|
|
1281
|
+
//#endregion
|
|
1111
1282
|
//#region src/pipeline-analytics/pipeline/label-utils.ts
|
|
1112
1283
|
/**
|
|
1113
1284
|
* Every macro class the pipeline can carry on a detection. A `label` exists to
|
|
@@ -1520,12 +1691,13 @@ function readSensorEventType(value) {
|
|
|
1520
1691
|
* what the operator named in the rule. Carrying only the camera is what made a
|
|
1521
1692
|
* sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
|
|
1522
1693
|
*/
|
|
1523
|
-
function subjectFromSensorEvent(ev) {
|
|
1694
|
+
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
1524
1695
|
const eventType = readSensorEventType(ev.value);
|
|
1525
1696
|
return {
|
|
1526
1697
|
kind: "device-event",
|
|
1527
1698
|
recordId: ev.id,
|
|
1528
1699
|
deviceId: ev.deviceId,
|
|
1700
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
|
|
1529
1701
|
...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
|
|
1530
1702
|
timestamp: ev.timestamp,
|
|
1531
1703
|
classNames: [],
|
|
@@ -2392,7 +2564,16 @@ var NcDispatcher = class {
|
|
|
2392
2564
|
name: "zone.jpg"
|
|
2393
2565
|
} : still);
|
|
2394
2566
|
} else out.push(still);
|
|
2395
|
-
}
|
|
2567
|
+
} else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
|
|
2568
|
+
tags: { deviceId: entry.payload.subject.deviceId },
|
|
2569
|
+
meta: {
|
|
2570
|
+
recordKind: entry.recordKind,
|
|
2571
|
+
recordId: entry.recordId,
|
|
2572
|
+
policy: entry.payload.media,
|
|
2573
|
+
...entry.payload.mediaFrame !== void 0 ? { frame: entry.payload.mediaFrame } : {},
|
|
2574
|
+
owners: [...entry.payload.subject.eventId !== void 0 ? [`event:${entry.payload.subject.eventId}`] : [], ...entry.payload.subject.trackId !== void 0 ? [`track:${entry.payload.subject.trackId}`] : []]
|
|
2575
|
+
}
|
|
2576
|
+
});
|
|
2396
2577
|
const wantGif = entry.payload.mediaGif === true;
|
|
2397
2578
|
const wantMp4 = entry.payload.mediaClip === true;
|
|
2398
2579
|
if ((wantGif || wantMp4) && this.deps.renderEventMedia !== void 0) try {
|
|
@@ -2601,6 +2782,123 @@ function copyBytes(bytes) {
|
|
|
2601
2782
|
return out;
|
|
2602
2783
|
}
|
|
2603
2784
|
//#endregion
|
|
2785
|
+
//#region src/notification-center/event-intake.ts
|
|
2786
|
+
/** Object-event insert (movement-state transitions + zone crossings). */
|
|
2787
|
+
function incomingFromObjectEvent(event) {
|
|
2788
|
+
return {
|
|
2789
|
+
subject: subjectFromObjectEvent(event),
|
|
2790
|
+
kind: "object-event",
|
|
2791
|
+
origin: "pipeline",
|
|
2792
|
+
log: () => ({
|
|
2793
|
+
tags: { deviceId: event.deviceId },
|
|
2794
|
+
meta: { eventId: event.id }
|
|
2795
|
+
})
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
/** `TrackCloser.closeExpired` — the ONE close point. */
|
|
2799
|
+
function incomingFromTrack(track, info) {
|
|
2800
|
+
return {
|
|
2801
|
+
subject: subjectFromTrack(track, info),
|
|
2802
|
+
kind: "track-end",
|
|
2803
|
+
origin: "pipeline",
|
|
2804
|
+
log: () => ({
|
|
2805
|
+
tags: { deviceId: track.deviceId },
|
|
2806
|
+
meta: { trackId: track.trackId }
|
|
2807
|
+
})
|
|
2808
|
+
};
|
|
2809
|
+
}
|
|
2810
|
+
/**
|
|
2811
|
+
* Sensor-event persist (one row per linked camera).
|
|
2812
|
+
*
|
|
2813
|
+
* `markerTrackId` is the synthetic marker track the same state change
|
|
2814
|
+
* projected onto this camera — the notification's media owner, and the only
|
|
2815
|
+
* frame a device event has. Absent when no marker landed.
|
|
2816
|
+
*/
|
|
2817
|
+
function incomingFromSensorEvent(event, markerTrackId) {
|
|
2818
|
+
return {
|
|
2819
|
+
subject: subjectFromSensorEvent(event, markerTrackId),
|
|
2820
|
+
kind: "device-event",
|
|
2821
|
+
origin: "sensor",
|
|
2822
|
+
log: () => ({
|
|
2823
|
+
tags: { deviceId: event.deviceId },
|
|
2824
|
+
meta: {
|
|
2825
|
+
sensorEventId: event.id,
|
|
2826
|
+
kind: event.kind,
|
|
2827
|
+
...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
|
|
2828
|
+
}
|
|
2829
|
+
})
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
/** Audio-event persist (classified episode). */
|
|
2833
|
+
function incomingFromAudioEvent(event) {
|
|
2834
|
+
return {
|
|
2835
|
+
subject: subjectFromAudioEvent(event),
|
|
2836
|
+
kind: "audio-event",
|
|
2837
|
+
origin: "pipeline",
|
|
2838
|
+
log: () => ({
|
|
2839
|
+
tags: { deviceId: event.deviceId },
|
|
2840
|
+
meta: {
|
|
2841
|
+
audioEventId: event.id,
|
|
2842
|
+
class: event.classification?.className
|
|
2843
|
+
}
|
|
2844
|
+
})
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
/** Package object-event persist (delivered / picked-up). */
|
|
2848
|
+
function incomingFromPackageEvent(event, phase) {
|
|
2849
|
+
return {
|
|
2850
|
+
subject: subjectFromPackageEvent(event, phase),
|
|
2851
|
+
kind: "package-event",
|
|
2852
|
+
origin: "pipeline",
|
|
2853
|
+
log: () => ({
|
|
2854
|
+
tags: { deviceId: event.deviceId },
|
|
2855
|
+
meta: {
|
|
2856
|
+
eventId: event.id,
|
|
2857
|
+
phase
|
|
2858
|
+
}
|
|
2859
|
+
})
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
/** A committed ZoneAnalytics occupancy edge. */
|
|
2863
|
+
function incomingFromOccupancyEdge(edge) {
|
|
2864
|
+
return {
|
|
2865
|
+
subject: subjectFromOccupancyEvent(edge),
|
|
2866
|
+
kind: "occupancy-event",
|
|
2867
|
+
origin: "pipeline",
|
|
2868
|
+
log: () => ({
|
|
2869
|
+
tags: { deviceId: edge.deviceId },
|
|
2870
|
+
meta: {
|
|
2871
|
+
zoneId: edge.zoneId ?? "@frame",
|
|
2872
|
+
className: edge.className ?? "@all",
|
|
2873
|
+
count: edge.count,
|
|
2874
|
+
threshold: edge.threshold,
|
|
2875
|
+
occupied: edge.occupied
|
|
2876
|
+
}
|
|
2877
|
+
})
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
2881
|
+
* record — same subject, honest origin in the log. */
|
|
2882
|
+
function asReconcile(event) {
|
|
2883
|
+
return {
|
|
2884
|
+
...event,
|
|
2885
|
+
origin: "reconcile"
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* The delivery an intake kind rides. `object-event` and `audio-event` both ride
|
|
2890
|
+
* `immediate` (classified audio has no delivery of its own — it is gated by the
|
|
2891
|
+
* audio-class opt-in instead); `occupancy-event` rides `device-event`.
|
|
2892
|
+
*
|
|
2893
|
+
* Extracted from the centre so the tester can tell an operator which rules a
|
|
2894
|
+
* given simulated trigger will even be considered against, without evaluating.
|
|
2895
|
+
*/
|
|
2896
|
+
function deliveryForKind(kind) {
|
|
2897
|
+
if (kind === "object-event" || kind === "audio-event") return "immediate";
|
|
2898
|
+
if (kind === "occupancy-event") return "device-event";
|
|
2899
|
+
return kind;
|
|
2900
|
+
}
|
|
2901
|
+
//#endregion
|
|
2604
2902
|
//#region src/notification-center/occupancy-watcher.ts
|
|
2605
2903
|
/** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
|
|
2606
2904
|
var FRAME_SCOPE = "@frame";
|
|
@@ -3456,468 +3754,6 @@ function rowToEntry$1(id, data) {
|
|
|
3456
3754
|
};
|
|
3457
3755
|
}
|
|
3458
3756
|
//#endregion
|
|
3459
|
-
//#region src/notification-center/event-intake.ts
|
|
3460
|
-
/** Object-event insert (movement-state transitions + zone crossings). */
|
|
3461
|
-
function incomingFromObjectEvent(event) {
|
|
3462
|
-
return {
|
|
3463
|
-
subject: subjectFromObjectEvent(event),
|
|
3464
|
-
kind: "object-event",
|
|
3465
|
-
origin: "pipeline",
|
|
3466
|
-
log: () => ({
|
|
3467
|
-
tags: { deviceId: event.deviceId },
|
|
3468
|
-
meta: { eventId: event.id }
|
|
3469
|
-
})
|
|
3470
|
-
};
|
|
3471
|
-
}
|
|
3472
|
-
/** `TrackCloser.closeExpired` — the ONE close point. */
|
|
3473
|
-
function incomingFromTrack(track, info) {
|
|
3474
|
-
return {
|
|
3475
|
-
subject: subjectFromTrack(track, info),
|
|
3476
|
-
kind: "track-end",
|
|
3477
|
-
origin: "pipeline",
|
|
3478
|
-
log: () => ({
|
|
3479
|
-
tags: { deviceId: track.deviceId },
|
|
3480
|
-
meta: { trackId: track.trackId }
|
|
3481
|
-
})
|
|
3482
|
-
};
|
|
3483
|
-
}
|
|
3484
|
-
/** Sensor-event persist (one row per linked camera). */
|
|
3485
|
-
function incomingFromSensorEvent(event) {
|
|
3486
|
-
return {
|
|
3487
|
-
subject: subjectFromSensorEvent(event),
|
|
3488
|
-
kind: "device-event",
|
|
3489
|
-
origin: "sensor",
|
|
3490
|
-
log: () => ({
|
|
3491
|
-
tags: { deviceId: event.deviceId },
|
|
3492
|
-
meta: {
|
|
3493
|
-
sensorEventId: event.id,
|
|
3494
|
-
kind: event.kind
|
|
3495
|
-
}
|
|
3496
|
-
})
|
|
3497
|
-
};
|
|
3498
|
-
}
|
|
3499
|
-
/** Audio-event persist (classified episode). */
|
|
3500
|
-
function incomingFromAudioEvent(event) {
|
|
3501
|
-
return {
|
|
3502
|
-
subject: subjectFromAudioEvent(event),
|
|
3503
|
-
kind: "audio-event",
|
|
3504
|
-
origin: "pipeline",
|
|
3505
|
-
log: () => ({
|
|
3506
|
-
tags: { deviceId: event.deviceId },
|
|
3507
|
-
meta: {
|
|
3508
|
-
audioEventId: event.id,
|
|
3509
|
-
class: event.classification?.className
|
|
3510
|
-
}
|
|
3511
|
-
})
|
|
3512
|
-
};
|
|
3513
|
-
}
|
|
3514
|
-
/** Package object-event persist (delivered / picked-up). */
|
|
3515
|
-
function incomingFromPackageEvent(event, phase) {
|
|
3516
|
-
return {
|
|
3517
|
-
subject: subjectFromPackageEvent(event, phase),
|
|
3518
|
-
kind: "package-event",
|
|
3519
|
-
origin: "pipeline",
|
|
3520
|
-
log: () => ({
|
|
3521
|
-
tags: { deviceId: event.deviceId },
|
|
3522
|
-
meta: {
|
|
3523
|
-
eventId: event.id,
|
|
3524
|
-
phase
|
|
3525
|
-
}
|
|
3526
|
-
})
|
|
3527
|
-
};
|
|
3528
|
-
}
|
|
3529
|
-
/** A committed ZoneAnalytics occupancy edge. */
|
|
3530
|
-
function incomingFromOccupancyEdge(edge) {
|
|
3531
|
-
return {
|
|
3532
|
-
subject: subjectFromOccupancyEvent(edge),
|
|
3533
|
-
kind: "occupancy-event",
|
|
3534
|
-
origin: "pipeline",
|
|
3535
|
-
log: () => ({
|
|
3536
|
-
tags: { deviceId: edge.deviceId },
|
|
3537
|
-
meta: {
|
|
3538
|
-
zoneId: edge.zoneId ?? "@frame",
|
|
3539
|
-
className: edge.className ?? "@all",
|
|
3540
|
-
count: edge.count,
|
|
3541
|
-
threshold: edge.threshold,
|
|
3542
|
-
occupied: edge.occupied
|
|
3543
|
-
}
|
|
3544
|
-
})
|
|
3545
|
-
};
|
|
3546
|
-
}
|
|
3547
|
-
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
3548
|
-
* record — same subject, honest origin in the log. */
|
|
3549
|
-
function asReconcile(event) {
|
|
3550
|
-
return {
|
|
3551
|
-
...event,
|
|
3552
|
-
origin: "reconcile"
|
|
3553
|
-
};
|
|
3554
|
-
}
|
|
3555
|
-
/**
|
|
3556
|
-
* The delivery an intake kind rides. `object-event` and `audio-event` both ride
|
|
3557
|
-
* `immediate` (classified audio has no delivery of its own — it is gated by the
|
|
3558
|
-
* audio-class opt-in instead); `occupancy-event` rides `device-event`.
|
|
3559
|
-
*
|
|
3560
|
-
* Extracted from the centre so the tester can tell an operator which rules a
|
|
3561
|
-
* given simulated trigger will even be considered against, without evaluating.
|
|
3562
|
-
*/
|
|
3563
|
-
function deliveryForKind(kind) {
|
|
3564
|
-
if (kind === "object-event" || kind === "audio-event") return "immediate";
|
|
3565
|
-
if (kind === "occupancy-event") return "device-event";
|
|
3566
|
-
return kind;
|
|
3567
|
-
}
|
|
3568
|
-
//#endregion
|
|
3569
|
-
//#region src/notification-center/test-event.ts
|
|
3570
|
-
/** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
|
|
3571
|
-
var BboxSchema = require_dist.object({
|
|
3572
|
-
x: require_dist.number().min(0).max(1),
|
|
3573
|
-
y: require_dist.number().min(0).max(1),
|
|
3574
|
-
w: require_dist.number().min(0).max(1),
|
|
3575
|
-
h: require_dist.number().min(0).max(1)
|
|
3576
|
-
});
|
|
3577
|
-
/**
|
|
3578
|
-
* Which trigger to simulate. One value per {@link NcSubjectKind}, because the
|
|
3579
|
-
* kind is what decides the delivery a rule must declare to be a candidate at
|
|
3580
|
-
* all — picking the wrong one is the single most common reason an operator's
|
|
3581
|
-
* rule "does not fire", and making it an explicit choice is half the point of
|
|
3582
|
-
* the tester.
|
|
3583
|
-
*/
|
|
3584
|
-
var TriggerSchema = require_dist._enum([
|
|
3585
|
-
"object-event",
|
|
3586
|
-
"track-end",
|
|
3587
|
-
"device-event",
|
|
3588
|
-
"package-event",
|
|
3589
|
-
"audio-event",
|
|
3590
|
-
"occupancy-event"
|
|
3591
|
-
]);
|
|
3592
|
-
/**
|
|
3593
|
-
* What the operator typed. Every field maps onto exactly one field of
|
|
3594
|
-
* {@link NcRuleSubject} — there is no test-only condition input, because a
|
|
3595
|
-
* condition the tester could set and a real record could not is a condition the
|
|
3596
|
-
* test result would lie about.
|
|
3597
|
-
*/
|
|
3598
|
-
var NcTestEventInputSchema = require_dist.object({
|
|
3599
|
-
trigger: TriggerSchema,
|
|
3600
|
-
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
3601
|
-
deviceId: require_dist.number().int(),
|
|
3602
|
-
/** DEVICE-EVENT: the linked device whose state changed — the contact, the
|
|
3603
|
-
* button, the doorbell — when that is not the camera. */
|
|
3604
|
-
sourceDeviceId: require_dist.number().int().optional(),
|
|
3605
|
-
/** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
|
|
3606
|
-
classNames: require_dist.array(require_dist.string()).default([]),
|
|
3607
|
-
/** Collapsed display label — an identity name, a plate, a subclass. */
|
|
3608
|
-
label: require_dist.string().optional(),
|
|
3609
|
-
/** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
|
|
3610
|
-
* condition FAILS on an absent confidence, and the tester must be able to
|
|
3611
|
-
* reproduce that. */
|
|
3612
|
-
confidence: require_dist.number().min(0).max(1).optional(),
|
|
3613
|
-
/** Identity/plate MATCH confidence — distinct from `confidence`. */
|
|
3614
|
-
labelConfidence: require_dist.number().min(0).max(1).optional(),
|
|
3615
|
-
/** Key-event importance [0,1] (track-end only, in practice). */
|
|
3616
|
-
importance: require_dist.number().min(0).max(1).optional(),
|
|
3617
|
-
/** Track lifespan in seconds (track-end only). */
|
|
3618
|
-
dwellSeconds: require_dist.number().min(0).optional(),
|
|
3619
|
-
/** Admin zone ids the subject is in (object) / visited (track). */
|
|
3620
|
-
zones: require_dist.array(require_dist.string()).default([]),
|
|
3621
|
-
/** OBJECT-EVENT: simulate a confirmed zone crossing. */
|
|
3622
|
-
crossing: require_dist.object({
|
|
3623
|
-
zoneId: require_dist.string(),
|
|
3624
|
-
direction: require_dist._enum(["enter", "exit"])
|
|
3625
|
-
}).optional(),
|
|
3626
|
-
/** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
|
|
3627
|
-
sensorKind: require_dist.string().optional(),
|
|
3628
|
-
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
3629
|
-
* event-emitter slice. */
|
|
3630
|
-
eventType: require_dist.string().optional(),
|
|
3631
|
-
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
3632
|
-
packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
|
|
3633
|
-
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
3634
|
-
* source reads as). */
|
|
3635
|
-
source: require_dist._enum([
|
|
3636
|
-
"pipeline",
|
|
3637
|
-
"onboard",
|
|
3638
|
-
"sensor",
|
|
3639
|
-
"audio"
|
|
3640
|
-
]).optional(),
|
|
3641
|
-
/** Normalized bbox — feeds `customZones`, AND is what lets the still be
|
|
3642
|
-
* cropped / boxed the way the rule's media policy asks. */
|
|
3643
|
-
bbox: BboxSchema.optional(),
|
|
3644
|
-
/** OCCUPANCY-EVENT: the committed count edge. */
|
|
3645
|
-
occupancy: require_dist.object({
|
|
3646
|
-
zoneId: require_dist.string().optional(),
|
|
3647
|
-
zoneName: require_dist.string().optional(),
|
|
3648
|
-
className: require_dist.string().optional(),
|
|
3649
|
-
count: require_dist.number().int().min(0),
|
|
3650
|
-
previousCount: require_dist.number().int().min(0).default(0),
|
|
3651
|
-
occupied: require_dist.boolean(),
|
|
3652
|
-
threshold: require_dist.number().int().min(1)
|
|
3653
|
-
}).optional(),
|
|
3654
|
-
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
3655
|
-
* condition without waiting for the hour. */
|
|
3656
|
-
timestamp: require_dist.number().int().optional(),
|
|
3657
|
-
/** Evaluate only this rule (what the per-rule Test button sends). */
|
|
3658
|
-
onlyRuleId: require_dist.string().optional(),
|
|
3659
|
-
bypassCooldown: require_dist.boolean().default(false),
|
|
3660
|
-
bypassMute: require_dist.boolean().default(false),
|
|
3661
|
-
/** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
|
|
3662
|
-
* not open a gate the operator did not ask to open. */
|
|
3663
|
-
runActions: require_dist.boolean().default(false),
|
|
3664
|
-
/** Where the still comes from. `live` takes a snapshot of the camera NOW;
|
|
3665
|
-
* `none` sends the notification without one. */
|
|
3666
|
-
still: require_dist._enum(["live", "none"]).default("live")
|
|
3667
|
-
});
|
|
3668
|
-
/** Per-rule verdict, as the tester reports it. */
|
|
3669
|
-
var NcTestEventRuleOutcomeSchema = require_dist.object({
|
|
3670
|
-
ruleId: require_dist.string(),
|
|
3671
|
-
ruleName: require_dist.string(),
|
|
3672
|
-
matched: require_dist.boolean(),
|
|
3673
|
-
failedCondition: require_dist.string().optional(),
|
|
3674
|
-
matchedOn: require_dist.array(require_dist.string()).optional(),
|
|
3675
|
-
cooldown: require_dist._enum([
|
|
3676
|
-
"n/a",
|
|
3677
|
-
"clear",
|
|
3678
|
-
"suppressed",
|
|
3679
|
-
"bypassed"
|
|
3680
|
-
]),
|
|
3681
|
-
enqueued: require_dist.number().int(),
|
|
3682
|
-
targetIds: require_dist.array(require_dist.string())
|
|
3683
|
-
});
|
|
3684
|
-
var NcTestEventResultSchema = require_dist.object({
|
|
3685
|
-
/** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
|
|
3686
|
-
* for in the logs, in the outbox and in the delivery history. */
|
|
3687
|
-
recordId: require_dist.string(),
|
|
3688
|
-
/** Always `synthetic` — the marker the whole path carried. */
|
|
3689
|
-
origin: require_dist.literal("synthetic"),
|
|
3690
|
-
/** The delivery the chosen trigger rides. A rule declaring anything else was
|
|
3691
|
-
* never a candidate, which is the answer to most "it did not fire". */
|
|
3692
|
-
delivery: require_dist.string(),
|
|
3693
|
-
candidates: require_dist.number().int(),
|
|
3694
|
-
outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
|
|
3695
|
-
/** What the still ended up being. `unattachable` means the chosen TRIGGER
|
|
3696
|
-
* cannot carry one at all — a device-event notification has no media owner,
|
|
3697
|
-
* live or synthetic — and that is a property of production, not of the test. */
|
|
3698
|
-
still: require_dist._enum([
|
|
3699
|
-
"live",
|
|
3700
|
-
"none",
|
|
3701
|
-
"unavailable",
|
|
3702
|
-
"unattachable"
|
|
3703
|
-
]),
|
|
3704
|
-
/** Everything the operator has to know to read the result honestly. */
|
|
3705
|
-
notes: require_dist.array(require_dist.string()),
|
|
3706
|
-
droppedBy: require_dist.string().optional()
|
|
3707
|
-
});
|
|
3708
|
-
/** The id shape every synthetic record carries. One prefix, everywhere. */
|
|
3709
|
-
var SYNTHETIC_ID_PREFIX = "synthetic:";
|
|
3710
|
-
/** True for a record id this module minted. Used by the media shim. */
|
|
3711
|
-
function isSyntheticId(id) {
|
|
3712
|
-
return id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
3713
|
-
}
|
|
3714
|
-
/**
|
|
3715
|
-
* Whether a subject of this kind can carry a still AT ALL, in production.
|
|
3716
|
-
*
|
|
3717
|
-
* The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
|
|
3718
|
-
* owner onto the payload only for the kinds that have one: an `eventId` for
|
|
3719
|
-
* object/package events, a `trackId` for track-scoped ones. A device-event, an
|
|
3720
|
-
* audio episode and an occupancy edge freeze neither — so those notifications
|
|
3721
|
-
* ship text-only today, and a tester that attached an image to them would be
|
|
3722
|
-
* advertising a feature that does not exist.
|
|
3723
|
-
*/
|
|
3724
|
-
function triggerCanCarryStill(kind) {
|
|
3725
|
-
return kind === "object-event" || kind === "package-event" || kind === "track-end";
|
|
3726
|
-
}
|
|
3727
|
-
/**
|
|
3728
|
-
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
3729
|
-
* event, so the red-green test can assert that this subject and a
|
|
3730
|
-
* producer-built one are indistinguishable.
|
|
3731
|
-
*/
|
|
3732
|
-
function buildSyntheticEvent(input, recordId, now) {
|
|
3733
|
-
const kind = input.trigger;
|
|
3734
|
-
const timestamp = input.timestamp ?? now;
|
|
3735
|
-
const trackScoped = kind === "object-event" || kind === "track-end";
|
|
3736
|
-
return {
|
|
3737
|
-
subject: {
|
|
3738
|
-
kind,
|
|
3739
|
-
recordId,
|
|
3740
|
-
deviceId: input.deviceId,
|
|
3741
|
-
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
3742
|
-
timestamp,
|
|
3743
|
-
classNames: input.classNames,
|
|
3744
|
-
...input.label !== void 0 ? { label: input.label } : {},
|
|
3745
|
-
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
3746
|
-
zones: input.zones,
|
|
3747
|
-
...trackScoped ? { trackId: recordId } : {},
|
|
3748
|
-
source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
|
|
3749
|
-
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
3750
|
-
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
3751
|
-
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
3752
|
-
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
3753
|
-
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
3754
|
-
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
3755
|
-
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
3756
|
-
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
3757
|
-
...input.occupancy !== void 0 ? { occupancy: {
|
|
3758
|
-
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
3759
|
-
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
3760
|
-
...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
|
|
3761
|
-
count: input.occupancy.count,
|
|
3762
|
-
previousCount: input.occupancy.previousCount,
|
|
3763
|
-
occupied: input.occupancy.occupied,
|
|
3764
|
-
threshold: input.occupancy.threshold
|
|
3765
|
-
} } : {}
|
|
3766
|
-
},
|
|
3767
|
-
kind,
|
|
3768
|
-
origin: "synthetic",
|
|
3769
|
-
controls: {
|
|
3770
|
-
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
|
|
3771
|
-
bypassCooldown: input.bypassCooldown,
|
|
3772
|
-
bypassMute: input.bypassMute,
|
|
3773
|
-
runActions: input.runActions
|
|
3774
|
-
},
|
|
3775
|
-
log: () => ({
|
|
3776
|
-
tags: { deviceId: input.deviceId },
|
|
3777
|
-
meta: {
|
|
3778
|
-
eventId: recordId,
|
|
3779
|
-
synthetic: true,
|
|
3780
|
-
trigger: input.trigger,
|
|
3781
|
-
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
3782
|
-
}
|
|
3783
|
-
})
|
|
3784
|
-
};
|
|
3785
|
-
}
|
|
3786
|
-
/**
|
|
3787
|
-
* Turn one live JPEG into the media set the dispatcher's ladders look for.
|
|
3788
|
-
*
|
|
3789
|
-
* Every entry is the SAME photograph, differing only in how it was cut — which
|
|
3790
|
-
* is exactly what the ladders mean by their kind names, so nothing here claims
|
|
3791
|
-
* a frame it does not have:
|
|
3792
|
-
*
|
|
3793
|
-
* - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
|
|
3794
|
-
* - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
|
|
3795
|
-
* - `crop` / `thumbnail` — that bbox, cut out.
|
|
3796
|
-
*
|
|
3797
|
-
* With NO bbox only the clean-scene kinds are produced, so a rule asking for
|
|
3798
|
-
* `frame: 'cropped'` correctly finds nothing rather than being handed a whole
|
|
3799
|
-
* scene it did not ask for. sharp failures degrade the same way: the clean
|
|
3800
|
-
* scene always survives.
|
|
3801
|
-
*/
|
|
3802
|
-
async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
3803
|
-
const base64 = Buffer.from(jpeg).toString("base64");
|
|
3804
|
-
const clean = (kind) => ({
|
|
3805
|
-
key: `${SYNTHETIC_ID_PREFIX}${kind}`,
|
|
3806
|
-
kind,
|
|
3807
|
-
base64,
|
|
3808
|
-
sizeBytes: jpeg.byteLength,
|
|
3809
|
-
timestamp
|
|
3810
|
-
});
|
|
3811
|
-
const files = [
|
|
3812
|
-
clean("fullFrame"),
|
|
3813
|
-
clean("keyFrame"),
|
|
3814
|
-
clean("firstFrame")
|
|
3815
|
-
];
|
|
3816
|
-
if (bbox === void 0) return files;
|
|
3817
|
-
try {
|
|
3818
|
-
const { default: sharp$7 } = await import("sharp");
|
|
3819
|
-
const meta = await sharp$7(Buffer.from(jpeg)).metadata();
|
|
3820
|
-
const W = meta.width ?? 0;
|
|
3821
|
-
const H = meta.height ?? 0;
|
|
3822
|
-
if (W === 0 || H === 0) return files;
|
|
3823
|
-
const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
|
|
3824
|
-
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
3825
|
-
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
3826
|
-
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
3827
|
-
const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
|
|
3828
|
-
left,
|
|
3829
|
-
top,
|
|
3830
|
-
width,
|
|
3831
|
-
height
|
|
3832
|
-
}).jpeg({ quality: 85 }).toBuffer();
|
|
3833
|
-
const cropB64 = cropBuf.toString("base64");
|
|
3834
|
-
files.push({
|
|
3835
|
-
key: `${SYNTHETIC_ID_PREFIX}crop`,
|
|
3836
|
-
kind: "crop",
|
|
3837
|
-
base64: cropB64,
|
|
3838
|
-
sizeBytes: cropBuf.byteLength,
|
|
3839
|
-
timestamp
|
|
3840
|
-
}, {
|
|
3841
|
-
key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
|
|
3842
|
-
kind: "thumbnail",
|
|
3843
|
-
base64: cropB64,
|
|
3844
|
-
sizeBytes: cropBuf.byteLength,
|
|
3845
|
-
timestamp
|
|
3846
|
-
});
|
|
3847
|
-
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
3848
|
-
const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
|
|
3849
|
-
const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
|
|
3850
|
-
input: overlay,
|
|
3851
|
-
top: 0,
|
|
3852
|
-
left: 0
|
|
3853
|
-
}]).jpeg({ quality: 85 }).toBuffer();
|
|
3854
|
-
files.push({
|
|
3855
|
-
key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
|
|
3856
|
-
kind: "fullFrameBoxed",
|
|
3857
|
-
base64: boxedBuf.toString("base64"),
|
|
3858
|
-
sizeBytes: boxedBuf.byteLength,
|
|
3859
|
-
timestamp
|
|
3860
|
-
});
|
|
3861
|
-
} catch {
|
|
3862
|
-
return files;
|
|
3863
|
-
}
|
|
3864
|
-
return files;
|
|
3865
|
-
}
|
|
3866
|
-
//#endregion
|
|
3867
|
-
//#region src/notification-center/action-buttons.ts
|
|
3868
|
-
/** Sequence names the rule actually declares. A button may only name one. */
|
|
3869
|
-
function declaredSequences(actions) {
|
|
3870
|
-
const names = /* @__PURE__ */ new Set();
|
|
3871
|
-
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
3872
|
-
return names;
|
|
3873
|
-
}
|
|
3874
|
-
/**
|
|
3875
|
-
* Build the notification's buttons.
|
|
3876
|
-
*
|
|
3877
|
-
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
3878
|
-
* to set the field, and an empty array is the honest answer to "which buttons
|
|
3879
|
-
* survived", which `undefined` would conflate with "the rule declared none".
|
|
3880
|
-
*/
|
|
3881
|
-
function buildActionButtons(input) {
|
|
3882
|
-
const actions = input.actions;
|
|
3883
|
-
if (actions === void 0) return [];
|
|
3884
|
-
const buttons = actions.buttons ?? [];
|
|
3885
|
-
if (buttons.length === 0) return [];
|
|
3886
|
-
const known = declaredSequences(actions);
|
|
3887
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3888
|
-
const out = [];
|
|
3889
|
-
for (const button of buttons) {
|
|
3890
|
-
if (!known.has(button.sequence)) continue;
|
|
3891
|
-
if (seen.has(button.id)) continue;
|
|
3892
|
-
seen.add(button.id);
|
|
3893
|
-
out.push({
|
|
3894
|
-
id: button.id,
|
|
3895
|
-
label: button.label,
|
|
3896
|
-
url: input.mintUrl({
|
|
3897
|
-
sequence: button.sequence,
|
|
3898
|
-
actionId: button.id
|
|
3899
|
-
}),
|
|
3900
|
-
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
3901
|
-
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
3902
|
-
});
|
|
3903
|
-
}
|
|
3904
|
-
return out;
|
|
3905
|
-
}
|
|
3906
|
-
/**
|
|
3907
|
-
* Which of a rule's buttons name a sequence it does not have.
|
|
3908
|
-
*
|
|
3909
|
-
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
3910
|
-
* notification is the exact shape of failure this repo keeps paying for — the
|
|
3911
|
-
* operator authored it, it never appeared, and nothing said why.
|
|
3912
|
-
*/
|
|
3913
|
-
function unresolvableButtons(actions) {
|
|
3914
|
-
if (actions === void 0) return [];
|
|
3915
|
-
const buttons = actions.buttons ?? [];
|
|
3916
|
-
if (buttons.length === 0) return [];
|
|
3917
|
-
const known = declaredSequences(actions);
|
|
3918
|
-
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
3919
|
-
}
|
|
3920
|
-
//#endregion
|
|
3921
3757
|
//#region src/notification-center/rule-actions.ts
|
|
3922
3758
|
var NcRuleActionRunner = class {
|
|
3923
3759
|
deps;
|
|
@@ -4230,53 +4066,6 @@ var NcRuleStore = class {
|
|
|
4230
4066
|
});
|
|
4231
4067
|
}
|
|
4232
4068
|
};
|
|
4233
|
-
var NcDeviceStateCache = class {
|
|
4234
|
-
deps;
|
|
4235
|
-
states = /* @__PURE__ */ new Map();
|
|
4236
|
-
lastOkAt = 0;
|
|
4237
|
-
ids = [];
|
|
4238
|
-
now;
|
|
4239
|
-
constructor(deps) {
|
|
4240
|
-
this.deps = deps;
|
|
4241
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4242
|
-
}
|
|
4243
|
-
/**
|
|
4244
|
-
* The device ids worth reading — recomputed from the rule set, so a cluster
|
|
4245
|
-
* with no gated rule does no work at all.
|
|
4246
|
-
*/
|
|
4247
|
-
setWatched(ids) {
|
|
4248
|
-
this.ids = [...new Set(ids)].sort((a, b) => a - b);
|
|
4249
|
-
}
|
|
4250
|
-
watched() {
|
|
4251
|
-
return this.ids;
|
|
4252
|
-
}
|
|
4253
|
-
/** Hot-path read. No I/O, no await — this is called per event. */
|
|
4254
|
-
get(deviceId) {
|
|
4255
|
-
if (this.now() - this.lastOkAt > 3e5) return void 0;
|
|
4256
|
-
return this.states.get(deviceId);
|
|
4257
|
-
}
|
|
4258
|
-
/**
|
|
4259
|
-
* Re-read every watched state. Never throws: a gate that could fail the
|
|
4260
|
-
* evaluation tick would take the notification pipeline with it.
|
|
4261
|
-
*/
|
|
4262
|
-
async refresh() {
|
|
4263
|
-
if (this.ids.length === 0) {
|
|
4264
|
-
this.states = /* @__PURE__ */ new Map();
|
|
4265
|
-
return;
|
|
4266
|
-
}
|
|
4267
|
-
try {
|
|
4268
|
-
const next = await this.deps.readStates(this.ids);
|
|
4269
|
-
this.states = new Map(next);
|
|
4270
|
-
this.lastOkAt = this.now();
|
|
4271
|
-
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 } });
|
|
4272
|
-
} catch (err) {
|
|
4273
|
-
this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
|
|
4274
|
-
watched: this.ids.length,
|
|
4275
|
-
error: String(err)
|
|
4276
|
-
} });
|
|
4277
|
-
}
|
|
4278
|
-
}
|
|
4279
|
-
};
|
|
4280
4069
|
//#endregion
|
|
4281
4070
|
//#region src/notification-center/snooze-digest.ts
|
|
4282
4071
|
/** `14:03` in the node's local zone — a digest is read by a person, at home. */
|
|
@@ -4393,119 +4182,6 @@ function dueSummaries(entries, nowMs, alreadySent) {
|
|
|
4393
4182
|
return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
|
|
4394
4183
|
}
|
|
4395
4184
|
//#endregion
|
|
4396
|
-
//#region src/notification-center/device-mute-store.ts
|
|
4397
|
-
var NC_DEVICE_MUTES_COLLECTION = "notification-center:device-mutes";
|
|
4398
|
-
var NC_DEVICE_MUTES_COLUMNS = [
|
|
4399
|
-
(
|
|
4400
|
-
/** `String(deviceId)` — one row per muted camera; unmute DELETES the row. */
|
|
4401
|
-
{
|
|
4402
|
-
name: "id",
|
|
4403
|
-
type: "TEXT",
|
|
4404
|
-
primaryKey: true,
|
|
4405
|
-
notNull: true
|
|
4406
|
-
}),
|
|
4407
|
-
{
|
|
4408
|
-
name: "deviceId",
|
|
4409
|
-
type: "INTEGER",
|
|
4410
|
-
notNull: true
|
|
4411
|
-
},
|
|
4412
|
-
(
|
|
4413
|
-
/** When an admin muted it — the only thing an audit ever wants to know. */
|
|
4414
|
-
{
|
|
4415
|
-
name: "mutedAt",
|
|
4416
|
-
type: "INTEGER",
|
|
4417
|
-
notNull: true
|
|
4418
|
-
})
|
|
4419
|
-
];
|
|
4420
|
-
/**
|
|
4421
|
-
* Absence of a row is "not muted"; there is no `muted: false` row. That keeps
|
|
4422
|
-
* the table proportional to what the operator actually silenced, and makes the
|
|
4423
|
-
* hot-path read a `Set.has`.
|
|
4424
|
-
*/
|
|
4425
|
-
var NcDeviceMuteStore = class {
|
|
4426
|
-
muted = /* @__PURE__ */ new Set();
|
|
4427
|
-
store;
|
|
4428
|
-
logger;
|
|
4429
|
-
now;
|
|
4430
|
-
constructor(deps) {
|
|
4431
|
-
this.store = deps.store;
|
|
4432
|
-
this.logger = deps.logger;
|
|
4433
|
-
this.now = deps.now ?? (() => Date.now());
|
|
4434
|
-
}
|
|
4435
|
-
static async declare(store) {
|
|
4436
|
-
await store.declareCollection.mutate({
|
|
4437
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4438
|
-
columns: [...NC_DEVICE_MUTES_COLUMNS]
|
|
4439
|
-
});
|
|
4440
|
-
}
|
|
4441
|
-
/**
|
|
4442
|
-
* (Re)hydrate. A failed load KEEPS what is already in memory rather than
|
|
4443
|
-
* clearing it: dropping every mute on a transient store error would start
|
|
4444
|
-
* notifying for a camera the operator silenced, without them asking — the
|
|
4445
|
-
* same reasoning as `NcSnoozeStore.load`.
|
|
4446
|
-
*/
|
|
4447
|
-
async load() {
|
|
4448
|
-
try {
|
|
4449
|
-
const rows = await this.store.query.query({
|
|
4450
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4451
|
-
filter: { limit: 1e4 }
|
|
4452
|
-
});
|
|
4453
|
-
const next = /* @__PURE__ */ new Set();
|
|
4454
|
-
let skipped = 0;
|
|
4455
|
-
for (const row of rows) {
|
|
4456
|
-
const raw = row.data["deviceId"];
|
|
4457
|
-
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
4458
|
-
skipped += 1;
|
|
4459
|
-
continue;
|
|
4460
|
-
}
|
|
4461
|
-
next.add(raw);
|
|
4462
|
-
}
|
|
4463
|
-
this.muted.clear();
|
|
4464
|
-
for (const id of next) this.muted.add(id);
|
|
4465
|
-
if (skipped > 0) this.logger.warn("device-mute rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
|
|
4466
|
-
} catch (err) {
|
|
4467
|
-
this.logger.warn("device-mute load failed — keeping the mutes already in memory", { meta: {
|
|
4468
|
-
error: String(err),
|
|
4469
|
-
muted: this.muted.size
|
|
4470
|
-
} });
|
|
4471
|
-
}
|
|
4472
|
-
}
|
|
4473
|
-
/** Hot-path read, called once per evaluated subject. */
|
|
4474
|
-
isMuted(deviceId) {
|
|
4475
|
-
return this.muted.has(deviceId);
|
|
4476
|
-
}
|
|
4477
|
-
/** Every muted camera, ascending — a stable order so two callers agree. */
|
|
4478
|
-
listMuted() {
|
|
4479
|
-
return [...this.muted].toSorted((a, b) => a - b);
|
|
4480
|
-
}
|
|
4481
|
-
/**
|
|
4482
|
-
* Mute or unmute. Idempotent in both directions; the in-memory set is
|
|
4483
|
-
* updated only after the write lands, so a failed write cannot leave the
|
|
4484
|
-
* hot path believing a camera is silenced when the next boot will not.
|
|
4485
|
-
*/
|
|
4486
|
-
async setMuted(deviceId, muted) {
|
|
4487
|
-
const id = String(deviceId);
|
|
4488
|
-
if (muted) {
|
|
4489
|
-
await this.store.set.mutate({
|
|
4490
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4491
|
-
key: id,
|
|
4492
|
-
value: {
|
|
4493
|
-
id,
|
|
4494
|
-
deviceId,
|
|
4495
|
-
mutedAt: this.now()
|
|
4496
|
-
}
|
|
4497
|
-
});
|
|
4498
|
-
this.muted.add(deviceId);
|
|
4499
|
-
} else {
|
|
4500
|
-
await this.store.delete.mutate({
|
|
4501
|
-
collection: NC_DEVICE_MUTES_COLLECTION,
|
|
4502
|
-
key: id
|
|
4503
|
-
});
|
|
4504
|
-
this.muted.delete(deviceId);
|
|
4505
|
-
}
|
|
4506
|
-
}
|
|
4507
|
-
};
|
|
4508
|
-
//#endregion
|
|
4509
4185
|
//#region src/notification-center/snooze-store.ts
|
|
4510
4186
|
/**
|
|
4511
4187
|
* NcSnoozeStore — the durable half of snooze.
|
|
@@ -4859,6 +4535,314 @@ var NcSnoozeStore = class {
|
|
|
4859
4535
|
}
|
|
4860
4536
|
};
|
|
4861
4537
|
//#endregion
|
|
4538
|
+
//#region src/notification-center/test-event.ts
|
|
4539
|
+
/** Normalized 0..1 rectangle, top-left origin — the subject bbox space. */
|
|
4540
|
+
var BboxSchema = require_dist.object({
|
|
4541
|
+
x: require_dist.number().min(0).max(1),
|
|
4542
|
+
y: require_dist.number().min(0).max(1),
|
|
4543
|
+
w: require_dist.number().min(0).max(1),
|
|
4544
|
+
h: require_dist.number().min(0).max(1)
|
|
4545
|
+
});
|
|
4546
|
+
/**
|
|
4547
|
+
* Which trigger to simulate. One value per {@link NcSubjectKind}, because the
|
|
4548
|
+
* kind is what decides the delivery a rule must declare to be a candidate at
|
|
4549
|
+
* all — picking the wrong one is the single most common reason an operator's
|
|
4550
|
+
* rule "does not fire", and making it an explicit choice is half the point of
|
|
4551
|
+
* the tester.
|
|
4552
|
+
*/
|
|
4553
|
+
var TriggerSchema = require_dist._enum([
|
|
4554
|
+
"object-event",
|
|
4555
|
+
"track-end",
|
|
4556
|
+
"device-event",
|
|
4557
|
+
"package-event",
|
|
4558
|
+
"audio-event",
|
|
4559
|
+
"occupancy-event"
|
|
4560
|
+
]);
|
|
4561
|
+
/**
|
|
4562
|
+
* What the operator typed. Every field maps onto exactly one field of
|
|
4563
|
+
* {@link NcRuleSubject} — there is no test-only condition input, because a
|
|
4564
|
+
* condition the tester could set and a real record could not is a condition the
|
|
4565
|
+
* test result would lie about.
|
|
4566
|
+
*/
|
|
4567
|
+
var NcTestEventInputSchema = require_dist.object({
|
|
4568
|
+
trigger: TriggerSchema,
|
|
4569
|
+
/** The CAMERA the event is attributed to (what a notification shows). */
|
|
4570
|
+
deviceId: require_dist.number().int(),
|
|
4571
|
+
/** DEVICE-EVENT: the linked device whose state changed — the contact, the
|
|
4572
|
+
* button, the doorbell — when that is not the camera. */
|
|
4573
|
+
sourceDeviceId: require_dist.number().int().optional(),
|
|
4574
|
+
/** Detection class ids, from the NC taxonomy (`person`, `car`, `audio-…`). */
|
|
4575
|
+
classNames: require_dist.array(require_dist.string()).default([]),
|
|
4576
|
+
/** Collapsed display label — an identity name, a plate, a subclass. */
|
|
4577
|
+
label: require_dist.string().optional(),
|
|
4578
|
+
/** Detection confidence [0,1]. ABSENT is meaningful: a `minConfidence`
|
|
4579
|
+
* condition FAILS on an absent confidence, and the tester must be able to
|
|
4580
|
+
* reproduce that. */
|
|
4581
|
+
confidence: require_dist.number().min(0).max(1).optional(),
|
|
4582
|
+
/** Identity/plate MATCH confidence — distinct from `confidence`. */
|
|
4583
|
+
labelConfidence: require_dist.number().min(0).max(1).optional(),
|
|
4584
|
+
/** Key-event importance [0,1] (track-end only, in practice). */
|
|
4585
|
+
importance: require_dist.number().min(0).max(1).optional(),
|
|
4586
|
+
/** Track lifespan in seconds (track-end only). */
|
|
4587
|
+
dwellSeconds: require_dist.number().min(0).optional(),
|
|
4588
|
+
/** Admin zone ids the subject is in (object) / visited (track). */
|
|
4589
|
+
zones: require_dist.array(require_dist.string()).default([]),
|
|
4590
|
+
/** OBJECT-EVENT: simulate a confirmed zone crossing. */
|
|
4591
|
+
crossing: require_dist.object({
|
|
4592
|
+
zoneId: require_dist.string(),
|
|
4593
|
+
direction: require_dist._enum(["enter", "exit"])
|
|
4594
|
+
}).optional(),
|
|
4595
|
+
/** DEVICE-EVENT: the taxonomy kind id (`doorbell`, `contact`, `button`, …). */
|
|
4596
|
+
sensorKind: require_dist.string().optional(),
|
|
4597
|
+
/** DEVICE-EVENT: the raw device event-type token, when the source is an
|
|
4598
|
+
* event-emitter slice. */
|
|
4599
|
+
eventType: require_dist.string().optional(),
|
|
4600
|
+
/** PACKAGE-EVENT: which lifecycle phase. */
|
|
4601
|
+
packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
|
|
4602
|
+
/** Detection provenance. Absent = `pipeline` (what a record with no stamped
|
|
4603
|
+
* source reads as). */
|
|
4604
|
+
source: require_dist._enum([
|
|
4605
|
+
"pipeline",
|
|
4606
|
+
"onboard",
|
|
4607
|
+
"sensor",
|
|
4608
|
+
"audio"
|
|
4609
|
+
]).optional(),
|
|
4610
|
+
/** Normalized bbox — feeds `customZones`, AND is what lets the still be
|
|
4611
|
+
* cropped / boxed the way the rule's media policy asks. */
|
|
4612
|
+
bbox: BboxSchema.optional(),
|
|
4613
|
+
/** OCCUPANCY-EVENT: the committed count edge. */
|
|
4614
|
+
occupancy: require_dist.object({
|
|
4615
|
+
zoneId: require_dist.string().optional(),
|
|
4616
|
+
zoneName: require_dist.string().optional(),
|
|
4617
|
+
className: require_dist.string().optional(),
|
|
4618
|
+
count: require_dist.number().int().min(0),
|
|
4619
|
+
previousCount: require_dist.number().int().min(0).default(0),
|
|
4620
|
+
occupied: require_dist.boolean(),
|
|
4621
|
+
threshold: require_dist.number().int().min(1)
|
|
4622
|
+
}).optional(),
|
|
4623
|
+
/** The instant the event happened. Absent = now. Set it to test a schedule
|
|
4624
|
+
* condition without waiting for the hour. */
|
|
4625
|
+
timestamp: require_dist.number().int().optional(),
|
|
4626
|
+
/** Evaluate only this rule (what the per-rule Test button sends). */
|
|
4627
|
+
onlyRuleId: require_dist.string().optional(),
|
|
4628
|
+
bypassCooldown: require_dist.boolean().default(false),
|
|
4629
|
+
bypassMute: require_dist.boolean().default(false),
|
|
4630
|
+
/** Run the matched rule's `onTrigger` sequences. OFF by default: a test must
|
|
4631
|
+
* not open a gate the operator did not ask to open. */
|
|
4632
|
+
runActions: require_dist.boolean().default(false),
|
|
4633
|
+
/** Where the still comes from. `live` takes a snapshot of the camera NOW;
|
|
4634
|
+
* `none` sends the notification without one. */
|
|
4635
|
+
still: require_dist._enum(["live", "none"]).default("live")
|
|
4636
|
+
});
|
|
4637
|
+
/** Per-rule verdict, as the tester reports it. */
|
|
4638
|
+
var NcTestEventRuleOutcomeSchema = require_dist.object({
|
|
4639
|
+
ruleId: require_dist.string(),
|
|
4640
|
+
ruleName: require_dist.string(),
|
|
4641
|
+
matched: require_dist.boolean(),
|
|
4642
|
+
failedCondition: require_dist.string().optional(),
|
|
4643
|
+
matchedOn: require_dist.array(require_dist.string()).optional(),
|
|
4644
|
+
cooldown: require_dist._enum([
|
|
4645
|
+
"n/a",
|
|
4646
|
+
"clear",
|
|
4647
|
+
"suppressed",
|
|
4648
|
+
"bypassed"
|
|
4649
|
+
]),
|
|
4650
|
+
enqueued: require_dist.number().int(),
|
|
4651
|
+
targetIds: require_dist.array(require_dist.string())
|
|
4652
|
+
});
|
|
4653
|
+
var NcTestEventResultSchema = require_dist.object({
|
|
4654
|
+
/** The synthetic record id. It is `synthetic:<uuid>` and it is what to grep
|
|
4655
|
+
* for in the logs, in the outbox and in the delivery history. */
|
|
4656
|
+
recordId: require_dist.string(),
|
|
4657
|
+
/** Always `synthetic` — the marker the whole path carried. */
|
|
4658
|
+
origin: require_dist.literal("synthetic"),
|
|
4659
|
+
/** The delivery the chosen trigger rides. A rule declaring anything else was
|
|
4660
|
+
* never a candidate, which is the answer to most "it did not fire". */
|
|
4661
|
+
delivery: require_dist.string(),
|
|
4662
|
+
candidates: require_dist.number().int(),
|
|
4663
|
+
outcomes: require_dist.array(NcTestEventRuleOutcomeSchema),
|
|
4664
|
+
/** What the still ended up being. `unattachable` means the chosen TRIGGER
|
|
4665
|
+
* cannot carry one at all — an audio episode and an occupancy edge freeze no
|
|
4666
|
+
* media owner, so those notifications ship text-only in production too, and
|
|
4667
|
+
* that is a property of production, not of the test. A DEVICE event is no
|
|
4668
|
+
* longer one of them: its press projects a marker track that owns the frame. */
|
|
4669
|
+
still: require_dist._enum([
|
|
4670
|
+
"live",
|
|
4671
|
+
"none",
|
|
4672
|
+
"unavailable",
|
|
4673
|
+
"unattachable"
|
|
4674
|
+
]),
|
|
4675
|
+
/** Everything the operator has to know to read the result honestly. */
|
|
4676
|
+
notes: require_dist.array(require_dist.string()),
|
|
4677
|
+
droppedBy: require_dist.string().optional()
|
|
4678
|
+
});
|
|
4679
|
+
/** The id shape every synthetic record carries. One prefix, everywhere. */
|
|
4680
|
+
var SYNTHETIC_ID_PREFIX = "synthetic:";
|
|
4681
|
+
/** True for a record id this module minted. Used by the media shim. */
|
|
4682
|
+
function isSyntheticId(id) {
|
|
4683
|
+
return id.startsWith(SYNTHETIC_ID_PREFIX);
|
|
4684
|
+
}
|
|
4685
|
+
/**
|
|
4686
|
+
* Whether a subject of this kind can carry a still AT ALL, in production.
|
|
4687
|
+
*
|
|
4688
|
+
* The attachment ladder resolves media by OWNER, and `buildEntries` freezes an
|
|
4689
|
+
* owner onto the payload only for the kinds that have one:
|
|
4690
|
+
*
|
|
4691
|
+
* - an `eventId` for object and package events;
|
|
4692
|
+
* - a `trackId` for track-scoped ones — and, since 2026-08-08, for a
|
|
4693
|
+
* DEVICE event too: the same state change projects a synthetic marker track
|
|
4694
|
+
* carrying the camera's frame (`sensor-marker-projector.ts`), and the
|
|
4695
|
+
* notification names it as its owner.
|
|
4696
|
+
*
|
|
4697
|
+
* An audio episode and an occupancy edge still freeze neither. Audio HAS a
|
|
4698
|
+
* marker projection, but its id does not reach the notification subject yet;
|
|
4699
|
+
* occupancy has no projection at all. Those two therefore remain honestly
|
|
4700
|
+
* unattachable — a tester that invented a picture for them would be advertising
|
|
4701
|
+
* a feature that does not exist.
|
|
4702
|
+
*/
|
|
4703
|
+
function triggerCanCarryStill(kind) {
|
|
4704
|
+
return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event";
|
|
4705
|
+
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
|
|
4708
|
+
* event, so the red-green test can assert that this subject and a
|
|
4709
|
+
* producer-built one are indistinguishable.
|
|
4710
|
+
*/
|
|
4711
|
+
function buildSyntheticEvent(input, recordId, now) {
|
|
4712
|
+
const kind = input.trigger;
|
|
4713
|
+
const timestamp = input.timestamp ?? now;
|
|
4714
|
+
const carriesTrackId = kind === "object-event" || kind === "track-end" || kind === "device-event";
|
|
4715
|
+
return {
|
|
4716
|
+
subject: {
|
|
4717
|
+
kind,
|
|
4718
|
+
recordId,
|
|
4719
|
+
deviceId: input.deviceId,
|
|
4720
|
+
...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
|
|
4721
|
+
timestamp,
|
|
4722
|
+
classNames: input.classNames,
|
|
4723
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
4724
|
+
...input.confidence !== void 0 ? { confidence: input.confidence } : {},
|
|
4725
|
+
zones: input.zones,
|
|
4726
|
+
...carriesTrackId ? { trackId: recordId } : {},
|
|
4727
|
+
source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
|
|
4728
|
+
...input.importance !== void 0 ? { importance: input.importance } : {},
|
|
4729
|
+
...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
|
|
4730
|
+
...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
|
|
4731
|
+
...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
|
|
4732
|
+
...input.eventType !== void 0 ? { eventType: input.eventType } : {},
|
|
4733
|
+
...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
|
|
4734
|
+
...input.bbox !== void 0 ? { bbox: input.bbox } : {},
|
|
4735
|
+
...input.crossing !== void 0 ? { crossing: input.crossing } : {},
|
|
4736
|
+
...input.occupancy !== void 0 ? { occupancy: {
|
|
4737
|
+
...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
|
|
4738
|
+
...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
|
|
4739
|
+
...input.occupancy.className !== void 0 ? { className: input.occupancy.className } : {},
|
|
4740
|
+
count: input.occupancy.count,
|
|
4741
|
+
previousCount: input.occupancy.previousCount,
|
|
4742
|
+
occupied: input.occupancy.occupied,
|
|
4743
|
+
threshold: input.occupancy.threshold
|
|
4744
|
+
} } : {}
|
|
4745
|
+
},
|
|
4746
|
+
kind,
|
|
4747
|
+
origin: "synthetic",
|
|
4748
|
+
controls: {
|
|
4749
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {},
|
|
4750
|
+
bypassCooldown: input.bypassCooldown,
|
|
4751
|
+
bypassMute: input.bypassMute,
|
|
4752
|
+
runActions: input.runActions
|
|
4753
|
+
},
|
|
4754
|
+
log: () => ({
|
|
4755
|
+
tags: { deviceId: input.deviceId },
|
|
4756
|
+
meta: {
|
|
4757
|
+
eventId: recordId,
|
|
4758
|
+
synthetic: true,
|
|
4759
|
+
trigger: input.trigger,
|
|
4760
|
+
...input.onlyRuleId !== void 0 ? { onlyRuleId: input.onlyRuleId } : {}
|
|
4761
|
+
}
|
|
4762
|
+
})
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
4765
|
+
/**
|
|
4766
|
+
* Turn one live JPEG into the media set the dispatcher's ladders look for.
|
|
4767
|
+
*
|
|
4768
|
+
* Every entry is the SAME photograph, differing only in how it was cut — which
|
|
4769
|
+
* is exactly what the ladders mean by their kind names, so nothing here claims
|
|
4770
|
+
* a frame it does not have:
|
|
4771
|
+
*
|
|
4772
|
+
* - `fullFrame` / `keyFrame` / `firstFrame` — the clean scene, verbatim.
|
|
4773
|
+
* - `fullFrameBoxed` — the clean scene with the operator's bbox drawn on it.
|
|
4774
|
+
* - `crop` / `thumbnail` — that bbox, cut out.
|
|
4775
|
+
*
|
|
4776
|
+
* With NO bbox only the clean-scene kinds are produced, so a rule asking for
|
|
4777
|
+
* `frame: 'cropped'` correctly finds nothing rather than being handed a whole
|
|
4778
|
+
* scene it did not ask for. sharp failures degrade the same way: the clean
|
|
4779
|
+
* scene always survives.
|
|
4780
|
+
*/
|
|
4781
|
+
async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
|
|
4782
|
+
const base64 = Buffer.from(jpeg).toString("base64");
|
|
4783
|
+
const clean = (kind) => ({
|
|
4784
|
+
key: `${SYNTHETIC_ID_PREFIX}${kind}`,
|
|
4785
|
+
kind,
|
|
4786
|
+
base64,
|
|
4787
|
+
sizeBytes: jpeg.byteLength,
|
|
4788
|
+
timestamp
|
|
4789
|
+
});
|
|
4790
|
+
const files = [
|
|
4791
|
+
clean("fullFrame"),
|
|
4792
|
+
clean("keyFrame"),
|
|
4793
|
+
clean("firstFrame")
|
|
4794
|
+
];
|
|
4795
|
+
if (bbox === void 0) return files;
|
|
4796
|
+
try {
|
|
4797
|
+
const { default: sharp$7 } = await import("sharp");
|
|
4798
|
+
const meta = await sharp$7(Buffer.from(jpeg)).metadata();
|
|
4799
|
+
const W = meta.width ?? 0;
|
|
4800
|
+
const H = meta.height ?? 0;
|
|
4801
|
+
if (W === 0 || H === 0) return files;
|
|
4802
|
+
const left = Math.max(0, Math.min(W - 1, Math.floor(bbox.x * W)));
|
|
4803
|
+
const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
|
|
4804
|
+
const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
|
|
4805
|
+
const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
|
|
4806
|
+
const cropBuf = await sharp$7(Buffer.from(jpeg)).extract({
|
|
4807
|
+
left,
|
|
4808
|
+
top,
|
|
4809
|
+
width,
|
|
4810
|
+
height
|
|
4811
|
+
}).jpeg({ quality: 85 }).toBuffer();
|
|
4812
|
+
const cropB64 = cropBuf.toString("base64");
|
|
4813
|
+
files.push({
|
|
4814
|
+
key: `${SYNTHETIC_ID_PREFIX}crop`,
|
|
4815
|
+
kind: "crop",
|
|
4816
|
+
base64: cropB64,
|
|
4817
|
+
sizeBytes: cropBuf.byteLength,
|
|
4818
|
+
timestamp
|
|
4819
|
+
}, {
|
|
4820
|
+
key: `${SYNTHETIC_ID_PREFIX}thumbnail`,
|
|
4821
|
+
kind: "thumbnail",
|
|
4822
|
+
base64: cropB64,
|
|
4823
|
+
sizeBytes: cropBuf.byteLength,
|
|
4824
|
+
timestamp
|
|
4825
|
+
});
|
|
4826
|
+
const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
|
|
4827
|
+
const overlay = Buffer.from(`<svg width="${W}" height="${H}"><rect x="${left}" y="${top}" width="${width}" height="${height}" fill="none" stroke="#22d3ee" stroke-width="${stroke}"/></svg>`);
|
|
4828
|
+
const boxedBuf = await sharp$7(Buffer.from(jpeg)).composite([{
|
|
4829
|
+
input: overlay,
|
|
4830
|
+
top: 0,
|
|
4831
|
+
left: 0
|
|
4832
|
+
}]).jpeg({ quality: 85 }).toBuffer();
|
|
4833
|
+
files.push({
|
|
4834
|
+
key: `${SYNTHETIC_ID_PREFIX}fullFrameBoxed`,
|
|
4835
|
+
kind: "fullFrameBoxed",
|
|
4836
|
+
base64: boxedBuf.toString("base64"),
|
|
4837
|
+
sizeBytes: boxedBuf.byteLength,
|
|
4838
|
+
timestamp
|
|
4839
|
+
});
|
|
4840
|
+
} catch {
|
|
4841
|
+
return files;
|
|
4842
|
+
}
|
|
4843
|
+
return files;
|
|
4844
|
+
}
|
|
4845
|
+
//#endregion
|
|
4862
4846
|
//#region src/notification-center/timelapse/timelapse-store.ts
|
|
4863
4847
|
var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
|
|
4864
4848
|
var NC_TIMELAPSE_RULES_COLUMNS = [
|
|
@@ -5697,10 +5681,16 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5697
5681
|
this.consumeEvent(incomingFromTrack(track, info));
|
|
5698
5682
|
}
|
|
5699
5683
|
/**
|
|
5700
|
-
* Called
|
|
5701
|
-
*
|
|
5702
|
-
* `device-event` trigger (doorbell press / sensor state
|
|
5703
|
-
* forget from the ingest loop; the outbox owns delivery
|
|
5684
|
+
* Called for each SensorEvent row (`ingestSensorStateChange` — one per linked
|
|
5685
|
+
* camera), once the same state change has had its chance to project a marker
|
|
5686
|
+
* track. Feeds the `device-event` trigger (doorbell press / sensor state
|
|
5687
|
+
* change). Fire-and-forget from the ingest loop; the outbox owns delivery.
|
|
5688
|
+
*
|
|
5689
|
+
* `markerTrackId` is that marker — the synthetic track carrying the camera's
|
|
5690
|
+
* frame — and it is what gives this notification a picture: the payload
|
|
5691
|
+
* freezes it as the media owner and the dispatcher resolves it through the
|
|
5692
|
+
* ladder every other attachment uses. Absent means no marker landed, and the
|
|
5693
|
+
* notification is text-only; that is logged, per camera, at the enqueue.
|
|
5704
5694
|
*
|
|
5705
5695
|
* Delivery-grade boundary (honest): the SensorEventStore is itself fed from
|
|
5706
5696
|
* the LOSSY `DeviceStateChanged` telemetry bus, and there is no NC crash-gap
|
|
@@ -5709,8 +5699,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5709
5699
|
* device-event notification — the durable guarantee begins at this hook, not
|
|
5710
5700
|
* before it.
|
|
5711
5701
|
*/
|
|
5712
|
-
onSensorEventPersisted(event) {
|
|
5713
|
-
this.consumeEvent(incomingFromSensorEvent(event));
|
|
5702
|
+
onSensorEventPersisted(event, markerTrackId) {
|
|
5703
|
+
this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
|
|
5714
5704
|
}
|
|
5715
5705
|
/**
|
|
5716
5706
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -5867,7 +5857,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5867
5857
|
}
|
|
5868
5858
|
/**
|
|
5869
5859
|
* Adopt the panel this node owns. Called once by the addon after
|
|
5870
|
-
* `
|
|
5860
|
+
* `declareAlarmPanel`; a node without one never calls it and the cap then
|
|
5871
5861
|
* answers `deviceId: null`.
|
|
5872
5862
|
*/
|
|
5873
5863
|
setAlarmPanel(panel) {
|
|
@@ -5997,6 +5987,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
5997
5987
|
}
|
|
5998
5988
|
const now = this.now();
|
|
5999
5989
|
let anyMatched = false;
|
|
5990
|
+
/** One "no media owner" line per EVENT, not per matching rule. */
|
|
5991
|
+
let noOwnerReported = false;
|
|
6000
5992
|
const rejections = [];
|
|
6001
5993
|
for (const rule of candidates) {
|
|
6002
5994
|
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
|
|
@@ -6079,6 +6071,18 @@ var NotificationCenter = class NotificationCenter {
|
|
|
6079
6071
|
buttons: unresolvable.join(", ")
|
|
6080
6072
|
}
|
|
6081
6073
|
});
|
|
6074
|
+
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
6075
|
+
noOwnerReported = true;
|
|
6076
|
+
this.logger.warn("device event has no marker track — this notification ships text-only", {
|
|
6077
|
+
tags: { deviceId: subject.deviceId },
|
|
6078
|
+
meta: {
|
|
6079
|
+
recordId: subject.recordId,
|
|
6080
|
+
...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
|
|
6081
|
+
ruleId: rule.id,
|
|
6082
|
+
rule: rule.name
|
|
6083
|
+
}
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
6082
6086
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
6083
6087
|
const entries = this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets, origin);
|
|
6084
6088
|
const inserted = await this.outbox.enqueue(entries);
|
|
@@ -6767,6 +6771,844 @@ function makeNcActionHandlers(deps) {
|
|
|
6767
6771
|
}
|
|
6768
6772
|
};
|
|
6769
6773
|
}
|
|
6774
|
+
var MAX_EXPORT_BYTES = 256 * 1024 * 1024;
|
|
6775
|
+
var DebugFindingSchema = require_dist.object({
|
|
6776
|
+
code: require_dist.string(),
|
|
6777
|
+
severity: require_dist._enum([
|
|
6778
|
+
"error",
|
|
6779
|
+
"warn",
|
|
6780
|
+
"info"
|
|
6781
|
+
]),
|
|
6782
|
+
detail: require_dist.string()
|
|
6783
|
+
});
|
|
6784
|
+
var DebugTrackListItemSchema = require_dist.object({
|
|
6785
|
+
trackId: require_dist.string(),
|
|
6786
|
+
deviceId: require_dist.number(),
|
|
6787
|
+
className: require_dist.string(),
|
|
6788
|
+
label: require_dist.string().optional(),
|
|
6789
|
+
firstSeen: require_dist.number(),
|
|
6790
|
+
lastSeen: require_dist.number(),
|
|
6791
|
+
durationMs: require_dist.number(),
|
|
6792
|
+
active: require_dist.boolean()
|
|
6793
|
+
});
|
|
6794
|
+
var DebugTrackReportSchema = require_dist.object({
|
|
6795
|
+
trackId: require_dist.string(),
|
|
6796
|
+
deviceId: require_dist.number(),
|
|
6797
|
+
className: require_dist.string(),
|
|
6798
|
+
label: require_dist.string().optional(),
|
|
6799
|
+
firstSeen: require_dist.number(),
|
|
6800
|
+
lastSeen: require_dist.number(),
|
|
6801
|
+
durationMs: require_dist.number(),
|
|
6802
|
+
active: require_dist.boolean(),
|
|
6803
|
+
mediaCount: require_dist.number(),
|
|
6804
|
+
mediaKinds: require_dist.array(require_dist.string()).readonly(),
|
|
6805
|
+
eventCount: require_dist.number(),
|
|
6806
|
+
positionCount: require_dist.number(),
|
|
6807
|
+
findings: require_dist.array(DebugFindingSchema).readonly()
|
|
6808
|
+
});
|
|
6809
|
+
var ListTracksInputSchema = require_dist.object({
|
|
6810
|
+
/** Empty or absent means every camera that has tracks. */
|
|
6811
|
+
deviceIds: require_dist.array(require_dist.number()).optional(),
|
|
6812
|
+
limit: require_dist.number().int().positive().max(200).optional()
|
|
6813
|
+
});
|
|
6814
|
+
var ListTracksOutputSchema = require_dist.object({
|
|
6815
|
+
tracks: require_dist.array(DebugTrackListItemSchema).readonly(),
|
|
6816
|
+
/**
|
|
6817
|
+
* True when a camera hit `limit`. Said rather than implied: a silently capped
|
|
6818
|
+
* list is how an operator concludes a mark did not take.
|
|
6819
|
+
*/
|
|
6820
|
+
truncated: require_dist.boolean()
|
|
6821
|
+
});
|
|
6822
|
+
var AnalyzeInputSchema = require_dist.object({ trackIds: require_dist.array(require_dist.string()).min(1).max(200) });
|
|
6823
|
+
var AnalyzeOutputSchema = require_dist.object({
|
|
6824
|
+
reports: require_dist.array(DebugTrackReportSchema).readonly(),
|
|
6825
|
+
/**
|
|
6826
|
+
* Ids that were asked for and no longer exist. Per D81 a `debug` mark does NOT
|
|
6827
|
+
* pin retention, so a marked track can be evicted between the list and the
|
|
6828
|
+
* analysis — reported as a number the tab can show rather than as silence.
|
|
6829
|
+
*/
|
|
6830
|
+
vanished: require_dist.array(require_dist.string()).readonly()
|
|
6831
|
+
});
|
|
6832
|
+
var debugActions = require_dist.defineCustomActions({
|
|
6833
|
+
"debug.listTracks": require_dist.customAction(ListTracksInputSchema, ListTracksOutputSchema, { auth: "admin" }),
|
|
6834
|
+
"debug.analyze": require_dist.customAction(AnalyzeInputSchema, AnalyzeOutputSchema, { auth: "admin" })
|
|
6835
|
+
});
|
|
6836
|
+
//#endregion
|
|
6837
|
+
//#region src/pipeline-analytics/debug/zip-stream.ts
|
|
6838
|
+
/**
|
|
6839
|
+
* A minimal STORE-only ZIP writer — enough to emit a debug bundle entry by
|
|
6840
|
+
* entry, without ever holding the archive in memory.
|
|
6841
|
+
*
|
|
6842
|
+
* ## Why hand-rolled, and why ZIP rather than the tar next door
|
|
6843
|
+
*
|
|
6844
|
+
* The archive the operator asked for is a ZIP, because it is the format that
|
|
6845
|
+
* opens with a double click on every desktop they use; the training exports use
|
|
6846
|
+
* ustar and that stays as it is. Both are hand-written here for the same two
|
|
6847
|
+
* reasons the {@link ../training-export/tar-stream.js tar writer} records:
|
|
6848
|
+
*
|
|
6849
|
+
* 1. **A dependency would not arrive.** A hub addon is deployed as its built
|
|
6850
|
+
* `dist/` and carries ZERO `node_modules` of its own — adding `yazl` or
|
|
6851
|
+
* `archiver` to `package.json` gets a module that is missing at runtime on
|
|
6852
|
+
* the node that actually runs the code. The only libraries an addon can rely
|
|
6853
|
+
* on are Node's builtins and what its bundler inlines.
|
|
6854
|
+
* 2. **Nothing to compress.** Every payload is JPEG or MP4, already compressed.
|
|
6855
|
+
* STORE (method 0) spends no CPU on the hub's one thread for a percent or
|
|
6856
|
+
* two, and — unlike DEFLATE — the entry's size and CRC are known before the
|
|
6857
|
+
* header is written, so no data descriptors and no seeking back: the writer
|
|
6858
|
+
* is append-only and therefore streamable.
|
|
6859
|
+
*
|
|
6860
|
+
* So: STORE-only ZIP, which is a 30-byte local header + verbatim body per entry
|
|
6861
|
+
* plus a central directory at the end, fully specified (APPNOTE 6.3.x), and
|
|
6862
|
+
* asserted against the system `unzip` binary in the spec next door rather than
|
|
6863
|
+
* against my reading of the format.
|
|
6864
|
+
*
|
|
6865
|
+
* ## What this deliberately does NOT do
|
|
6866
|
+
*
|
|
6867
|
+
* No ZIP64. The classic record set addresses 4 GiB and 65 535 entries, and this
|
|
6868
|
+
* archive is a hand-marked debug set — tens of tracks, not a dataset.
|
|
6869
|
+
* {@link ZipStreamWriter.fits} reports an entry that would cross either ceiling
|
|
6870
|
+
* so the caller can skip it and SAY it skipped it, rather than emitting an
|
|
6871
|
+
* archive with a wrapped offset that reads as corrupt.
|
|
6872
|
+
*
|
|
6873
|
+
* ## Entry names are ASCII, by construction
|
|
6874
|
+
*
|
|
6875
|
+
* The writer sets the UTF-8 name flag (bit 11) because that is what the format
|
|
6876
|
+
* says, but **it cannot be relied on**: the `unzip` shipped on macOS is Info-ZIP
|
|
6877
|
+
* 6.00, which ignores the flag and re-encodes the name from CP437 — `persona-è`
|
|
6878
|
+
* comes out of it as `persona-+¿`, and on a case where the mangled bytes are not
|
|
6879
|
+
* valid for the filesystem it refuses to extract the entry at all. Measured, not
|
|
6880
|
+
* assumed; the spec next door pins it.
|
|
6881
|
+
*
|
|
6882
|
+
* So {@link toAsciiZipName} is applied to every path, and — more importantly —
|
|
6883
|
+
* no free text goes into a path in the first place: an entry is addressed by
|
|
6884
|
+
* `deviceId` and `trackId`, and the camera name lives INSIDE `track.json` where
|
|
6885
|
+
* its encoding is JSON's problem and not the archive's.
|
|
6886
|
+
*/
|
|
6887
|
+
var LOCAL_SIG = 67324752;
|
|
6888
|
+
var CENTRAL_SIG = 33639248;
|
|
6889
|
+
var EOCD_SIG = 101010256;
|
|
6890
|
+
/** Bit 11 — the entry name is UTF-8. */
|
|
6891
|
+
var FLAG_UTF8 = 2048;
|
|
6892
|
+
/** STORE. */
|
|
6893
|
+
var METHOD_STORE = 0;
|
|
6894
|
+
/** PKZIP 2.0, the floor for STORE + UTF-8 names. */
|
|
6895
|
+
var VERSION = 20;
|
|
6896
|
+
/** The classic (non-ZIP64) ceilings. Crossing either needs ZIP64; we refuse. */
|
|
6897
|
+
var ZIP_MAX_BYTES = 4294967295;
|
|
6898
|
+
var CRC_TABLE = (() => {
|
|
6899
|
+
const table = new Array(256);
|
|
6900
|
+
for (let i = 0; i < 256; i++) {
|
|
6901
|
+
let c = i;
|
|
6902
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
6903
|
+
table[i] = c >>> 0;
|
|
6904
|
+
}
|
|
6905
|
+
return table;
|
|
6906
|
+
})();
|
|
6907
|
+
/**
|
|
6908
|
+
* Reduce a path to the ASCII set every ZIP reader agrees on.
|
|
6909
|
+
*
|
|
6910
|
+
* Anything outside `[A-Za-z0-9._-]` (and the `/` separator) becomes `_`. Not a
|
|
6911
|
+
* transliteration: a name that survives Info-ZIP's CP437 pass unchanged is the
|
|
6912
|
+
* only name this archive can promise, and a lossy-but-stable mapping is better
|
|
6913
|
+
* than a pretty one that a reader mangles differently from the manifest.
|
|
6914
|
+
*/
|
|
6915
|
+
function toAsciiZipName(name) {
|
|
6916
|
+
return name.replace(/[^A-Za-z0-9._/-]/g, "_");
|
|
6917
|
+
}
|
|
6918
|
+
/** CRC-32 (IEEE 802.3), the checksum every ZIP entry header carries. */
|
|
6919
|
+
function crc32(buf) {
|
|
6920
|
+
let c = 4294967295;
|
|
6921
|
+
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
|
|
6922
|
+
return (c ^ 4294967295) >>> 0;
|
|
6923
|
+
}
|
|
6924
|
+
/**
|
|
6925
|
+
* MS-DOS date/time, the only timestamp a classic ZIP record carries.
|
|
6926
|
+
*
|
|
6927
|
+
* Two-second resolution and an epoch of 1980 — a date before that is clamped
|
|
6928
|
+
* rather than allowed to write a negative year field, which some readers show as
|
|
6929
|
+
* 2107 and others reject outright.
|
|
6930
|
+
*/
|
|
6931
|
+
function dosDateTime(mtimeMs) {
|
|
6932
|
+
const d = new Date(mtimeMs);
|
|
6933
|
+
const year = Math.max(1980, d.getFullYear());
|
|
6934
|
+
return {
|
|
6935
|
+
time: d.getHours() << 11 | d.getMinutes() << 5 | d.getSeconds() >> 1,
|
|
6936
|
+
date: year - 1980 << 9 | d.getMonth() + 1 << 5 | d.getDate()
|
|
6937
|
+
};
|
|
6938
|
+
}
|
|
6939
|
+
/**
|
|
6940
|
+
* The 30-byte local header + name for one stored file.
|
|
6941
|
+
*
|
|
6942
|
+
* Sizes and CRC are written into the header itself (never a trailing data
|
|
6943
|
+
* descriptor) — STORE knows both before the body moves, and a header that tells
|
|
6944
|
+
* the truth is what lets a reader index the archive without scanning it.
|
|
6945
|
+
*/
|
|
6946
|
+
function zipLocalHeader(entry, crc, nameBytes) {
|
|
6947
|
+
const head = Buffer.alloc(30);
|
|
6948
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
6949
|
+
head.writeUInt32LE(LOCAL_SIG, 0);
|
|
6950
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6951
|
+
head.writeUInt16LE(FLAG_UTF8, 6);
|
|
6952
|
+
head.writeUInt16LE(METHOD_STORE, 8);
|
|
6953
|
+
head.writeUInt16LE(time, 10);
|
|
6954
|
+
head.writeUInt16LE(date, 12);
|
|
6955
|
+
head.writeUInt32LE(crc, 14);
|
|
6956
|
+
head.writeUInt32LE(entry.body.length, 18);
|
|
6957
|
+
head.writeUInt32LE(entry.body.length, 22);
|
|
6958
|
+
head.writeUInt16LE(nameBytes.length, 26);
|
|
6959
|
+
head.writeUInt16LE(0, 28);
|
|
6960
|
+
return Buffer.concat([head, nameBytes]);
|
|
6961
|
+
}
|
|
6962
|
+
function centralHeader(entry) {
|
|
6963
|
+
const head = Buffer.alloc(46);
|
|
6964
|
+
head.writeUInt32LE(CENTRAL_SIG, 0);
|
|
6965
|
+
head.writeUInt16LE(VERSION, 4);
|
|
6966
|
+
head.writeUInt16LE(VERSION, 6);
|
|
6967
|
+
head.writeUInt16LE(FLAG_UTF8, 8);
|
|
6968
|
+
head.writeUInt16LE(METHOD_STORE, 10);
|
|
6969
|
+
head.writeUInt16LE(entry.dosTime, 12);
|
|
6970
|
+
head.writeUInt16LE(entry.dosDate, 14);
|
|
6971
|
+
head.writeUInt32LE(entry.crc, 16);
|
|
6972
|
+
head.writeUInt32LE(entry.size, 20);
|
|
6973
|
+
head.writeUInt32LE(entry.size, 24);
|
|
6974
|
+
head.writeUInt16LE(entry.nameBytes.length, 28);
|
|
6975
|
+
head.writeUInt16LE(0, 30);
|
|
6976
|
+
head.writeUInt16LE(0, 32);
|
|
6977
|
+
head.writeUInt16LE(0, 34);
|
|
6978
|
+
head.writeUInt16LE(0, 36);
|
|
6979
|
+
head.writeUInt32LE(0, 38);
|
|
6980
|
+
head.writeUInt32LE(entry.offset, 42);
|
|
6981
|
+
return Buffer.concat([head, entry.nameBytes]);
|
|
6982
|
+
}
|
|
6983
|
+
function endOfCentralDirectory(count, size, offset) {
|
|
6984
|
+
const eocd = Buffer.alloc(22);
|
|
6985
|
+
eocd.writeUInt32LE(EOCD_SIG, 0);
|
|
6986
|
+
eocd.writeUInt16LE(0, 4);
|
|
6987
|
+
eocd.writeUInt16LE(0, 6);
|
|
6988
|
+
eocd.writeUInt16LE(count, 8);
|
|
6989
|
+
eocd.writeUInt16LE(count, 10);
|
|
6990
|
+
eocd.writeUInt32LE(size, 12);
|
|
6991
|
+
eocd.writeUInt32LE(offset, 16);
|
|
6992
|
+
eocd.writeUInt16LE(0, 20);
|
|
6993
|
+
return eocd;
|
|
6994
|
+
}
|
|
6995
|
+
/**
|
|
6996
|
+
* True when one more entry still fits inside the classic (non-ZIP64) ceilings.
|
|
6997
|
+
*
|
|
6998
|
+
* Pure, and asked with a length rather than a Buffer, so the 4 GiB boundary can
|
|
6999
|
+
* be tested without allocating 4 GiB — the guard whose failure mode (a wrapped
|
|
7000
|
+
* 32-bit offset, producing an archive that looks complete and unpacks as
|
|
7001
|
+
* garbage) is precisely the one a test must be able to reach.
|
|
7002
|
+
*/
|
|
7003
|
+
function zipEntryFits(query) {
|
|
7004
|
+
if (query.entryIndex >= 65535) return false;
|
|
7005
|
+
const nameLen = toAsciiZipName(query.name).length;
|
|
7006
|
+
return query.offset + 30 + nameLen + query.bodyLength <= ZIP_MAX_BYTES;
|
|
7007
|
+
}
|
|
7008
|
+
/**
|
|
7009
|
+
* An append-only ZIP writer.
|
|
7010
|
+
*
|
|
7011
|
+
* The caller supplies `write` — the real `res.write`, awaited, so backpressure
|
|
7012
|
+
* is the socket's. The writer holds only the central directory (one small record
|
|
7013
|
+
* per entry), never a body: {@link add} takes one file at a time and forgets it.
|
|
7014
|
+
*/
|
|
7015
|
+
var ZipStreamWriter = class {
|
|
7016
|
+
write;
|
|
7017
|
+
entries = [];
|
|
7018
|
+
offset = 0;
|
|
7019
|
+
constructor(write) {
|
|
7020
|
+
this.write = write;
|
|
7021
|
+
}
|
|
7022
|
+
/** Entries accepted so far — what the manifest should claim, not what was asked. */
|
|
7023
|
+
get entryCount() {
|
|
7024
|
+
return this.entries.length;
|
|
7025
|
+
}
|
|
7026
|
+
/** Bytes emitted so far, central directory excluded. */
|
|
7027
|
+
get bytesWritten() {
|
|
7028
|
+
return this.offset;
|
|
7029
|
+
}
|
|
7030
|
+
/**
|
|
7031
|
+
* True when this entry still fits inside the classic ZIP ceilings.
|
|
7032
|
+
*
|
|
7033
|
+
* Checked BEFORE the header goes out, because a 32-bit offset that wraps
|
|
7034
|
+
* produces an archive that looks complete and unpacks as garbage — the failure
|
|
7035
|
+
* this writer most needs to make impossible.
|
|
7036
|
+
*/
|
|
7037
|
+
fits(entry) {
|
|
7038
|
+
return zipEntryFits({
|
|
7039
|
+
offset: this.offset,
|
|
7040
|
+
entryIndex: this.entries.length,
|
|
7041
|
+
name: entry.name,
|
|
7042
|
+
bodyLength: entry.body.length
|
|
7043
|
+
});
|
|
7044
|
+
}
|
|
7045
|
+
/** Append one stored file. Returns false (and writes nothing) when it does not fit. */
|
|
7046
|
+
async add(entry) {
|
|
7047
|
+
if (!this.fits(entry)) return false;
|
|
7048
|
+
const nameBytes = Buffer.from(toAsciiZipName(entry.name), "ascii");
|
|
7049
|
+
const crc = crc32(entry.body);
|
|
7050
|
+
const { time, date } = dosDateTime(entry.mtimeMs);
|
|
7051
|
+
const header = zipLocalHeader(entry, crc, nameBytes);
|
|
7052
|
+
this.entries.push({
|
|
7053
|
+
nameBytes,
|
|
7054
|
+
crc,
|
|
7055
|
+
size: entry.body.length,
|
|
7056
|
+
offset: this.offset,
|
|
7057
|
+
dosTime: time,
|
|
7058
|
+
dosDate: date
|
|
7059
|
+
});
|
|
7060
|
+
await this.write(header);
|
|
7061
|
+
await this.write(entry.body);
|
|
7062
|
+
this.offset += header.length + entry.body.length;
|
|
7063
|
+
return true;
|
|
7064
|
+
}
|
|
7065
|
+
/** Write the central directory + EOCD. The archive is unreadable without it. */
|
|
7066
|
+
async finish() {
|
|
7067
|
+
const start = this.offset;
|
|
7068
|
+
let size = 0;
|
|
7069
|
+
for (const entry of this.entries) {
|
|
7070
|
+
const block = centralHeader(entry);
|
|
7071
|
+
await this.write(block);
|
|
7072
|
+
size += block.length;
|
|
7073
|
+
}
|
|
7074
|
+
await this.write(endOfCentralDirectory(this.entries.length, size, start));
|
|
7075
|
+
this.offset = start + size + 22;
|
|
7076
|
+
}
|
|
7077
|
+
};
|
|
7078
|
+
var DEBUG_MANIFEST_ENTRY = "manifest.json";
|
|
7079
|
+
var DEBUG_TRACK_ENTRY = "track.json";
|
|
7080
|
+
var DEBUG_ANALYSIS_ENTRY = "analysis.json";
|
|
7081
|
+
var DEBUG_EVENTS_ENTRY = "events.json";
|
|
7082
|
+
/** `tracks/<deviceId>/<trackId>` — ASCII by construction, both parts are ids. */
|
|
7083
|
+
function debugTrackDir(deviceId, trackId) {
|
|
7084
|
+
return toAsciiZipName(`tracks/${String(deviceId)}/${trackId}`);
|
|
7085
|
+
}
|
|
7086
|
+
/**
|
|
7087
|
+
* Resolve every media row of one track to an archive-relative name.
|
|
7088
|
+
*
|
|
7089
|
+
* Deterministic: the input order (the store returns `timestamp asc`) fully
|
|
7090
|
+
* determines the output, so re-exporting an unchanged track produces the same
|
|
7091
|
+
* names. A kind that occurs once is bare (`keyFrame.jpg`); a kind that repeats
|
|
7092
|
+
* carries its timestamp; a genuine duplicate of both gets `-2`, `-3`, …
|
|
7093
|
+
*/
|
|
7094
|
+
function planTrackMediaNames(media) {
|
|
7095
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
7096
|
+
for (const m of media) occurrences.set(m.kind, (occurrences.get(m.kind) ?? 0) + 1);
|
|
7097
|
+
const used = /* @__PURE__ */ new Set();
|
|
7098
|
+
const out = [];
|
|
7099
|
+
for (const m of media) {
|
|
7100
|
+
const base = (occurrences.get(m.kind) ?? 0) > 1 ? `${m.kind}-${String(m.timestamp)}` : m.kind;
|
|
7101
|
+
let name = `media/${base}.jpg`;
|
|
7102
|
+
for (let n = 2; used.has(name); n++) name = `media/${base}-${String(n)}.jpg`;
|
|
7103
|
+
used.add(name);
|
|
7104
|
+
out.push({
|
|
7105
|
+
key: m.key,
|
|
7106
|
+
name: toAsciiZipName(name),
|
|
7107
|
+
kind: m.kind,
|
|
7108
|
+
timestamp: m.timestamp,
|
|
7109
|
+
sizeBytes: m.sizeBytes
|
|
7110
|
+
});
|
|
7111
|
+
}
|
|
7112
|
+
return out;
|
|
7113
|
+
}
|
|
7114
|
+
var DEBUG_RETENTION_NOTE = "A `debug` mark does not pin retention (D81). A track with no media here may have been evicted rather than never captured.";
|
|
7115
|
+
/** The archive filename, stamped so two downloads never collide in ~/Downloads. */
|
|
7116
|
+
function debugArchiveFilename(nowMs) {
|
|
7117
|
+
return `camstack-debug-${new Date(nowMs).toISOString().replace(/[:.]/g, "-")}.zip`.replace(/[\r\n"]/g, "");
|
|
7118
|
+
}
|
|
7119
|
+
/** Parse `?deviceIds=615,617` — empty means every camera. */
|
|
7120
|
+
function parseDebugDeviceIds(url) {
|
|
7121
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
7122
|
+
const raw = new URLSearchParams(query).get("deviceIds");
|
|
7123
|
+
if (raw === null || raw.trim() === "") return [];
|
|
7124
|
+
const out = [];
|
|
7125
|
+
for (const part of raw.split(",")) {
|
|
7126
|
+
const n = Number.parseInt(part.trim(), 10);
|
|
7127
|
+
if (Number.isInteger(n) && n > 0 && !out.includes(n)) out.push(n);
|
|
7128
|
+
}
|
|
7129
|
+
return out;
|
|
7130
|
+
}
|
|
7131
|
+
//#endregion
|
|
7132
|
+
//#region src/pipeline-analytics/data-plane-access.ts
|
|
7133
|
+
/**
|
|
7134
|
+
* Prefix literals for the export planes, mirrored here so the policy reads as
|
|
7135
|
+
* a table without importing three handler modules into the addon's boot path.
|
|
7136
|
+
* `data-plane-access.spec.ts` asserts they still match the constants their
|
|
7137
|
+
* handlers register with — a mirror nothing checks is a lie waiting to happen.
|
|
7138
|
+
*/
|
|
7139
|
+
var TRAINING_EXPORT_PREFIX_REF = "training-export";
|
|
7140
|
+
var RETRAIN_EXPORT_PREFIX_REF = "retrain-export";
|
|
7141
|
+
var DEBUG_EXPORT_PREFIX_REF = "debug-export";
|
|
7142
|
+
/**
|
|
7143
|
+
* Every data plane this addon serves, and the level the HUB enforces before it
|
|
7144
|
+
* proxies. Exhaustive by test: a new plane that forgets to appear here fails
|
|
7145
|
+
* `data-plane-access.spec.ts`.
|
|
7146
|
+
*/
|
|
7147
|
+
var PIPELINE_ANALYTICS_PLANE_ACCESS = {
|
|
7148
|
+
/** One stored crop/thumbnail by an unguessable id the caller already holds. */
|
|
7149
|
+
"event-media": "authenticated",
|
|
7150
|
+
/** Self-authenticating single-use action token (D47). */
|
|
7151
|
+
"nc-action": "public",
|
|
7152
|
+
/** Self-authenticating single-use artifact token (D47). */
|
|
7153
|
+
"nc-artifact": "public",
|
|
7154
|
+
/** Every frame behind a marked selection — a corpus, not an item. */
|
|
7155
|
+
[TRAINING_EXPORT_PREFIX_REF]: "admin",
|
|
7156
|
+
[RETRAIN_EXPORT_PREFIX_REF]: "admin",
|
|
7157
|
+
/** Diagnostic bundle: logs, config and frames. */
|
|
7158
|
+
[DEBUG_EXPORT_PREFIX_REF]: "admin"
|
|
7159
|
+
};
|
|
7160
|
+
/** The declared level for a plane prefix. Typed, so a prefix with no declared
|
|
7161
|
+
* policy is a compile error rather than a silent default. */
|
|
7162
|
+
function planeAccess(prefix) {
|
|
7163
|
+
return PIPELINE_ANALYTICS_PLANE_ACCESS[prefix];
|
|
7164
|
+
}
|
|
7165
|
+
//#endregion
|
|
7166
|
+
//#region src/pipeline-analytics/debug/debug-export-handler.ts
|
|
7167
|
+
/**
|
|
7168
|
+
* Pacing. The export is a BACKGROUND job sharing a thread with live decode, so
|
|
7169
|
+
* it yields twice: between tracks, and between individual media files.
|
|
7170
|
+
*
|
|
7171
|
+
* The per-file yield is the one that matters. A track can own twenty blobs, and
|
|
7172
|
+
* reading them back to back — each a base64 decode of a few hundred KB —
|
|
7173
|
+
* occupies the loop for long enough that snapshot resizes start timing out. One
|
|
7174
|
+
* file at a time, with a gap, keeps the export off the critical path; it is slow
|
|
7175
|
+
* on purpose.
|
|
7176
|
+
*/
|
|
7177
|
+
var TRACK_PACING_MS = 25;
|
|
7178
|
+
var MEDIA_PACING_MS = 15;
|
|
7179
|
+
function json(value) {
|
|
7180
|
+
return Buffer.from(JSON.stringify(value, null, 2), "utf8");
|
|
7181
|
+
}
|
|
7182
|
+
/** Total media rows the plan hopes to ship — the number HEAD reports. */
|
|
7183
|
+
function plannedMediaCount(plan) {
|
|
7184
|
+
let total = 0;
|
|
7185
|
+
for (const t of plan.tracks) total += t.media.length;
|
|
7186
|
+
return total;
|
|
7187
|
+
}
|
|
7188
|
+
function createDebugExportHandler(deps) {
|
|
7189
|
+
return async (req, res) => {
|
|
7190
|
+
const method = req.method ?? "GET";
|
|
7191
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
7192
|
+
res.writeHead(405, { allow: "GET, HEAD" });
|
|
7193
|
+
res.end();
|
|
7194
|
+
return;
|
|
7195
|
+
}
|
|
7196
|
+
const deviceIds = parseDebugDeviceIds(req.url ?? "");
|
|
7197
|
+
let plan;
|
|
7198
|
+
try {
|
|
7199
|
+
plan = await deps.buildPlan(deviceIds);
|
|
7200
|
+
} catch (err) {
|
|
7201
|
+
deps.logger.warn("debug export plan failed", { meta: { error: String(err) } });
|
|
7202
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
7203
|
+
res.end("debug export plan failed");
|
|
7204
|
+
return;
|
|
7205
|
+
}
|
|
7206
|
+
res.writeHead(200, {
|
|
7207
|
+
"content-type": "application/zip",
|
|
7208
|
+
"content-disposition": `attachment; filename="${debugArchiveFilename(deps.now())}"`,
|
|
7209
|
+
"cache-control": "no-store",
|
|
7210
|
+
"x-camstack-debug-tracks": String(plan.tracks.length),
|
|
7211
|
+
"x-camstack-debug-media": String(plannedMediaCount(plan))
|
|
7212
|
+
});
|
|
7213
|
+
if (method === "HEAD") {
|
|
7214
|
+
res.end();
|
|
7215
|
+
return;
|
|
7216
|
+
}
|
|
7217
|
+
const write = (chunk) => new Promise((resolve, reject) => {
|
|
7218
|
+
if (!res.write(chunk, (err) => {
|
|
7219
|
+
if (err) reject(err);
|
|
7220
|
+
else resolve();
|
|
7221
|
+
})) res.once("drain", resolve);
|
|
7222
|
+
});
|
|
7223
|
+
const zip = new ZipStreamWriter(write);
|
|
7224
|
+
const mtimeMs = deps.now();
|
|
7225
|
+
const startedAt = deps.now();
|
|
7226
|
+
const manifestTracks = [];
|
|
7227
|
+
let included = 0;
|
|
7228
|
+
let missing = 0;
|
|
7229
|
+
let stoppedBecause = plan.capped ? "track-cap" : null;
|
|
7230
|
+
for (const entry of plan.tracks) {
|
|
7231
|
+
if (zip.bytesWritten >= 268435456) {
|
|
7232
|
+
stoppedBecause ??= "byte-budget";
|
|
7233
|
+
deps.logger.warn("debug export stopped — byte budget reached", { meta: {
|
|
7234
|
+
budget: MAX_EXPORT_BYTES,
|
|
7235
|
+
written: zip.bytesWritten
|
|
7236
|
+
} });
|
|
7237
|
+
break;
|
|
7238
|
+
}
|
|
7239
|
+
const dir = debugTrackDir(entry.report.deviceId, entry.report.trackId);
|
|
7240
|
+
const deviceId = entry.report.deviceId;
|
|
7241
|
+
for (const [name, body] of [
|
|
7242
|
+
[DEBUG_TRACK_ENTRY, json(entry.track)],
|
|
7243
|
+
[DEBUG_ANALYSIS_ENTRY, json(entry.report)],
|
|
7244
|
+
[DEBUG_EVENTS_ENTRY, json(entry.events)]
|
|
7245
|
+
]) if (!await zip.add({
|
|
7246
|
+
name: `${dir}/${name}`,
|
|
7247
|
+
body,
|
|
7248
|
+
mtimeMs
|
|
7249
|
+
})) stoppedBecause ??= "zip-ceiling";
|
|
7250
|
+
const mediaIncluded = [];
|
|
7251
|
+
const mediaMissing = [];
|
|
7252
|
+
for (const media of entry.media) {
|
|
7253
|
+
const bytes = await deps.readMedia(media.key);
|
|
7254
|
+
if (bytes === null) {
|
|
7255
|
+
missing += 1;
|
|
7256
|
+
mediaMissing.push(media.key);
|
|
7257
|
+
deps.logger.warn("debug export skipped a media file — the blob is unreadable", {
|
|
7258
|
+
tags: { deviceId },
|
|
7259
|
+
meta: {
|
|
7260
|
+
trackId: entry.report.trackId,
|
|
7261
|
+
mediaKey: media.key,
|
|
7262
|
+
kind: media.kind
|
|
7263
|
+
}
|
|
7264
|
+
});
|
|
7265
|
+
continue;
|
|
7266
|
+
}
|
|
7267
|
+
if (!await zip.add({
|
|
7268
|
+
name: `${dir}/${media.name}`,
|
|
7269
|
+
body: bytes,
|
|
7270
|
+
mtimeMs
|
|
7271
|
+
})) {
|
|
7272
|
+
stoppedBecause ??= "zip-ceiling";
|
|
7273
|
+
missing += 1;
|
|
7274
|
+
mediaMissing.push(media.key);
|
|
7275
|
+
deps.logger.warn("debug export skipped a media file — the archive is full", {
|
|
7276
|
+
tags: { deviceId },
|
|
7277
|
+
meta: {
|
|
7278
|
+
trackId: entry.report.trackId,
|
|
7279
|
+
mediaKey: media.key,
|
|
7280
|
+
kind: media.kind
|
|
7281
|
+
}
|
|
7282
|
+
});
|
|
7283
|
+
continue;
|
|
7284
|
+
}
|
|
7285
|
+
included += 1;
|
|
7286
|
+
mediaIncluded.push(media.name);
|
|
7287
|
+
await new Promise((resolve) => setTimeout(resolve, MEDIA_PACING_MS));
|
|
7288
|
+
}
|
|
7289
|
+
manifestTracks.push({
|
|
7290
|
+
trackId: entry.report.trackId,
|
|
7291
|
+
deviceId,
|
|
7292
|
+
dir,
|
|
7293
|
+
className: entry.report.className,
|
|
7294
|
+
...entry.report.label === void 0 ? {} : { label: entry.report.label },
|
|
7295
|
+
firstSeen: entry.report.firstSeen,
|
|
7296
|
+
lastSeen: entry.report.lastSeen,
|
|
7297
|
+
durationMs: entry.report.durationMs,
|
|
7298
|
+
eventCount: entry.report.eventCount,
|
|
7299
|
+
findings: entry.report.findings.map((f) => f.code),
|
|
7300
|
+
mediaIncluded,
|
|
7301
|
+
mediaMissing
|
|
7302
|
+
});
|
|
7303
|
+
await new Promise((resolve) => setTimeout(resolve, TRACK_PACING_MS));
|
|
7304
|
+
}
|
|
7305
|
+
const manifest = {
|
|
7306
|
+
generatedAt: plan.generatedAt,
|
|
7307
|
+
deviceIds: plan.deviceIds,
|
|
7308
|
+
trackCount: manifestTracks.length,
|
|
7309
|
+
mediaIncluded: included,
|
|
7310
|
+
mediaMissing: missing,
|
|
7311
|
+
truncated: stoppedBecause !== null,
|
|
7312
|
+
stoppedBecause,
|
|
7313
|
+
retentionNote: DEBUG_RETENTION_NOTE,
|
|
7314
|
+
tracks: manifestTracks
|
|
7315
|
+
};
|
|
7316
|
+
await zip.add({
|
|
7317
|
+
name: DEBUG_MANIFEST_ENTRY,
|
|
7318
|
+
body: json(manifest),
|
|
7319
|
+
mtimeMs
|
|
7320
|
+
});
|
|
7321
|
+
await zip.finish();
|
|
7322
|
+
await new Promise((resolve) => {
|
|
7323
|
+
res.end(resolve);
|
|
7324
|
+
});
|
|
7325
|
+
deps.logger.info("debug export complete", { meta: {
|
|
7326
|
+
tracks: manifestTracks.length,
|
|
7327
|
+
mediaIncluded: included,
|
|
7328
|
+
mediaMissing: missing,
|
|
7329
|
+
stoppedBecause,
|
|
7330
|
+
bytes: zip.bytesWritten,
|
|
7331
|
+
elapsedMs: deps.now() - startedAt
|
|
7332
|
+
} });
|
|
7333
|
+
};
|
|
7334
|
+
}
|
|
7335
|
+
//#endregion
|
|
7336
|
+
//#region src/pipeline-analytics/debug/debug-track-analysis.ts
|
|
7337
|
+
/** A track is "short" below this — the pipeline rarely produces a full media set. */
|
|
7338
|
+
var SHORT_TRACK_MS = 2e3;
|
|
7339
|
+
/** Below this, the label the track settled on is weak enough to be worth saying. */
|
|
7340
|
+
var LOW_BEST_SCORE = .5;
|
|
7341
|
+
/** Duration in ms. Never negative — a clock that went backwards reads as 0. */
|
|
7342
|
+
function trackDurationMs(facts) {
|
|
7343
|
+
return Math.max(0, facts.lastSeen - facts.firstSeen);
|
|
7344
|
+
}
|
|
7345
|
+
function hasKind(media, kind) {
|
|
7346
|
+
return media.some((m) => m.kind === kind);
|
|
7347
|
+
}
|
|
7348
|
+
function latestMediaAt(media) {
|
|
7349
|
+
let latest = null;
|
|
7350
|
+
for (const m of media) if (latest === null || m.timestamp > latest) latest = m.timestamp;
|
|
7351
|
+
return latest;
|
|
7352
|
+
}
|
|
7353
|
+
function finding(code, severity, detail) {
|
|
7354
|
+
return {
|
|
7355
|
+
code,
|
|
7356
|
+
severity,
|
|
7357
|
+
detail
|
|
7358
|
+
};
|
|
7359
|
+
}
|
|
7360
|
+
/**
|
|
7361
|
+
* Analyse one track. Pure — same inventory in, same findings out.
|
|
7362
|
+
*
|
|
7363
|
+
* Order is stable and roughly "worst first": the media the whole rest of the
|
|
7364
|
+
* system reads, then the track's own substance, then the softer signals.
|
|
7365
|
+
*/
|
|
7366
|
+
function analyseDebugTrack(inventory) {
|
|
7367
|
+
const { facts, media, eventCount } = inventory;
|
|
7368
|
+
const durationMs = trackDurationMs(facts);
|
|
7369
|
+
const closed = !facts.active;
|
|
7370
|
+
const out = [];
|
|
7371
|
+
if (media.length === 0) out.push(finding("MEDIA_EVICTED_OR_NEVER", "error", "No media at all — either nothing was captured, or it was evicted (a debug mark does not pin retention)."));
|
|
7372
|
+
else if (closed) {
|
|
7373
|
+
if (!hasKind(media, "keyFrame")) out.push(finding("MISSING_KEYFRAME", "error", "No keyFrame — the best-shot frame is absent."));
|
|
7374
|
+
if (!hasKind(media, "firstFrame")) out.push(finding("MISSING_FIRSTFRAME", "warn", "No firstFrame for a closed track."));
|
|
7375
|
+
if (!hasKind(media, "lastFrame")) out.push(finding("MISSING_LASTFRAME", "warn", "No lastFrame for a closed track."));
|
|
7376
|
+
if (!hasKind(media, "thumbnail")) out.push(finding("MISSING_THUMBNAIL", "warn", "No thumbnail — every list surface renders blank."));
|
|
7377
|
+
if (!hasKind(media, "crop") && !hasKind(media, "faceCrop") && !hasKind(media, "plateCrop")) out.push(finding("MISSING_BEST_CROP", "warn", "No crop of any kind — nothing was cut for a subject."));
|
|
7378
|
+
}
|
|
7379
|
+
if (durationMs < 2e3) out.push(finding("SHORT_TRACK", "warn", `Track lasted ${String(durationMs)} ms, under the ${String(SHORT_TRACK_MS)} ms floor — likely a fragment.`));
|
|
7380
|
+
if (eventCount === 0) out.push(finding("ZERO_EVENTS", "error", "No object events — the track exists but recorded nothing."));
|
|
7381
|
+
if (facts.positionCount === 0) out.push(finding("ZERO_POSITIONS", "error", "No positions — there is no trajectory to reconstruct."));
|
|
7382
|
+
if (durationMs >= 1e4 && facts.snapshotCount === 0 && !hasKind(media, "snapshot")) out.push(finding("NO_PERIODIC_SNAPSHOTS", "warn", `No periodic snapshot over ${String(durationMs)} ms — the movement gate never opened.`));
|
|
7383
|
+
if (closed && hasKind(media, "lastFrame")) {
|
|
7384
|
+
const lastFrameAt = media.filter((m) => m.kind === "lastFrame").reduce((max, m) => Math.max(max, m.timestamp), 0);
|
|
7385
|
+
const lag = facts.lastSeen - lastFrameAt;
|
|
7386
|
+
if (lag > 2e3) out.push(finding("LASTFRAME_STALE", "warn", `lastFrame is ${String(lag)} ms older than the track's end.`));
|
|
7387
|
+
}
|
|
7388
|
+
if (closed && durationMs >= 1e4 && media.length > 0) {
|
|
7389
|
+
const latest = latestMediaAt(media);
|
|
7390
|
+
if (latest !== null) {
|
|
7391
|
+
const covered = (latest - facts.firstSeen) / durationMs;
|
|
7392
|
+
if (covered < .5) out.push(finding("MEDIA_STOPPED_EARLY", "warn", `Media stops at ${String(Math.round(covered * 100))}% of the track's span.`));
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
if (closed && facts.bestEventId === void 0) out.push(finding("NO_BEST_EVENT", "info", "No representative event was chosen for this track."));
|
|
7396
|
+
if (facts.labelScore !== void 0 && facts.labelScore < .5) out.push(finding("LOW_BEST_SCORE", "info", `Best label score ${facts.labelScore.toFixed(2)} is under ${String(LOW_BEST_SCORE)}.`));
|
|
7397
|
+
return out;
|
|
7398
|
+
}
|
|
7399
|
+
/** Fold an inventory into the report shape, findings included. */
|
|
7400
|
+
function buildDebugTrackReport(inventory) {
|
|
7401
|
+
const { facts, media, eventCount } = inventory;
|
|
7402
|
+
const kinds = [...new Set(media.map((m) => m.kind))].toSorted();
|
|
7403
|
+
return {
|
|
7404
|
+
trackId: facts.trackId,
|
|
7405
|
+
deviceId: facts.deviceId,
|
|
7406
|
+
className: facts.className,
|
|
7407
|
+
...facts.label === void 0 ? {} : { label: facts.label },
|
|
7408
|
+
firstSeen: facts.firstSeen,
|
|
7409
|
+
lastSeen: facts.lastSeen,
|
|
7410
|
+
durationMs: trackDurationMs(facts),
|
|
7411
|
+
active: facts.active,
|
|
7412
|
+
mediaCount: media.length,
|
|
7413
|
+
mediaKinds: kinds,
|
|
7414
|
+
eventCount,
|
|
7415
|
+
positionCount: facts.positionCount,
|
|
7416
|
+
findings: analyseDebugTrack(inventory)
|
|
7417
|
+
};
|
|
7418
|
+
}
|
|
7419
|
+
//#endregion
|
|
7420
|
+
//#region src/pipeline-analytics/debug/debug-service.ts
|
|
7421
|
+
/**
|
|
7422
|
+
* The three media owners one track's files hang off.
|
|
7423
|
+
*
|
|
7424
|
+
* A track's face and plate crops are NOT owned by `track:<trackId>` — they live
|
|
7425
|
+
* under `face-<trackId>` and `plate-<trackId>`. An inventory that reads only the
|
|
7426
|
+
* first owner reports MISSING_BEST_CROP on every track that produced a face,
|
|
7427
|
+
* which is the opposite of the truth.
|
|
7428
|
+
*/
|
|
7429
|
+
var MEDIA_OWNERS = [
|
|
7430
|
+
{
|
|
7431
|
+
kind: "track",
|
|
7432
|
+
prefix: ""
|
|
7433
|
+
},
|
|
7434
|
+
{
|
|
7435
|
+
kind: "face",
|
|
7436
|
+
prefix: "face-"
|
|
7437
|
+
},
|
|
7438
|
+
{
|
|
7439
|
+
kind: "plate",
|
|
7440
|
+
prefix: "plate-"
|
|
7441
|
+
}
|
|
7442
|
+
];
|
|
7443
|
+
function toFacts(track) {
|
|
7444
|
+
return {
|
|
7445
|
+
trackId: track.trackId,
|
|
7446
|
+
deviceId: track.deviceId,
|
|
7447
|
+
className: track.className,
|
|
7448
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7449
|
+
...track.labelScore === void 0 ? {} : { labelScore: track.labelScore },
|
|
7450
|
+
firstSeen: track.firstSeen,
|
|
7451
|
+
lastSeen: track.lastSeen,
|
|
7452
|
+
active: track.active,
|
|
7453
|
+
positionCount: track.positions.length,
|
|
7454
|
+
snapshotCount: track.snapshots.length,
|
|
7455
|
+
...track.importance === void 0 ? {} : { importance: track.importance },
|
|
7456
|
+
...track.bestEventId === void 0 ? {} : { bestEventId: track.bestEventId }
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
function toListItem(track) {
|
|
7460
|
+
return {
|
|
7461
|
+
trackId: track.trackId,
|
|
7462
|
+
deviceId: track.deviceId,
|
|
7463
|
+
className: track.className,
|
|
7464
|
+
...track.label === void 0 ? {} : { label: track.label },
|
|
7465
|
+
firstSeen: track.firstSeen,
|
|
7466
|
+
lastSeen: track.lastSeen,
|
|
7467
|
+
durationMs: trackDurationMs(toFacts(track)),
|
|
7468
|
+
active: track.active
|
|
7469
|
+
};
|
|
7470
|
+
}
|
|
7471
|
+
/** Which cameras to sweep — the caller's list, else every camera with tracks. */
|
|
7472
|
+
async function resolveDeviceIds(deps, requested) {
|
|
7473
|
+
if (requested !== void 0 && requested.length > 0) return requested;
|
|
7474
|
+
return deps.listDeviceIds();
|
|
7475
|
+
}
|
|
7476
|
+
/**
|
|
7477
|
+
* The marked set, newest first per camera.
|
|
7478
|
+
*
|
|
7479
|
+
* One indexed query per camera and nothing else — no media, no events, and no
|
|
7480
|
+
* trajectory: `listDebug` reads `slim` rows, so nothing here parses the
|
|
7481
|
+
* `positions` JSON. The counts an operator wants (media, events, positions) come
|
|
7482
|
+
* from `debug.analyze`, per track, by id.
|
|
7483
|
+
*
|
|
7484
|
+
* A camera that hits `limit` sets `truncated`, because a list that silently
|
|
7485
|
+
* stops at 200 is how an operator concludes a mark did not take.
|
|
7486
|
+
*/
|
|
7487
|
+
async function listDebugTracks(deps, input) {
|
|
7488
|
+
const limit = input.limit ?? 200;
|
|
7489
|
+
const deviceIds = await resolveDeviceIds(deps, input.deviceIds);
|
|
7490
|
+
const tracks = [];
|
|
7491
|
+
let truncated = false;
|
|
7492
|
+
for (const deviceId of deviceIds) {
|
|
7493
|
+
const rows = await deps.listDebugTracks(deviceId, limit);
|
|
7494
|
+
if (rows.length >= limit) {
|
|
7495
|
+
truncated = true;
|
|
7496
|
+
deps.logger.warn("debug listTracks hit the per-camera cap", {
|
|
7497
|
+
tags: { deviceId },
|
|
7498
|
+
meta: {
|
|
7499
|
+
deviceId,
|
|
7500
|
+
limit
|
|
7501
|
+
}
|
|
7502
|
+
});
|
|
7503
|
+
}
|
|
7504
|
+
for (const row of rows) tracks.push(toListItem(row));
|
|
7505
|
+
}
|
|
7506
|
+
return {
|
|
7507
|
+
tracks: tracks.toSorted((a, b) => b.lastSeen - a.lastSeen),
|
|
7508
|
+
truncated
|
|
7509
|
+
};
|
|
7510
|
+
}
|
|
7511
|
+
/**
|
|
7512
|
+
* Gather one track's whole inventory: three media queries plus its events.
|
|
7513
|
+
*
|
|
7514
|
+
* The events are RETURNED as well as counted — the bundle writes them to
|
|
7515
|
+
* `events.json`, and fetching them a second time there would double the query
|
|
7516
|
+
* count of an export for a number the caller already holds.
|
|
7517
|
+
*/
|
|
7518
|
+
async function gatherTrack(deps, track) {
|
|
7519
|
+
const media = [];
|
|
7520
|
+
for (const owner of MEDIA_OWNERS) media.push(...await deps.listMediaInfo(owner.kind, `${owner.prefix}${track.trackId}`));
|
|
7521
|
+
const events = await deps.listEvents(track.trackId);
|
|
7522
|
+
return {
|
|
7523
|
+
inventory: {
|
|
7524
|
+
facts: toFacts(track),
|
|
7525
|
+
media,
|
|
7526
|
+
eventCount: events.length
|
|
7527
|
+
},
|
|
7528
|
+
events
|
|
7529
|
+
};
|
|
7530
|
+
}
|
|
7531
|
+
/**
|
|
7532
|
+
* Analyse the named tracks.
|
|
7533
|
+
*
|
|
7534
|
+
* An id that no longer resolves is reported in `vanished` rather than dropped: a
|
|
7535
|
+
* `debug` mark does not pin retention (D81), so a track CAN disappear between
|
|
7536
|
+
* the list and the analysis, and the tab has to be able to say so instead of
|
|
7537
|
+
* quietly showing fewer rows than it listed.
|
|
7538
|
+
*/
|
|
7539
|
+
async function analyzeDebugTracks(deps, input) {
|
|
7540
|
+
const reports = [];
|
|
7541
|
+
const vanished = [];
|
|
7542
|
+
for (const trackId of input.trackIds) {
|
|
7543
|
+
const track = await deps.getTrack(trackId);
|
|
7544
|
+
if (track === null) {
|
|
7545
|
+
vanished.push(trackId);
|
|
7546
|
+
deps.logger.info("debug analyze — track no longer exists", { meta: { trackId } });
|
|
7547
|
+
continue;
|
|
7548
|
+
}
|
|
7549
|
+
const gathered = await gatherTrack(deps, track);
|
|
7550
|
+
reports.push(buildDebugTrackReport(gathered.inventory));
|
|
7551
|
+
}
|
|
7552
|
+
return {
|
|
7553
|
+
reports,
|
|
7554
|
+
vanished
|
|
7555
|
+
};
|
|
7556
|
+
}
|
|
7557
|
+
/**
|
|
7558
|
+
* Plan the bundle — INDEX READS ONLY, no blob touched.
|
|
7559
|
+
*
|
|
7560
|
+
* The plan is what the manifest is built from and what the handler streams
|
|
7561
|
+
* against, so it must be produced in one pass: a plan that re-queried while
|
|
7562
|
+
* streaming could describe a set the archive does not contain.
|
|
7563
|
+
*
|
|
7564
|
+
* **Bounded at {@link MAX_EXPORT_TRACKS}, far below the list cap.** Listing is
|
|
7565
|
+
* index rows; exporting is every blob those rows point at, base64-decoded and
|
|
7566
|
+
* pushed through the hub's one thread. An unbounded "export all" is a
|
|
7567
|
+
* self-inflicted outage on a busy hub, so the plan stops and SAYS it stopped
|
|
7568
|
+
* rather than quietly shipping less than the button offered.
|
|
7569
|
+
*
|
|
7570
|
+
* Each row is re-read at full projection: `listDebugTracks` deliberately returns
|
|
7571
|
+
* `slim` rows, and a bundle built from those would report ZERO_POSITIONS on
|
|
7572
|
+
* every track and write an empty `positions` array into `track.json`.
|
|
7573
|
+
*/
|
|
7574
|
+
async function buildDebugBundlePlan(deps, requestedDeviceIds) {
|
|
7575
|
+
const deviceIds = await resolveDeviceIds(deps, requestedDeviceIds);
|
|
7576
|
+
const tracks = [];
|
|
7577
|
+
const covered = [];
|
|
7578
|
+
let capped = false;
|
|
7579
|
+
for (const deviceId of deviceIds) {
|
|
7580
|
+
if (tracks.length >= 25) {
|
|
7581
|
+
capped = true;
|
|
7582
|
+
break;
|
|
7583
|
+
}
|
|
7584
|
+
const rows = await deps.listDebugTracks(deviceId, 200);
|
|
7585
|
+
if (rows.length > 0) covered.push(deviceId);
|
|
7586
|
+
for (const row of rows) {
|
|
7587
|
+
if (tracks.length >= 25) {
|
|
7588
|
+
capped = true;
|
|
7589
|
+
break;
|
|
7590
|
+
}
|
|
7591
|
+
const full = await deps.getTrack(row.trackId) ?? row;
|
|
7592
|
+
const { inventory, events } = await gatherTrack(deps, full);
|
|
7593
|
+
tracks.push({
|
|
7594
|
+
report: buildDebugTrackReport(inventory),
|
|
7595
|
+
track: full,
|
|
7596
|
+
events,
|
|
7597
|
+
media: planTrackMediaNames(inventory.media)
|
|
7598
|
+
});
|
|
7599
|
+
}
|
|
7600
|
+
}
|
|
7601
|
+
if (capped) deps.logger.warn("debug export capped — more marked tracks than one bundle may carry", { meta: {
|
|
7602
|
+
limit: 25,
|
|
7603
|
+
deviceIds: covered
|
|
7604
|
+
} });
|
|
7605
|
+
return {
|
|
7606
|
+
generatedAt: deps.now(),
|
|
7607
|
+
deviceIds: covered,
|
|
7608
|
+
tracks,
|
|
7609
|
+
capped
|
|
7610
|
+
};
|
|
7611
|
+
}
|
|
6770
7612
|
//#endregion
|
|
6771
7613
|
//#region src/pipeline-analytics/embedding-actions.ts
|
|
6772
7614
|
/**
|
|
@@ -11013,6 +11855,13 @@ function eventsToClips(events, pad) {
|
|
|
11013
11855
|
...events.audio.map((e) => make(e, "audio"))
|
|
11014
11856
|
].toSorted((a, b) => b.timeRange.startMs - a.timeRange.startMs);
|
|
11015
11857
|
}
|
|
11858
|
+
/** Keep only clips whose window overlaps at least one recorded range — a clip
|
|
11859
|
+
* is a window over footage, so a window with nothing behind it is unplayable
|
|
11860
|
+
* and must not be listed (a camera that never recorded showed 15 dead audio
|
|
11861
|
+
* "clips" in the viewer ribbon, every tap a 500). Pure. */
|
|
11862
|
+
function filterClipsByFootage(clips, ranges) {
|
|
11863
|
+
return clips.filter((c) => ranges.some((r) => c.timeRange.startMs < r.endMs && c.timeRange.endMs > r.startMs));
|
|
11864
|
+
}
|
|
11016
11865
|
function createVideoclipsProvider(deps) {
|
|
11017
11866
|
return {
|
|
11018
11867
|
listClips: async ({ deviceId, since, until, limit }) => {
|
|
@@ -11022,7 +11871,20 @@ function createVideoclipsProvider(deps) {
|
|
|
11022
11871
|
until,
|
|
11023
11872
|
limit
|
|
11024
11873
|
}), deps.defaultPad);
|
|
11025
|
-
|
|
11874
|
+
let filtered = clips;
|
|
11875
|
+
if (deps.fetchAvailability) try {
|
|
11876
|
+
filtered = filterClipsByFootage(clips, await deps.fetchAvailability({
|
|
11877
|
+
deviceId,
|
|
11878
|
+
since,
|
|
11879
|
+
until
|
|
11880
|
+
}));
|
|
11881
|
+
} catch (err) {
|
|
11882
|
+
deps.logWarn?.("videoclips: availability read failed — listing unfiltered", {
|
|
11883
|
+
deviceId,
|
|
11884
|
+
error: err instanceof Error ? err.message : String(err)
|
|
11885
|
+
});
|
|
11886
|
+
}
|
|
11887
|
+
return limit != null ? filtered.slice(0, limit) : filtered;
|
|
11026
11888
|
},
|
|
11027
11889
|
getClipPlayback: async ({ deviceId, clipId }) => {
|
|
11028
11890
|
const parsed = parseClipId(clipId);
|
|
@@ -11035,8 +11897,22 @@ function createVideoclipsProvider(deps) {
|
|
|
11035
11897
|
}
|
|
11036
11898
|
};
|
|
11037
11899
|
}
|
|
11038
|
-
/**
|
|
11039
|
-
|
|
11900
|
+
/**
|
|
11901
|
+
* The VIDEO's playback rate — 4×, the same as {@link NC_GIF_SPEED}.
|
|
11902
|
+
*
|
|
11903
|
+
* This was 1, and the reasoning for 1 was sound as far as it went: `speed !== 1`
|
|
11904
|
+
* fails `clipCanCopy`, so a sped-up video cannot be the camera's own H.264
|
|
11905
|
+
* copied — it is a `libx264` burst. The OPERATOR priced that and took it. It is
|
|
11906
|
+
* one encode per event over a ~12 s window, not a permanent transcode child
|
|
11907
|
+
* ([D84](../../../../../../docs/decisions/adr-0084.md) is about the latter), and
|
|
11908
|
+
* it was measured on a real 615 720p cut before being chosen: **0.23 s of
|
|
11909
|
+
* encode, 254 KB out**, against the 922 KB the copy of the same window carried.
|
|
11910
|
+
* The re-encode is smaller than what it replaces.
|
|
11911
|
+
*
|
|
11912
|
+
* So both attachments now agree on the timeline as well as on the window: one
|
|
11913
|
+
* clip, one rate, two containers.
|
|
11914
|
+
*/
|
|
11915
|
+
var DEFAULT_SPEED = 4;
|
|
11040
11916
|
/** The fallback mp4's requested width — the cap's own ceiling, so a rendition
|
|
11041
11917
|
* at or below 1080p is an identity scale and the broker copies it. */
|
|
11042
11918
|
var RING_MP4_MAX_WIDTH = 1920;
|
|
@@ -11081,7 +11957,8 @@ var EventMediaService = class {
|
|
|
11081
11957
|
postSeconds: request.postRollSec,
|
|
11082
11958
|
kinds,
|
|
11083
11959
|
gifMaxWidth: 640,
|
|
11084
|
-
gifFps:
|
|
11960
|
+
gifFps: 12,
|
|
11961
|
+
gifSpeed: 4,
|
|
11085
11962
|
speed: request.speed ?? DEFAULT_SPEED,
|
|
11086
11963
|
...request.profile !== void 0 ? { profile: request.profile } : {}
|
|
11087
11964
|
});
|
|
@@ -11207,7 +12084,8 @@ var EventMediaService = class {
|
|
|
11207
12084
|
...window,
|
|
11208
12085
|
format: "gif",
|
|
11209
12086
|
maxWidth: 640,
|
|
11210
|
-
fps:
|
|
12087
|
+
fps: 12 / 4,
|
|
12088
|
+
speed: 4
|
|
11211
12089
|
}) : null;
|
|
11212
12090
|
if ((mp4 === null || mp4.byteLength === 0) && (gif === null || gif.byteLength === 0)) {
|
|
11213
12091
|
this.deps.logger.warn("nc media: the clip ring covered nothing — no footage attached", {
|
|
@@ -22131,6 +23009,10 @@ var TRACKS_COLUMNS = [
|
|
|
22131
23009
|
name: "debug",
|
|
22132
23010
|
type: "BOOLEAN"
|
|
22133
23011
|
},
|
|
23012
|
+
{
|
|
23013
|
+
name: "hasFace",
|
|
23014
|
+
type: "BOOLEAN"
|
|
23015
|
+
},
|
|
22134
23016
|
{
|
|
22135
23017
|
name: "envMinX",
|
|
22136
23018
|
type: "REAL"
|
|
@@ -22211,7 +23093,8 @@ function cloneTrack(t) {
|
|
|
22211
23093
|
retrainStatus: t.retrainStatus,
|
|
22212
23094
|
markForTrain: t.retrainStatus === "staging"
|
|
22213
23095
|
} : {},
|
|
22214
|
-
...t.debug !== void 0 ? { debug: t.debug } : {}
|
|
23096
|
+
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
23097
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {}
|
|
22215
23098
|
};
|
|
22216
23099
|
}
|
|
22217
23100
|
var TrackStore = class {
|
|
@@ -22574,6 +23457,49 @@ var TrackStore = class {
|
|
|
22574
23457
|
}
|
|
22575
23458
|
}
|
|
22576
23459
|
/**
|
|
23460
|
+
* Record that a face DETECTOR found a face on this track. Set-once, never
|
|
23461
|
+
* cleared, and deliberately NOT part of {@link setFlags}: that method is the
|
|
23462
|
+
* operator's write surface and this is the pipeline's.
|
|
23463
|
+
*
|
|
23464
|
+
* **The signal is detection, not recognition.** The caller is the detail-route
|
|
23465
|
+
* fan-out, keyed on the detail's own `className === 'face'` — BEFORE the
|
|
23466
|
+
* `minFacePx` gate, before the recognizer is consulted, and regardless of
|
|
23467
|
+
* whether face recognition is enabled at all. That is the whole point: the
|
|
23468
|
+
* viewer's "face present, identity unknown" badge is `hasFace && !subLabel`,
|
|
23469
|
+
* and a flag that only fired when the recognizer answered would be true
|
|
23470
|
+
* exactly when the badge must not show.
|
|
23471
|
+
*
|
|
23472
|
+
* **Idempotent, and it does not write per detail.** A face detail lands many
|
|
23473
|
+
* times over a track's life. While the track is LIVE the flag is set in RAM
|
|
23474
|
+
* only and rides `persistCompleted` at expiry — one row write, not one per
|
|
23475
|
+
* frame. Once the flag is set, every later call returns immediately.
|
|
23476
|
+
*
|
|
23477
|
+
* The persisted patch is for the LATE detail: enrichment answers routinely
|
|
23478
|
+
* arrive after the track has expired, when there is no RAM entry left and the
|
|
23479
|
+
* row is the only thing to correct. Best-effort — a failed patch costs a badge,
|
|
23480
|
+
* never a track.
|
|
23481
|
+
*/
|
|
23482
|
+
async markFaceDetected(trackId) {
|
|
23483
|
+
const active = this.active.get(trackId);
|
|
23484
|
+
if (active) {
|
|
23485
|
+
if (active.hasFace === true) return;
|
|
23486
|
+
active.hasFace = true;
|
|
23487
|
+
return;
|
|
23488
|
+
}
|
|
23489
|
+
try {
|
|
23490
|
+
await this.store.update.mutate({
|
|
23491
|
+
collection: TRACKS_COLLECTION,
|
|
23492
|
+
id: trackId,
|
|
23493
|
+
data: { hasFace: true }
|
|
23494
|
+
});
|
|
23495
|
+
} catch (err) {
|
|
23496
|
+
this.logger.warn("markFaceDetected persist failed", { meta: {
|
|
23497
|
+
trackId,
|
|
23498
|
+
error: String(err)
|
|
23499
|
+
} });
|
|
23500
|
+
}
|
|
23501
|
+
}
|
|
23502
|
+
/**
|
|
22577
23503
|
* Write the retrain LIFECYCLE state directly — the move `setFlags` cannot
|
|
22578
23504
|
* express.
|
|
22579
23505
|
*
|
|
@@ -22787,6 +23713,71 @@ var TrackStore = class {
|
|
|
22787
23713
|
}
|
|
22788
23714
|
}
|
|
22789
23715
|
/**
|
|
23716
|
+
* The device's DEBUG-marked tracks, newest first — the Events → Debug tab.
|
|
23717
|
+
*
|
|
23718
|
+
* Two things about this query are load-bearing.
|
|
23719
|
+
*
|
|
23720
|
+
* **`where: { debug: true }` matches only the explicit `true`.** The column is
|
|
23721
|
+
* nullable with no default (unlike `retrainStatus`), so a row that was never
|
|
23722
|
+
* marked holds NULL and positive equality skips it — which is exactly the set
|
|
23723
|
+
* wanted here, and the reason the column was left nullable in the first place.
|
|
23724
|
+
*
|
|
23725
|
+
* **The result is re-checked in memory.** `compileFilter` DROPS a predicate
|
|
23726
|
+
* whose column does not exist in SQLite — an older hub whose `declareCollection`
|
|
23727
|
+
* fingerprint never re-ran would answer this query with EVERY track on the
|
|
23728
|
+
* camera, silently, and the tab would present the whole history as
|
|
23729
|
+
* operator-marked. A skipped predicate is indistinguishable from a matching one
|
|
23730
|
+
* at the wire, so the guard is the only thing that can tell them apart.
|
|
23731
|
+
*
|
|
23732
|
+
* **`projection: 'slim'`, and this is the expensive detail.** `positions` is a
|
|
23733
|
+
* JSON column averaging ~10 KB a row, and `'full'` parses it for every row
|
|
23734
|
+
* returned. That is affordable for a handful of marked tracks and ruinous in
|
|
23735
|
+
* the case this method already defends against: if the predicate is dropped,
|
|
23736
|
+
* the store hands back `limit` rows PER CAMERA and the trajectory of the
|
|
23737
|
+
* camera's whole recent history is parsed before the guard below throws it
|
|
23738
|
+
* away. The cheap question comes first (D56) — the list carries no trajectory,
|
|
23739
|
+
* and the per-track analysis reads the one row it needs through
|
|
23740
|
+
* `getPersistedByTrackId`.
|
|
23741
|
+
*/
|
|
23742
|
+
async listDebug(deviceId, limit) {
|
|
23743
|
+
try {
|
|
23744
|
+
const tracks = (await this.store.query.query({
|
|
23745
|
+
collection: TRACKS_COLLECTION,
|
|
23746
|
+
filter: {
|
|
23747
|
+
where: {
|
|
23748
|
+
deviceId,
|
|
23749
|
+
debug: true
|
|
23750
|
+
},
|
|
23751
|
+
orderBy: {
|
|
23752
|
+
field: "lastSeen",
|
|
23753
|
+
direction: "desc"
|
|
23754
|
+
},
|
|
23755
|
+
limit
|
|
23756
|
+
}
|
|
23757
|
+
})).filter((r) => typeof r.id === "string").map((r) => this.rowToTrack(r.id, r.data, "slim"));
|
|
23758
|
+
const marked = tracks.filter((t) => t.debug === true);
|
|
23759
|
+
if (marked.length !== tracks.length) this.logger.warn("TrackStore.listDebug — the store returned unmarked rows; the `debug` predicate was not applied", {
|
|
23760
|
+
tags: { deviceId },
|
|
23761
|
+
meta: {
|
|
23762
|
+
deviceId,
|
|
23763
|
+
returned: tracks.length,
|
|
23764
|
+
marked: marked.length
|
|
23765
|
+
}
|
|
23766
|
+
});
|
|
23767
|
+
return marked;
|
|
23768
|
+
} catch (err) {
|
|
23769
|
+
this.logger.warn("TrackStore.listDebug failed", {
|
|
23770
|
+
tags: { deviceId },
|
|
23771
|
+
meta: {
|
|
23772
|
+
deviceId,
|
|
23773
|
+
limit,
|
|
23774
|
+
error: String(err)
|
|
23775
|
+
}
|
|
23776
|
+
});
|
|
23777
|
+
return [];
|
|
23778
|
+
}
|
|
23779
|
+
}
|
|
23780
|
+
/**
|
|
22790
23781
|
* How many of this device's tracks are STAGING — optionally only those older
|
|
22791
23782
|
* than a cutoff, i.e. exactly the rows a sweep just declined to touch.
|
|
22792
23783
|
*
|
|
@@ -23193,6 +24184,7 @@ var TrackStore = class {
|
|
|
23193
24184
|
...t.audioLabels !== void 0 ? { audioLabels: [...t.audioLabels] } : {},
|
|
23194
24185
|
...t.retrainStatus !== void 0 ? { retrainStatus: t.retrainStatus } : {},
|
|
23195
24186
|
...t.debug !== void 0 ? { debug: t.debug } : {},
|
|
24187
|
+
...t.hasFace !== void 0 ? { hasFace: t.hasFace } : {},
|
|
23196
24188
|
...envelope !== null && dims !== void 0 ? {
|
|
23197
24189
|
envMinX: envelope.minX,
|
|
23198
24190
|
envMinY: envelope.minY,
|
|
@@ -23227,6 +24219,7 @@ var TrackStore = class {
|
|
|
23227
24219
|
const audioLabels = data["audioLabels"];
|
|
23228
24220
|
const retrainParsed = require_dist.RetrainStatusSchema.safeParse(data["retrainStatus"]);
|
|
23229
24221
|
const debug = data["debug"];
|
|
24222
|
+
const hasFace = data["hasFace"];
|
|
23230
24223
|
const envMinX = data["envMinX"];
|
|
23231
24224
|
const envMinY = data["envMinY"];
|
|
23232
24225
|
const envMaxX = data["envMaxX"];
|
|
@@ -23262,6 +24255,7 @@ var TrackStore = class {
|
|
|
23262
24255
|
markForTrain: retrainParsed.data === "staging"
|
|
23263
24256
|
} : {},
|
|
23264
24257
|
...typeof debug === "boolean" ? { debug } : {},
|
|
24258
|
+
...typeof hasFace === "boolean" ? { hasFace } : {},
|
|
23265
24259
|
...envelope !== null ? { envelope } : {}
|
|
23266
24260
|
};
|
|
23267
24261
|
}
|
|
@@ -26410,41 +27404,6 @@ var EventMediaDispatcher = class {
|
|
|
26410
27404
|
}
|
|
26411
27405
|
}
|
|
26412
27406
|
};
|
|
26413
|
-
//#endregion
|
|
26414
|
-
//#region src/pipeline-analytics/services/sensor-marker-projector.ts
|
|
26415
|
-
/**
|
|
26416
|
-
* Sensor markers — the gate between "a mapped sensor/control cap changed
|
|
26417
|
-
* state" and "a synthetic track exists on the camera's timeline"
|
|
26418
|
-
* (event-kinds-taxonomy §8).
|
|
26419
|
-
*
|
|
26420
|
-
* It is the sibling of {@link ../services/audio-marker-projector.ts} and it
|
|
26421
|
-
* exists for the same reason: the projection has gates, and a gate that drops
|
|
26422
|
-
* work without saying so is indistinguishable from a feature that was never
|
|
26423
|
-
* wired.
|
|
26424
|
-
*
|
|
26425
|
-
* **What this fixes.** The projection used to resolve its cameras with
|
|
26426
|
-
* `camerasFor` alone — LINKED cameras only — while the history-row ingest
|
|
26427
|
-
* beside it had gained the self-camera half on 2026-08-07. A doorbell wrapper
|
|
26428
|
-
* is bound on the CAMERA (the virtual-doorbell builtin writes the `doorbell`
|
|
26429
|
-
* slice onto camera 615 itself), so `camerasFor(615)` answered `[]` and the
|
|
26430
|
-
* projection returned on a `debug` line nobody runs at. Camera 615's real
|
|
26431
|
-
* press at 2026-08-08 06:59Z therefore produced a `SensorEvent` row, a HomeKit
|
|
26432
|
-
* ring, an Alexa post and a matched notification rule — and no track. Both
|
|
26433
|
-
* halves now derive their cameras from {@link resolveSensorEventCameras}.
|
|
26434
|
-
*
|
|
26435
|
-
* **The asymmetry that used to be here was defended by a wrong premise.** The
|
|
26436
|
-
* old comment argued a self-projected track would feed the NC `track-end`
|
|
26437
|
-
* trigger and make every press evaluate the camera's object rules. It does
|
|
26438
|
-
* not: `persistSyntheticTrack` writes the row directly and the NC track-end
|
|
26439
|
-
* hook lives in the LIVE tracker's closer, which no synthetic track ever
|
|
26440
|
-
* enters. A linked contact sensor has been projecting onto its camera all
|
|
26441
|
-
* along under exactly the same mechanism.
|
|
26442
|
-
*
|
|
26443
|
-
* **Nothing here is silent.** A marker that lands emits one `info` line; every
|
|
26444
|
-
* branch that lands none emits one line carrying the reason. Both carry
|
|
26445
|
-
* `tags: { deviceId }` with the CAMERA id, because the question always arrives
|
|
26446
|
-
* as "why did 617 get a marker and 615 not".
|
|
26447
|
-
*/
|
|
26448
27407
|
/**
|
|
26449
27408
|
* Project one `DeviceStateChanged` of a mapped sensor/control cap into a
|
|
26450
27409
|
* synthetic track per camera the event belongs to. Returns the number of
|
|
@@ -26483,6 +27442,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
26483
27442
|
source: "sensor",
|
|
26484
27443
|
kind: descriptor.kind,
|
|
26485
27444
|
timestamp,
|
|
27445
|
+
snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
|
|
26486
27446
|
...producingDeviceName !== void 0 ? { producingDeviceName } : {}
|
|
26487
27447
|
});
|
|
26488
27448
|
if (track === null) {
|
|
@@ -26502,7 +27462,8 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
26502
27462
|
capName: data.capName,
|
|
26503
27463
|
kind: descriptor.kind,
|
|
26504
27464
|
trackId: track.trackId,
|
|
26505
|
-
selfCamera: cameraId === data.deviceId
|
|
27465
|
+
selfCamera: cameraId === data.deviceId,
|
|
27466
|
+
hasMedia: track.snapshots.length > 0
|
|
26506
27467
|
});
|
|
26507
27468
|
}
|
|
26508
27469
|
return landed;
|
|
@@ -26617,13 +27578,13 @@ var SyntheticTrackMaterializer = class {
|
|
|
26617
27578
|
* answers: ≤10 s old on a mains camera, and on a battery camera the
|
|
26618
27579
|
* intentionally stale-but-honest frame that costs no wake.
|
|
26619
27580
|
*/
|
|
26620
|
-
async fetchSnapshot(cameraId) {
|
|
27581
|
+
async fetchSnapshot(cameraId, timeoutMs) {
|
|
26621
27582
|
try {
|
|
26622
|
-
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }),
|
|
26623
|
-
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
|
|
27583
|
+
const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), timeoutMs);
|
|
27584
|
+
if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"), cameraId);
|
|
26624
27585
|
return snap;
|
|
26625
27586
|
} catch (err) {
|
|
26626
|
-
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err);
|
|
27587
|
+
this.deps.onError?.(err instanceof SnapshotTimeoutError ? "snapshotTimeout" : "snapshotMedia", err, cameraId);
|
|
26627
27588
|
return null;
|
|
26628
27589
|
}
|
|
26629
27590
|
}
|
|
@@ -26640,14 +27601,14 @@ var SyntheticTrackMaterializer = class {
|
|
|
26640
27601
|
const trackId = this.makeId();
|
|
26641
27602
|
const ts = input.timestamp;
|
|
26642
27603
|
let mediaKey = null;
|
|
26643
|
-
const snap = await this.fetchSnapshot(input.cameraId);
|
|
27604
|
+
const snap = await this.fetchSnapshot(input.cameraId, input.snapshotTimeoutMs ?? this.snapshotTimeoutMs);
|
|
26644
27605
|
if (snap !== null) try {
|
|
26645
27606
|
const raw = Buffer.from(snap.base64, "base64");
|
|
26646
27607
|
let data = raw;
|
|
26647
27608
|
try {
|
|
26648
27609
|
data = await downscaleFullFrameJpeg(raw, 960, 540);
|
|
26649
27610
|
} catch (err) {
|
|
26650
|
-
this.deps.onError?.("downscaleSnapshot", err);
|
|
27611
|
+
this.deps.onError?.("downscaleSnapshot", err, input.cameraId);
|
|
26651
27612
|
}
|
|
26652
27613
|
mediaKey = await this.deps.media.put({
|
|
26653
27614
|
deviceId: input.cameraId,
|
|
@@ -26658,7 +27619,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26658
27619
|
data
|
|
26659
27620
|
});
|
|
26660
27621
|
} catch (err) {
|
|
26661
|
-
this.deps.onError?.("snapshotMedia", err);
|
|
27622
|
+
this.deps.onError?.("snapshotMedia", err, input.cameraId);
|
|
26662
27623
|
}
|
|
26663
27624
|
const track = {
|
|
26664
27625
|
trackId,
|
|
@@ -26683,7 +27644,7 @@ var SyntheticTrackMaterializer = class {
|
|
|
26683
27644
|
try {
|
|
26684
27645
|
await this.deps.tracks.persistSyntheticTrack(track);
|
|
26685
27646
|
} catch (err) {
|
|
26686
|
-
this.deps.onError?.("persistSyntheticTrack", err);
|
|
27647
|
+
this.deps.onError?.("persistSyntheticTrack", err, input.cameraId);
|
|
26687
27648
|
return null;
|
|
26688
27649
|
}
|
|
26689
27650
|
return track;
|
|
@@ -29383,8 +30344,6 @@ var REBUILD_PAGE_SIZE = 200;
|
|
|
29383
30344
|
*/
|
|
29384
30345
|
var REBUILD_FALLBACK_NODE_ID = "hub";
|
|
29385
30346
|
var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
|
|
29386
|
-
/** Operator-visible name of the fixed integration. */
|
|
29387
|
-
var NC_INTEGRATION_NAME = "Notification Center";
|
|
29388
30347
|
/**
|
|
29389
30348
|
* The FULL action catalog for this addon.
|
|
29390
30349
|
*
|
|
@@ -29395,7 +30354,8 @@ var NC_INTEGRATION_NAME = "Notification Center";
|
|
|
29395
30354
|
*/
|
|
29396
30355
|
var customActions = {
|
|
29397
30356
|
...ncActions,
|
|
29398
|
-
...embeddingActions
|
|
30357
|
+
...embeddingActions,
|
|
30358
|
+
...debugActions
|
|
29399
30359
|
};
|
|
29400
30360
|
/**
|
|
29401
30361
|
* Assist threshold when the caller does not pin one. Packages have their own
|
|
@@ -29642,6 +30602,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29642
30602
|
trainingExportDataPlane = null;
|
|
29643
30603
|
/** Handle for the retrain-export data-plane listener (dispose on shutdown). */
|
|
29644
30604
|
retrainExportDataPlane = null;
|
|
30605
|
+
/** Handle for the debug-export data-plane listener (dispose on shutdown). */
|
|
30606
|
+
debugExportDataPlane = null;
|
|
29645
30607
|
/** The retrain curation surface — see `retrain/retrain-service.ts`. Null
|
|
29646
30608
|
* until the stores are built. */
|
|
29647
30609
|
retrain = null;
|
|
@@ -29680,6 +30642,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29680
30642
|
kinds: [...input.kinds],
|
|
29681
30643
|
gifMaxWidth: input.gifMaxWidth,
|
|
29682
30644
|
gifFps: input.gifFps,
|
|
30645
|
+
gifSpeed: input.gifSpeed,
|
|
29683
30646
|
speed: input.speed,
|
|
29684
30647
|
...input.profile === "high" || input.profile === "mid" || input.profile === "low" ? { profile: input.profile } : {}
|
|
29685
30648
|
}),
|
|
@@ -29804,7 +30767,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29804
30767
|
});
|
|
29805
30768
|
const served = await this.ctx.dataPlane?.serve({
|
|
29806
30769
|
prefix: "nc-action",
|
|
29807
|
-
access: "
|
|
30770
|
+
access: planeAccess("nc-action"),
|
|
29808
30771
|
handler: plane.handler
|
|
29809
30772
|
}) ?? null;
|
|
29810
30773
|
this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
|
|
@@ -29858,7 +30821,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
29858
30821
|
});
|
|
29859
30822
|
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
29860
30823
|
prefix: "nc-artifact",
|
|
29861
|
-
access: "
|
|
30824
|
+
access: planeAccess("nc-artifact"),
|
|
29862
30825
|
handler: plane.handler
|
|
29863
30826
|
}) ?? null;
|
|
29864
30827
|
this.ncArtifactPlane = this.ncArtifactDataPlane !== null ? plane : null;
|
|
@@ -30141,7 +31104,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30141
31104
|
const api = this.ctx.api;
|
|
30142
31105
|
if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
|
|
30143
31106
|
await this.declareCollections(api);
|
|
30144
|
-
await this.
|
|
31107
|
+
await this.declareAlarmPanel(api);
|
|
30145
31108
|
const logger = this.ctx.logger;
|
|
30146
31109
|
const storage = await this.resolveMediaStorage(logger);
|
|
30147
31110
|
const stores = await this.buildStores(api, logger, storage);
|
|
@@ -30203,11 +31166,13 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30203
31166
|
...isHub && ncHandlers !== void 0 ? {
|
|
30204
31167
|
customActions: {
|
|
30205
31168
|
...ncActions,
|
|
30206
|
-
...embeddingActions
|
|
31169
|
+
...embeddingActions,
|
|
31170
|
+
...debugActions
|
|
30207
31171
|
},
|
|
30208
31172
|
actionHandlers: {
|
|
30209
31173
|
...ncHandlers,
|
|
30210
|
-
...this.buildEmbeddingActionHandlers()
|
|
31174
|
+
...this.buildEmbeddingActionHandlers(),
|
|
31175
|
+
...this.buildDebugActionHandlers()
|
|
30211
31176
|
}
|
|
30212
31177
|
} : {}
|
|
30213
31178
|
};
|
|
@@ -30238,64 +31203,38 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30238
31203
|
/** Declare typed collections up-front so the first insert doesn't race with
|
|
30239
31204
|
* a CREATE TABLE. Idempotent. */
|
|
30240
31205
|
/**
|
|
30241
|
-
*
|
|
31206
|
+
* DECLARE the Notification Center's alarm panel.
|
|
30242
31207
|
*
|
|
30243
|
-
*
|
|
30244
|
-
*
|
|
30245
|
-
*
|
|
30246
|
-
*
|
|
30247
|
-
|
|
30248
|
-
|
|
30249
|
-
|
|
30250
|
-
|
|
30251
|
-
const
|
|
30252
|
-
|
|
30253
|
-
|
|
30254
|
-
|
|
30255
|
-
|
|
30256
|
-
|
|
30257
|
-
|
|
30258
|
-
|
|
31208
|
+
* The procedure is not here any more — `DeclaredDevices` owns the
|
|
31209
|
+
* get-or-create, the FIXED integration and its every-pass `fixed`
|
|
31210
|
+
* re-assertion, the adopt-don't-skip rule, the hub-ownership gate and the D49
|
|
31211
|
+
* second read that stops a transiently empty index re-stamping the declared
|
|
31212
|
+
* name over the operator's rename. What is left is the declaration and the
|
|
31213
|
+
* ports.
|
|
31214
|
+
*/
|
|
31215
|
+
async declareAlarmPanel(api) {
|
|
31216
|
+
const declared = new require_dist.DeclaredDevices({
|
|
31217
|
+
logger: this.ctx.logger.child("alarm"),
|
|
31218
|
+
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31219
|
+
devices: this.ctx.kernel.devices,
|
|
31220
|
+
localNodeId: this.ctx.kernel.localNodeId,
|
|
31221
|
+
getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
|
|
31222
|
+
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
31223
|
+
updateIntegration: async ({ id, info }) => {
|
|
31224
|
+
await api.integrations.update.mutate({
|
|
31225
|
+
id,
|
|
31226
|
+
info,
|
|
31227
|
+
skipRestart: true
|
|
31228
|
+
});
|
|
31229
|
+
},
|
|
31230
|
+
listOwnDevices: async () => api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })
|
|
31231
|
+
});
|
|
30259
31232
|
try {
|
|
30260
|
-
const result = await
|
|
30261
|
-
logger: this.ctx.logger.child("alarm"),
|
|
30262
|
-
addonId: PIPELINE_ANALYTICS_ADDON_ID,
|
|
31233
|
+
const result = await declared.reconcile({
|
|
30263
31234
|
integrationName: NC_INTEGRATION_NAME,
|
|
30264
|
-
|
|
30265
|
-
const found = await api.integrations.getByAddonId.query({ addonId });
|
|
30266
|
-
return found === null ? null : {
|
|
30267
|
-
id: found.id,
|
|
30268
|
-
fixed: found.info?.["fixed"] === true
|
|
30269
|
-
};
|
|
30270
|
-
},
|
|
30271
|
-
markFixed: async (integrationId) => {
|
|
30272
|
-
await api.integrations.update.mutate({
|
|
30273
|
-
id: integrationId,
|
|
30274
|
-
info: { fixed: true },
|
|
30275
|
-
skipRestart: true
|
|
30276
|
-
});
|
|
30277
|
-
},
|
|
30278
|
-
createIntegration: async (input) => api.integrations.create.mutate(input),
|
|
30279
|
-
findDeviceByStableId: async (stableId) => {
|
|
30280
|
-
const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
|
|
30281
|
-
return found === void 0 ? null : { id: found.id };
|
|
30282
|
-
},
|
|
30283
|
-
createDevice: async ({ stableId, integrationId, name }) => {
|
|
30284
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
|
|
30285
|
-
type: NC_ALARM_DEVICE_TYPE,
|
|
30286
|
-
name,
|
|
30287
|
-
integrationId
|
|
30288
|
-
});
|
|
30289
|
-
this.holdAlarmPanel(device);
|
|
30290
|
-
return { id: device.id };
|
|
30291
|
-
},
|
|
30292
|
-
adoptDevice: async ({ stableId }) => {
|
|
30293
|
-
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
|
|
30294
|
-
this.holdAlarmPanel(device);
|
|
30295
|
-
return { id: device.id };
|
|
30296
|
-
}
|
|
31235
|
+
devices: [NC_ALARM_DECLARATION]
|
|
30297
31236
|
});
|
|
30298
|
-
|
|
31237
|
+
for (const outcome of result.devices) this.holdAlarmPanel(outcome.device);
|
|
30299
31238
|
} catch (err) {
|
|
30300
31239
|
this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
30301
31240
|
}
|
|
@@ -30318,7 +31257,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30318
31257
|
/**
|
|
30319
31258
|
* Join the panel and the Notification Center, once both exist.
|
|
30320
31259
|
*
|
|
30321
|
-
* They are built in that order (`
|
|
31260
|
+
* They are built in that order (`declareAlarmPanel` runs before the centre is
|
|
30322
31261
|
* constructed) and neither can reach the other on its own, so the join is
|
|
30323
31262
|
* here. A node with no panel simply never calls it, and the cap answers
|
|
30324
31263
|
* `deviceId: null` — which is what an agent node should say.
|
|
@@ -30366,7 +31305,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30366
31305
|
let storage = this.ctx.kernel.storage;
|
|
30367
31306
|
const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
|
|
30368
31307
|
if (mediaRoot) {
|
|
30369
|
-
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-
|
|
31308
|
+
const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-CpXZkQ6V.js"));
|
|
30370
31309
|
storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
|
|
30371
31310
|
logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
|
|
30372
31311
|
}
|
|
@@ -30875,7 +31814,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30875
31814
|
}
|
|
30876
31815
|
/** Event-media data-plane: serve thumbnail JPEGs at
|
|
30877
31816
|
* `/addon/<addonId>/event-media/<eventId>` through the hub's port.
|
|
30878
|
-
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31817
|
+
* Mirror the recorder playback pattern: try/warn, store handle for dispose.
|
|
31818
|
+
*
|
|
31819
|
+
* `authenticated`, not `admin` — see `data-plane-access.ts` for the rule and
|
|
31820
|
+
* the reason it changed: `getTrackMedia` / `getEventMedia` are declared
|
|
31821
|
+
* `access: 'view'` and return the SAME stored blob base64 over tRPC, so the
|
|
31822
|
+
* admin gate protected nothing and cost every non-admin session its tiles. */
|
|
30879
31823
|
async serveEventMediaDataPlane() {
|
|
30880
31824
|
try {
|
|
30881
31825
|
const handler = createEventMediaHandler({ getMedia: async (id, variant, preferKind) => {
|
|
@@ -30891,22 +31835,27 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30891
31835
|
} });
|
|
30892
31836
|
this.eventMediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
30893
31837
|
prefix: "event-media",
|
|
30894
|
-
access: "
|
|
31838
|
+
access: planeAccess("event-media"),
|
|
30895
31839
|
handler
|
|
30896
31840
|
}) ?? null;
|
|
30897
31841
|
this.eventMediaBaseUrl = this.eventMediaDataPlane !== null ? `/addon/${this.ctx.id}/event-media` : null;
|
|
30898
31842
|
await this.serveNcArtifactPlane();
|
|
30899
31843
|
await this.serveTrainingExportPlane();
|
|
30900
31844
|
await this.serveRetrainExportPlane();
|
|
30901
|
-
|
|
31845
|
+
await this.serveDebugExportPlane();
|
|
31846
|
+
this.ctx.logger.info("event-media data-plane served", { meta: {
|
|
31847
|
+
baseUrl: this.eventMediaBaseUrl ?? "(no dataPlane facility)",
|
|
31848
|
+
access: planeAccess("event-media")
|
|
31849
|
+
} });
|
|
30902
31850
|
} catch (err) {
|
|
30903
31851
|
this.ctx.logger.warn("event-media data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
30904
31852
|
}
|
|
30905
31853
|
}
|
|
30906
31854
|
/**
|
|
30907
|
-
* The training-export data plane. Separate listener,
|
|
30908
|
-
* marking a track is a viewer-level
|
|
30909
|
-
* every stored frame behind the
|
|
31855
|
+
* The training-export data plane. Separate listener, admin-only (the level
|
|
31856
|
+
* is declared in `data-plane-access.ts`) — marking a track is a viewer-level
|
|
31857
|
+
* curation act (D81), but walking off with every stored frame behind the
|
|
31858
|
+
* marked set is not.
|
|
30910
31859
|
*/
|
|
30911
31860
|
async serveTrainingExportPlane() {
|
|
30912
31861
|
try {
|
|
@@ -30921,7 +31870,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
30921
31870
|
});
|
|
30922
31871
|
this.trainingExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
30923
31872
|
prefix: "training-export",
|
|
30924
|
-
access: "
|
|
31873
|
+
access: planeAccess("training-export"),
|
|
30925
31874
|
handler
|
|
30926
31875
|
}) ?? null;
|
|
30927
31876
|
} catch (err) {
|
|
@@ -31050,6 +31999,60 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31050
31999
|
return { url: `/addon/${this.ctx.id}/${RETRAIN_EXPORT_PREFIX}${query}` };
|
|
31051
32000
|
}
|
|
31052
32001
|
/**
|
|
32002
|
+
* The store seam the `debug.*` actions and the debug bundle both read through.
|
|
32003
|
+
*
|
|
32004
|
+
* INDEX READS ONLY — three media-inventory queries and an event query per
|
|
32005
|
+
* track, no blob. The bytes are fetched one at a time, by key, by the export
|
|
32006
|
+
* handler, so a bundle of a hundred tracks never holds more than one file.
|
|
32007
|
+
*/
|
|
32008
|
+
buildDebugServiceDeps() {
|
|
32009
|
+
const trackStore = this.trackStore;
|
|
32010
|
+
const mediaStore = this.mediaStore;
|
|
32011
|
+
const eventStore = this.eventStore;
|
|
32012
|
+
if (trackStore === null || mediaStore === null || eventStore === null) throw new Error("debug: analytics stores unavailable");
|
|
32013
|
+
return {
|
|
32014
|
+
listDeviceIds: () => trackStore.listDeviceIds(),
|
|
32015
|
+
listDebugTracks: (deviceId, limit) => trackStore.listDebug(deviceId, limit),
|
|
32016
|
+
getTrack: (trackId) => trackStore.getPersistedByTrackId(trackId),
|
|
32017
|
+
listMediaInfo: (ownerKind, ownerId) => mediaStore.listInfoByOwner(ownerKind, ownerId),
|
|
32018
|
+
listEvents: (trackId) => eventStore.queryObjectByTrackIds([trackId]),
|
|
32019
|
+
logger: this.ctx.logger.child("debug"),
|
|
32020
|
+
now: () => Date.now()
|
|
32021
|
+
};
|
|
32022
|
+
}
|
|
32023
|
+
/** The `debug.*` bridge handlers — hub-only, like `nc.*` and `embedding.*`. */
|
|
32024
|
+
buildDebugActionHandlers() {
|
|
32025
|
+
return {
|
|
32026
|
+
"debug.listTracks": async (input) => listDebugTracks(this.buildDebugServiceDeps(), input),
|
|
32027
|
+
"debug.analyze": async (input) => analyzeDebugTracks(this.buildDebugServiceDeps(), input)
|
|
32028
|
+
};
|
|
32029
|
+
}
|
|
32030
|
+
/**
|
|
32031
|
+
* The debug-export data plane. Its own listener, for the same reason the
|
|
32032
|
+
* retrain export has one: a client asking for the debug bundle must never be
|
|
32033
|
+
* able to receive the annotated dataset by changing a query parameter.
|
|
32034
|
+
*/
|
|
32035
|
+
async serveDebugExportPlane() {
|
|
32036
|
+
try {
|
|
32037
|
+
const handler = createDebugExportHandler({
|
|
32038
|
+
buildPlan: (deviceIds) => buildDebugBundlePlan(this.buildDebugServiceDeps(), deviceIds),
|
|
32039
|
+
readMedia: async (key) => {
|
|
32040
|
+
const file = await this.mediaStore?.getByKey(key);
|
|
32041
|
+
return file === null || file === void 0 ? null : Buffer.from(file.base64, "base64");
|
|
32042
|
+
},
|
|
32043
|
+
logger: this.ctx.logger.child("debug-export"),
|
|
32044
|
+
now: () => Date.now()
|
|
32045
|
+
});
|
|
32046
|
+
this.debugExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
32047
|
+
prefix: "debug-export",
|
|
32048
|
+
access: planeAccess("debug-export"),
|
|
32049
|
+
handler
|
|
32050
|
+
}) ?? null;
|
|
32051
|
+
} catch (err) {
|
|
32052
|
+
this.ctx.logger.warn("debug-export data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
32053
|
+
}
|
|
32054
|
+
}
|
|
32055
|
+
/**
|
|
31053
32056
|
* The retrain-export data plane. Separate listener from the training export
|
|
31054
32057
|
* next door: different archive, different contents, and a client asking for
|
|
31055
32058
|
* one must never be able to receive the other by changing a query parameter.
|
|
@@ -31071,7 +32074,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31071
32074
|
});
|
|
31072
32075
|
this.retrainExportDataPlane = await this.ctx.dataPlane?.serve({
|
|
31073
32076
|
prefix: "retrain-export",
|
|
31074
|
-
access: "
|
|
32077
|
+
access: planeAccess("retrain-export"),
|
|
31075
32078
|
handler
|
|
31076
32079
|
}) ?? null;
|
|
31077
32080
|
} catch (err) {
|
|
@@ -31281,6 +32284,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31281
32284
|
audio
|
|
31282
32285
|
};
|
|
31283
32286
|
},
|
|
32287
|
+
fetchAvailability: async ({ deviceId, since, until }) => {
|
|
32288
|
+
return (await api.recording.getAvailability.query({
|
|
32289
|
+
deviceId,
|
|
32290
|
+
fromMs: since,
|
|
32291
|
+
toMs: until
|
|
32292
|
+
})).ranges;
|
|
32293
|
+
},
|
|
32294
|
+
logWarn: (message, meta) => this.ctx.logger.warn(message, {
|
|
32295
|
+
tags: { deviceId: Number(meta["deviceId"]) },
|
|
32296
|
+
meta
|
|
32297
|
+
}),
|
|
31284
32298
|
resolvePlayback: async ({ deviceId, startMs, endMs }) => {
|
|
31285
32299
|
const manifest = await api.recording.getPlaybackManifest.query({
|
|
31286
32300
|
deviceId,
|
|
@@ -31402,6 +32416,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
31402
32416
|
this.trainingExportDataPlane = null;
|
|
31403
32417
|
await this.retrainExportDataPlane?.dispose();
|
|
31404
32418
|
this.retrainExportDataPlane = null;
|
|
32419
|
+
await this.debugExportDataPlane?.dispose();
|
|
32420
|
+
this.debugExportDataPlane = null;
|
|
31405
32421
|
}
|
|
31406
32422
|
async handleInferenceResult(data) {
|
|
31407
32423
|
if (this.shuttingDown) return;
|
|
@@ -32330,6 +33346,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
32330
33346
|
async routeDetailResults(deviceId, trackId, details, frame) {
|
|
32331
33347
|
for (const d of details) try {
|
|
32332
33348
|
const isFaceDetail = d.className === "face";
|
|
33349
|
+
if (isFaceDetail) await this.trackStore?.markFaceDetected(trackId);
|
|
32333
33350
|
if (isFaceDetail && d.bbox !== void 0) this.overlayState.noteFaceDetail(deviceId, trackId, {
|
|
32334
33351
|
x: d.bbox.x,
|
|
32335
33352
|
y: d.bbox.y,
|
|
@@ -33752,11 +34769,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33752
34769
|
const store = this.sensorEventStore;
|
|
33753
34770
|
const cache = this.linkedCamerasCache;
|
|
33754
34771
|
if (store === null || cache === null) return;
|
|
34772
|
+
const persisted = [];
|
|
33755
34773
|
try {
|
|
33756
34774
|
await ingestSensorStateChange({
|
|
33757
34775
|
sink: store,
|
|
33758
34776
|
cache,
|
|
33759
|
-
onPersisted: (ev) =>
|
|
34777
|
+
onPersisted: (ev) => persisted.push(ev),
|
|
33760
34778
|
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
33761
34779
|
}, data, timestamp);
|
|
33762
34780
|
} catch (err) {
|
|
@@ -33768,23 +34786,30 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33768
34786
|
}
|
|
33769
34787
|
});
|
|
33770
34788
|
}
|
|
34789
|
+
const markerByCamera = /* @__PURE__ */ new Map();
|
|
33771
34790
|
const materializer = this.syntheticTracks;
|
|
33772
|
-
if (materializer
|
|
33773
|
-
try {
|
|
34791
|
+
if (materializer !== null) try {
|
|
33774
34792
|
await projectSensorMarkers({
|
|
33775
34793
|
cache,
|
|
33776
34794
|
markers: materializer,
|
|
33777
34795
|
producingDeviceName: (deviceId) => this.resolveProducingDeviceName(deviceId),
|
|
33778
|
-
onProjected: (r) =>
|
|
33779
|
-
|
|
33780
|
-
meta: {
|
|
33781
|
-
capName: r.capName,
|
|
33782
|
-
kind: r.kind,
|
|
34796
|
+
onProjected: (r) => {
|
|
34797
|
+
markerByCamera.set(r.cameraId, {
|
|
33783
34798
|
trackId: r.trackId,
|
|
33784
|
-
|
|
33785
|
-
|
|
33786
|
-
|
|
33787
|
-
|
|
34799
|
+
hasMedia: r.hasMedia
|
|
34800
|
+
});
|
|
34801
|
+
this.ctx.logger.info("synthetic sensor-track projected", {
|
|
34802
|
+
tags: { deviceId: r.cameraId },
|
|
34803
|
+
meta: {
|
|
34804
|
+
capName: r.capName,
|
|
34805
|
+
kind: r.kind,
|
|
34806
|
+
trackId: r.trackId,
|
|
34807
|
+
sourceDeviceId: r.sourceDeviceId,
|
|
34808
|
+
selfCamera: r.selfCamera,
|
|
34809
|
+
hasMedia: r.hasMedia
|
|
34810
|
+
}
|
|
34811
|
+
});
|
|
34812
|
+
},
|
|
33788
34813
|
onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
|
|
33789
34814
|
tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
|
|
33790
34815
|
meta: {
|
|
@@ -33804,6 +34829,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
|
|
|
33804
34829
|
}
|
|
33805
34830
|
});
|
|
33806
34831
|
}
|
|
34832
|
+
for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
33807
34833
|
}
|
|
33808
34834
|
/**
|
|
33809
34835
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|