@camstack/addon-post-analysis 1.2.279 → 1.2.281

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.
@@ -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-BUtk8EU2.js");
5
+ const require_dist = require("../dist-BtqlSCQT.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -5620,8 +5620,8 @@ async function raceTimeout(call, timeoutMs) {
5620
5620
  * detail the encoder can see.
5621
5621
  */
5622
5622
  async function downscaleJpeg(bytes, maxPx) {
5623
- const { default: sharp$17 } = await import("sharp");
5624
- const out = await sharp$17(Buffer.from(bytes)).resize({
5623
+ const { default: sharp$18 } = await import("sharp");
5624
+ const out = await sharp$18(Buffer.from(bytes)).resize({
5625
5625
  width: maxPx,
5626
5626
  height: maxPx,
5627
5627
  fit: "inside",
@@ -9547,15 +9547,20 @@ function readSensorEventType(value) {
9547
9547
  const eventType = last["eventType"];
9548
9548
  return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
9549
9549
  }
9550
+ /** A LINKED SET subject (ADR 0632) — see {@link NcRuleSubject.linkedSensorId}. */
9551
+ function isLinkedSetSubject(subject) {
9552
+ return subject.linkedSensorId !== void 0;
9553
+ }
9550
9554
  /**
9551
9555
  * Build the subject for a `device-event` evaluation (a persisted SensorEvent —
9552
- * one row per linked camera; `deviceId` is the CAMERA).
9556
+ * one row per linked camera; `deviceId` is the CAMERA), or, with `linked`, the
9557
+ * ONE sensor-level evaluation of a linked set.
9553
9558
  *
9554
9559
  * BOTH ids ride along. The camera is what the notification shows; the sensor is
9555
9560
  * what the operator named in the rule. Carrying only the camera is what made a
9556
9561
  * sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
9557
9562
  */
9558
- function subjectFromSensorEvent(ev, markerTrackId) {
9563
+ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9559
9564
  const eventType = readSensorEventType(ev.value);
9560
9565
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9561
9566
  return {
@@ -9570,7 +9575,14 @@ function subjectFromSensorEvent(ev, markerTrackId) {
9570
9575
  source: "sensor",
9571
9576
  sensorKind: ev.kind,
9572
9577
  ...eventType !== void 0 ? { eventType } : {},
9573
- ...sensorEdge !== void 0 ? { sensorEdge } : {}
9578
+ ...sensorEdge !== void 0 ? { sensorEdge } : {},
9579
+ ...linked !== void 0 ? {
9580
+ linkedSensorId: ev.sourceDeviceId,
9581
+ linkedCameraIds: [...linked.linkedCameraIds],
9582
+ rowCameraIds: [...linked.rowCameraIds],
9583
+ mutedRowCameraIds: [...linked.mutedRowCameraIds]
9584
+ } : {},
9585
+ ...linked?.linkedStills !== void 0 ? { linkedStills: linked.linkedStills } : {}
9574
9586
  };
9575
9587
  }
9576
9588
  /**
@@ -9892,6 +9904,21 @@ function zonesVisitedBy(subject) {
9892
9904
  if (subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
9893
9905
  return visited;
9894
9906
  }
9907
+ /**
9908
+ * Could this `device-event` rule fire on a sensor change of `sensorKind`?
9909
+ * The SAME two refusals {@link evaluateRule} makes before anything else a
9910
+ * sensor subject is judged on — an `occupancy` condition (such a rule targets
9911
+ * only occupancy edges, which ride the same delivery) and a `sensorKinds` list
9912
+ * that excludes the kind — for a caller that must decide from the rules alone,
9913
+ * before the subject exists (the linked-camera capture gate). Coarse in one
9914
+ * direction only: `true` may still fail later; `false` never would have fired.
9915
+ */
9916
+ function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9917
+ const c = rule.conditions;
9918
+ if (c.occupancy !== void 0) return false;
9919
+ if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9920
+ return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9921
+ }
9895
9922
  function toLowerSet(values) {
9896
9923
  return new Set(values.map((v) => v.trim().toLowerCase()));
9897
9924
  }
@@ -10292,13 +10319,17 @@ function systemEventCooldownScope(systemEvent) {
10292
10319
  * the operator actually cares about and the one they pick in the Devices
10293
10320
  * tab. Before this, that rule matched nothing, ever, silently.
10294
10321
  *
10295
- * Consequence worth knowing: a sensor linked to N cameras persists N rows, so
10296
- * a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
10297
- * own throttle is what collapses that — `scope: 'rule'` gives one
10298
- * notification, `scope: 'device'` gives one per camera, which is the right
10299
- * choice when each carries its own picture.
10322
+ * A sensor linked to MORE than one camera is evaluated once, at the sensor
10323
+ * level (ADR 0632), with `deviceId` = the primary camera. That evaluation
10324
+ * matches the SENSOR and the cameras that took a timeline row for it
10325
+ * (`rowCameraIds`) — exactly the rules the per-camera rows would have matched
10326
+ * (D630 §6). The primary is only the camera the notification leads with: one
10327
+ * that links the sensor without opting it in keeps its rules silent, and an
10328
+ * opted-in camera that is not the primary still fires its own. The other
10329
+ * linked cameras ride `linkedCameraIds` for the MEDIA only.
10300
10330
  */
10301
10331
  function matchesDeviceScope(scope, subject) {
10332
+ if (subject.linkedSensorId !== void 0) return scope.includes(subject.linkedSensorId) || (subject.rowCameraIds ?? []).some((cameraId) => scope.includes(cameraId));
10302
10333
  if (scope.includes(subject.deviceId)) return true;
10303
10334
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10304
10335
  }
@@ -10521,23 +10552,34 @@ function incomingFromTrack(track, info) {
10521
10552
  };
10522
10553
  }
10523
10554
  /**
10524
- * Sensor-event persist (one row per linked camera).
10555
+ * Sensor-event persist (one row per linked camera), or — with `linked` — the
10556
+ * ONE sensor-level evaluation of a sensor linked to several cameras (ADR 0632),
10557
+ * whose `event.deviceId` is the primary camera.
10525
10558
  *
10526
10559
  * `markerTrackId` is the synthetic marker track the same state change
10527
10560
  * projected onto this camera — the notification's media owner, and the only
10528
10561
  * frame a device event has. Absent when no marker landed.
10562
+ *
10563
+ * A sensor-level evaluation is a per-EVENT line, so it is tagged with the
10564
+ * SENSOR; the primary camera rides in the meta. `mutedCameraIds` are the
10565
+ * linked cameras the centre left out (D62) — for the line's counts only.
10529
10566
  */
10530
- function incomingFromSensorEvent(event, markerTrackId) {
10567
+ function incomingFromSensorEvent(event, markerTrackId, linked, mutedCameraIds = []) {
10531
10568
  return {
10532
- subject: subjectFromSensorEvent(event, markerTrackId),
10569
+ subject: subjectFromSensorEvent(event, markerTrackId, linked),
10533
10570
  kind: "device-event",
10534
10571
  origin: "sensor",
10535
10572
  log: () => ({
10536
- tags: { deviceId: event.deviceId },
10573
+ tags: { deviceId: linked !== void 0 ? event.sourceDeviceId : event.deviceId },
10537
10574
  meta: {
10538
10575
  sensorEventId: event.id,
10539
10576
  kind: event.kind,
10540
- ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {}
10577
+ ...markerTrackId !== void 0 ? { trackId: markerTrackId } : {},
10578
+ ...linked !== void 0 ? {
10579
+ ...event.deviceId !== event.sourceDeviceId ? { primaryCameraId: event.deviceId } : {},
10580
+ linkedCameras: linked.linkedCameraIds.length + mutedCameraIds.length,
10581
+ mutedCameras: mutedCameraIds.length
10582
+ } : {}
10541
10583
  }
10542
10584
  })
10543
10585
  };
@@ -13108,6 +13150,141 @@ function pickTrackNarrationFrames(rows, max) {
13108
13150
  return picked;
13109
13151
  }
13110
13152
  //#endregion
13153
+ //#region src/notification-center/rule-family-guard.ts
13154
+ /**
13155
+ * The hub's half of D637: a NEW or UPDATED system-event rule answers ONE family
13156
+ * (device events, system, alarm) and carries only that family's filters.
13157
+ *
13158
+ * THE authority for the invariant. The cap-provider route
13159
+ * (`notificationRules.createRule/updateRule`, index.ts) and the rule bridge
13160
+ * (`nc.createRule/updateRule`, nc-actions.ts) both call it through
13161
+ * `guardRuleFamily` (create) and `guardPatchFamily` (update), so the two save
13162
+ * paths cannot drift. This is the same
13163
+ * arrangement as `assertRuleHasAddressee`.
13164
+ *
13165
+ * Not in Zod: a cross-field refine on `NcConditionsSchema` would also reject the
13166
+ * STORED mixed rules at load (NcRuleSchema.parse in the store), and those must
13167
+ * keep firing until the operator recreates them. The engine is untouched, and
13168
+ * so is the device-prune runner: removing a deleted device id from a stored
13169
+ * rule is not authoring.
13170
+ */
13171
+ var NcRuleFamilyRefusal = class extends Error {
13172
+ problems;
13173
+ /** The rule that was judged: the input on a create, the MERGED rule on an update. */
13174
+ rule;
13175
+ constructor(problems, rule) {
13176
+ super(`system-event rule refused: ${problems.map(require_dist.describeSystemEventFamilyProblem).join("; ")}`);
13177
+ this.name = "NcRuleFamilyRefusal";
13178
+ this.problems = problems;
13179
+ this.rule = rule;
13180
+ }
13181
+ };
13182
+ function assertRuleFamily(rule) {
13183
+ if (!require_dist.isSystemDelivery(rule.delivery)) return;
13184
+ const problems = require_dist.systemEventFamilyProblems(rule.conditions.systemEvent);
13185
+ if (problems.length > 0) throw new NcRuleFamilyRefusal(problems, rule);
13186
+ }
13187
+ var byValue = (a, b) => a < b ? -1 : a > b ? 1 : 0;
13188
+ var byNumber = (a, b) => a - b;
13189
+ /**
13190
+ * A system-event condition with every list sorted. The engine reads `kinds` and
13191
+ * the id-list filters as SETS (membership only), so an editor that re-sorts
13192
+ * them — the admin's `toggleSystemEventKind` puts kinds in option order — has
13193
+ * not changed the rule. Copies; the input is never touched.
13194
+ */
13195
+ function sortedSystemEvent(condition) {
13196
+ const { kinds, deviceIds, excludeDeviceIds, deviceTypes, nodeIds, packageNames } = condition;
13197
+ return {
13198
+ ...condition,
13199
+ kinds: kinds.toSorted(byValue),
13200
+ ...deviceIds !== void 0 ? { deviceIds: deviceIds.toSorted(byNumber) } : {},
13201
+ ...excludeDeviceIds !== void 0 ? { excludeDeviceIds: excludeDeviceIds.toSorted(byNumber) } : {},
13202
+ ...deviceTypes !== void 0 ? { deviceTypes: deviceTypes.toSorted(byValue) } : {},
13203
+ ...nodeIds !== void 0 ? { nodeIds: nodeIds.toSorted(byValue) } : {},
13204
+ ...packageNames !== void 0 ? { packageNames: packageNames.toSorted(byValue) } : {}
13205
+ };
13206
+ }
13207
+ /** The canonical form two conditions are compared in. */
13208
+ function conditionsFingerprint(conditions) {
13209
+ const { systemEvent } = conditions;
13210
+ return require_dist.canonicalHash(systemEvent === void 0 ? conditions : {
13211
+ ...conditions,
13212
+ systemEvent: sortedSystemEvent(systemEvent)
13213
+ });
13214
+ }
13215
+ /**
13216
+ * Do two conditions say the same thing? Both go through `NcConditionsSchema`
13217
+ * (the normalisation the store applies on every write) and are compared
13218
+ * canonically: key order is irrelevant, an `undefined` key equals an absent
13219
+ * one, and the system-event `kinds` and id-list filters are compared as SETS.
13220
+ * Every other array keeps its order. A side that does not parse is treated as
13221
+ * a change, so it is judged rather than waved through.
13222
+ */
13223
+ function sameConditions(a, b) {
13224
+ const left = require_dist.NcConditionsSchema.safeParse(a);
13225
+ const right = require_dist.NcConditionsSchema.safeParse(b);
13226
+ if (!left.success || !right.success) return false;
13227
+ return conditionsFingerprint(left.data) === conditionsFingerprint(right.data);
13228
+ }
13229
+ /**
13230
+ * The rule a patch WOULD produce, as far as the family is concerned — merged
13231
+ * the way the store merges it: `delivery` and `conditions` replace whole.
13232
+ *
13233
+ * `null` when the patch CHANGES neither. Both editors send the whole rule on
13234
+ * every save, so "the patch carries `conditions`" is not "the patch changes
13235
+ * them": an unchanged full-form save of a stored mixed rule (a rename, a
13236
+ * retarget) must pass, and only a save that alters the delivery or the
13237
+ * conditions is judged.
13238
+ */
13239
+ function mergedFamilySubject(existing, patch) {
13240
+ const deliveryChanged = patch.delivery !== void 0 && patch.delivery !== existing.delivery;
13241
+ const conditionsChanged = patch.conditions !== void 0 && !sameConditions(patch.conditions, existing.conditions);
13242
+ if (!deliveryChanged && !conditionsChanged) return null;
13243
+ return {
13244
+ delivery: patch.delivery ?? existing.delivery,
13245
+ conditions: patch.conditions ?? existing.conditions
13246
+ };
13247
+ }
13248
+ /**
13249
+ * On update, the MERGED rule is judged. A patch that changes neither `delivery`
13250
+ * nor `conditions` (absent, or equal to what is stored) leaves the kinds as
13251
+ * they are, so a stored mixed rule can still be switched off, renamed or
13252
+ * retargeted before the operator recreates it.
13253
+ */
13254
+ function assertPatchKeepsFamily(existing, patch) {
13255
+ if (existing === null) return;
13256
+ const merged = mergedFamilySubject(existing, patch);
13257
+ if (merged !== null) assertRuleFamily(merged);
13258
+ }
13259
+ /**
13260
+ * Run a family check and LOG a refusal: who tried to save, the rule that was
13261
+ * judged (its delivery and kinds) and every problem in words (the families,
13262
+ * the offending kind or filter). Then rethrow. A refused save is dropped work,
13263
+ * and dropped work is never silent. Any other error passes through unlogged.
13264
+ */
13265
+ function logRefusal(check, ctx) {
13266
+ try {
13267
+ check();
13268
+ } catch (err) {
13269
+ if (err instanceof NcRuleFamilyRefusal) ctx.logger.warn("nc rule refused: outside one family", { meta: {
13270
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13271
+ by: ctx.by,
13272
+ delivery: err.rule.delivery,
13273
+ kinds: err.rule.conditions.systemEvent?.kinds ?? [],
13274
+ problems: err.problems.map(require_dist.describeSystemEventFamilyProblem)
13275
+ } });
13276
+ throw err;
13277
+ }
13278
+ }
13279
+ /** {@link assertRuleFamily}, logged. The create path of both routes. */
13280
+ function guardRuleFamily(rule, ctx) {
13281
+ logRefusal(() => assertRuleFamily(rule), ctx);
13282
+ }
13283
+ /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13284
+ function guardPatchFamily(existing, patch, ctx) {
13285
+ logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
13286
+ }
13287
+ //#endregion
13111
13288
  //#region src/notification-center/occupancy-watcher.ts
13112
13289
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
13113
13290
  var FRAME_SCOPE = "@frame";
@@ -14774,8 +14951,8 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
14774
14951
  ];
14775
14952
  if (bbox === void 0) return files;
14776
14953
  try {
14777
- const { default: sharp$16 } = await import("sharp");
14778
- const meta = await sharp$16(Buffer.from(jpeg)).metadata();
14954
+ const { default: sharp$17 } = await import("sharp");
14955
+ const meta = await sharp$17(Buffer.from(jpeg)).metadata();
14779
14956
  const W = meta.width ?? 0;
14780
14957
  const H = meta.height ?? 0;
14781
14958
  if (W === 0 || H === 0) return files;
@@ -14783,7 +14960,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
14783
14960
  const top = Math.max(0, Math.min(H - 1, Math.floor(bbox.y * H)));
14784
14961
  const width = Math.max(1, Math.min(W - left, Math.ceil(bbox.w * W)));
14785
14962
  const height = Math.max(1, Math.min(H - top, Math.ceil(bbox.h * H)));
14786
- const cropBuf = await sharp$16(Buffer.from(jpeg)).extract({
14963
+ const cropBuf = await sharp$17(Buffer.from(jpeg)).extract({
14787
14964
  left,
14788
14965
  top,
14789
14966
  width,
@@ -14805,7 +14982,7 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
14805
14982
  });
14806
14983
  const stroke = Math.max(2, Math.round(Math.min(W, H) / 240));
14807
14984
  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>`);
14808
- const boxedBuf = await sharp$16(Buffer.from(jpeg)).composite([{
14985
+ const boxedBuf = await sharp$17(Buffer.from(jpeg)).composite([{
14809
14986
  input: overlay,
14810
14987
  top: 0,
14811
14988
  left: 0
@@ -15823,6 +16000,10 @@ function makeNcActionHandlers(deps) {
15823
16000
  const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
15824
16001
  assertAdminOnlyFields(parsed, [], c);
15825
16002
  assertRuleHasAddressee(parsed.targets, parsed.targetUsers);
16003
+ guardRuleFamily(parsed, {
16004
+ logger: deps.logger,
16005
+ by: c.userId
16006
+ });
15826
16007
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
15827
16008
  const rule = await deps.ruleStore.create({
15828
16009
  ...parsed,
@@ -15841,6 +16022,11 @@ function makeNcActionHandlers(deps) {
15841
16022
  const clear = input.clear ?? [];
15842
16023
  assertAdminOnlyFields(patch, clear, c, existing);
15843
16024
  assertPatchKeepsAddressee(existing, patch, clear);
16025
+ guardPatchFamily(existing, patch, {
16026
+ logger: deps.logger,
16027
+ by: c.userId,
16028
+ ruleId: input.ruleId
16029
+ });
15844
16030
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
15845
16031
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
15846
16032
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -17004,7 +17190,8 @@ var NcDispatcher = class {
17004
17190
  const analyse = this.deps.ruleAi;
17005
17191
  const policy = entry.payload.ai;
17006
17192
  if (analyse === void 0 || policy === void 0) return null;
17007
- const frames = policy.shape === "detection-track" ? await this.trackNarrationFrames(entry) : shippedFrames(built.notification);
17193
+ const tiles = policy.shape === "detection-track" ? [] : await this.linkedAnalysisFrames(entry);
17194
+ const frames = policy.shape === "detection-track" ? await this.trackNarrationFrames(entry) : tiles.length > 0 ? tiles : shippedFrames(built.notification);
17008
17195
  if (frames.length === 0) {
17009
17196
  this.deps.logger.debug("notification AI skipped — no picture to describe", {
17010
17197
  tags: { deviceId: entry.deviceId },
@@ -17045,6 +17232,43 @@ var NcDispatcher = class {
17045
17232
  return null;
17046
17233
  }
17047
17234
  }
17235
+ /** A linked set's raw per-camera tiles (`subject.analysisTiles`), in
17236
+ * camera order — one frame per camera whose picture landed. Best-effort: an
17237
+ * owner that answers nothing or throws is left out (a blank frame is never
17238
+ * handed to a model), and no owners at all is `[]`. */
17239
+ async linkedAnalysisFrames(entry) {
17240
+ const tiles = entry.payload.subject.analysisTiles ?? [];
17241
+ const frames = [];
17242
+ for (const { ownerId, cameraId } of tiles) {
17243
+ const file = await this.linkedStillFile(entry, ownerId, cameraId);
17244
+ const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
17245
+ if (bytes !== null) frames.push({
17246
+ bytes,
17247
+ mime: "image/jpeg"
17248
+ });
17249
+ }
17250
+ return frames;
17251
+ }
17252
+ /** One linked-camera shelf owner's still — `keyFrameSmall`, else whatever it
17253
+ * holds. `undefined` when it holds nothing or the read throws (said at DEBUG
17254
+ * on `cameraId`, the camera the picture is of: the caller decides whether
17255
+ * the absence is worth a line of its own). */
17256
+ async linkedStillFile(entry, ownerId, cameraId) {
17257
+ try {
17258
+ const files = await this.deps.getMediaForOwner("track", ownerId);
17259
+ return files.find((f) => f.kind === "keyFrameSmall") ?? files[0];
17260
+ } catch (err) {
17261
+ this.deps.logger.debug("linked camera picture read failed", {
17262
+ tags: { deviceId: cameraId },
17263
+ meta: {
17264
+ recordId: entry.recordId,
17265
+ ownerId,
17266
+ error: String(err)
17267
+ }
17268
+ });
17269
+ return;
17270
+ }
17271
+ }
17048
17272
  /** The whole-scene frames spanning a CLOSED track, oldest first. Best-effort
17049
17273
  * in every direction: no track id, a throwing read or an empty store all
17050
17274
  * leave the pass with nothing, which skips it. */
@@ -17053,11 +17277,8 @@ var NcDispatcher = class {
17053
17277
  if (trackId === void 0) return [];
17054
17278
  try {
17055
17279
  return pickTrackNarrationFrames(await this.deps.getMediaForOwner("track", trackId), 3).flatMap((file) => {
17056
- const raw = Buffer.from(file.base64, "base64");
17057
- if (raw.byteLength === 0) return [];
17058
- const bytes = new Uint8Array(raw.byteLength);
17059
- bytes.set(raw);
17060
- return [{
17280
+ const bytes = decodeStillBytes(file.base64);
17281
+ return bytes === null ? [] : [{
17061
17282
  bytes,
17062
17283
  mime: "image/jpeg",
17063
17284
  atMs: file.timestamp
@@ -17505,7 +17726,7 @@ var NcDispatcher = class {
17505
17726
  const deviceId = entry.payload.subject.deviceId;
17506
17727
  const common = {
17507
17728
  deviceId: deviceId > 0 ? deviceId : null,
17508
- deviceIds: deviceId > 0 ? [deviceId] : [],
17729
+ deviceIds: entry.payload.subject.linkedCameraIds !== void 0 ? [...entry.payload.subject.linkedCameraIds] : deviceId > 0 ? [deviceId] : [],
17509
17730
  capturedAt: entry.payload.subject.timestamp,
17510
17731
  ruleId: entry.ruleId,
17511
17732
  ruleName: entry.payload.ruleName
@@ -17553,6 +17774,7 @@ var NcDispatcher = class {
17553
17774
  const footagePending = this.renderFootage(entry);
17554
17775
  const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
17555
17776
  const footage = await footagePending;
17777
+ const extras = await this.linkedExtraStills(entry);
17556
17778
  if (still !== null) {
17557
17779
  const zoneIds = zoneIdsWanted;
17558
17780
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -17570,7 +17792,17 @@ var NcDispatcher = class {
17570
17792
  name: "group.jpg"
17571
17793
  } : still);
17572
17794
  }
17573
- } else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
17795
+ } else if (entry.payload.media !== "none" && extras.length > 0) this.deps.logger.info("no still of the primary camera could be resolved — the other linked cameras' stills ship without it", {
17796
+ tags: { deviceId: entry.payload.subject.deviceId },
17797
+ meta: {
17798
+ recordKind: entry.recordKind,
17799
+ recordId: entry.recordId,
17800
+ policy: entry.payload.media,
17801
+ ...entry.payload.subject.trackId !== void 0 ? { owner: `track:${entry.payload.subject.trackId}` } : {},
17802
+ otherCameras: extras.length
17803
+ }
17804
+ });
17805
+ else if (entry.payload.media !== "none") this.deps.logger.info("no still could be resolved — this notification ships text-only", {
17574
17806
  tags: { deviceId: entry.payload.subject.deviceId },
17575
17807
  meta: {
17576
17808
  recordKind: entry.recordKind,
@@ -17581,6 +17813,43 @@ var NcDispatcher = class {
17581
17813
  }
17582
17814
  });
17583
17815
  out.push(...footage.attachments);
17816
+ out.push(...extras);
17817
+ return out;
17818
+ }
17819
+ /**
17820
+ * `each` (ADR 0632): the OTHER linked cameras' stills, in camera order —
17821
+ * AFTER the primary still and the primary's footage, so a target with little
17822
+ * room keeps both and the other cameras fill only what is left (the degrade
17823
+ * engine drops by position). A camera whose picture never landed is skipped
17824
+ * and named ON THAT CAMERA: a blank attachment would read as a broken camera.
17825
+ * The camera is the one the row CARRIES with each still (the one the shelf
17826
+ * photographed) — never inferred from a position in `linkedCameraIds`, which
17827
+ * a mute between capture and evaluation shifts. Named `camera-<id>.jpg`.
17828
+ */
17829
+ async linkedExtraStills(entry) {
17830
+ const subject = entry.payload.subject;
17831
+ const out = [];
17832
+ for (const { ownerId, cameraId } of subject.extraStills ?? []) {
17833
+ const file = await this.linkedStillFile(entry, ownerId, cameraId);
17834
+ const bytes = file === void 0 ? null : decodeStillBytes(file.base64);
17835
+ if (bytes === null) {
17836
+ this.deps.logger.info("linked camera picture missing at send — skipped", {
17837
+ tags: { deviceId: cameraId },
17838
+ meta: {
17839
+ recordId: entry.recordId,
17840
+ ruleId: entry.ruleId,
17841
+ ownerId
17842
+ }
17843
+ });
17844
+ continue;
17845
+ }
17846
+ out.push({
17847
+ mediaType: "image",
17848
+ bytes,
17849
+ mime: "image/jpeg",
17850
+ name: `camera-${String(cameraId)}.jpg`
17851
+ });
17852
+ }
17584
17853
  return out;
17585
17854
  }
17586
17855
  /**
@@ -17857,8 +18126,8 @@ var NcDispatcher = class {
17857
18126
  try {
17858
18127
  const points = (await this.deps.getZonePolygons?.(deviceId, zoneIds) ?? []).flat();
17859
18128
  if (points.length === 0) return null;
17860
- const { default: sharp$15 } = await import("sharp");
17861
- const img = sharp$15(Buffer.from(jpeg));
18129
+ const { default: sharp$16 } = await import("sharp");
18130
+ const img = sharp$16(Buffer.from(jpeg));
17862
18131
  const meta = await img.metadata();
17863
18132
  const W = meta.width ?? 0;
17864
18133
  const H = meta.height ?? 0;
@@ -18059,10 +18328,8 @@ var NcDispatcher = class {
18059
18328
  const shortlist = await this.narrowToTriggerZone(entry, inBand, freshness);
18060
18329
  if (shortlist.length === 0) continue;
18061
18330
  for (const file of shortlist) {
18062
- const raw = Buffer.from(file.base64, "base64");
18063
- if (raw.byteLength === 0) continue;
18064
- const bytes = new Uint8Array(raw.byteLength);
18065
- bytes.set(raw);
18331
+ const bytes = decodeStillBytes(file.base64);
18332
+ if (bytes === null) continue;
18066
18333
  return {
18067
18334
  mediaType: "image",
18068
18335
  bytes,
@@ -18295,6 +18562,18 @@ function copyBytes(bytes) {
18295
18562
  out.set(bytes);
18296
18563
  return out;
18297
18564
  }
18565
+ /**
18566
+ * A stored file's base64 as bytes backed by their OWN `ArrayBuffer`, or `null`
18567
+ * when it decodes to nothing (a blank picture is never a picture).
18568
+ *
18569
+ * The copy is the point: a pooled `Buffer` view throws `Trying to deserialize
18570
+ * unknown typed array` on the far side of a transport, and the attachment and
18571
+ * frame types both demand `Uint8Array<ArrayBuffer>`.
18572
+ */
18573
+ function decodeStillBytes(base64) {
18574
+ const raw = Buffer.from(base64, "base64");
18575
+ return raw.byteLength === 0 ? null : copyBytes(raw);
18576
+ }
18298
18577
  /** Does this rule ever cut footage from the broker's clip ring? */
18299
18578
  function wantsFootage(rule) {
18300
18579
  return rule.media.gif === true || rule.media.clip === true;
@@ -20131,6 +20410,916 @@ var DeviceLivenessHoldoff = class {
20131
20410
  }
20132
20411
  };
20133
20412
  //#endregion
20413
+ //#region src/notification-center/linked-camera-media.ts
20414
+ /** ADR 0632: absent = mosaic, applied HERE and never as a Zod default. */
20415
+ function linkedCameraMode(media) {
20416
+ return media.linkedCameras ?? "mosaic";
20417
+ }
20418
+ /** Which shelf owners one RULE's row names. The model always gets the raw tiles
20419
+ * (like the digest, D154) — a composite is a worse input than its parts. */
20420
+ function linkedMediaOwners(stills, mode) {
20421
+ const tiles = stills.cameraOwnerIds.flatMap((ownerId, i) => {
20422
+ const cameraId = stills.cameraIds[i];
20423
+ return cameraId === void 0 ? [] : [{
20424
+ ownerId,
20425
+ cameraId
20426
+ }];
20427
+ });
20428
+ const [primary] = stills.cameraOwnerIds;
20429
+ if (mode === "each" && primary !== void 0) return {
20430
+ trackId: primary,
20431
+ extraStills: tiles.slice(1),
20432
+ analysisTiles: tiles
20433
+ };
20434
+ return {
20435
+ trackId: stills.mosaicOwnerId,
20436
+ extraStills: [],
20437
+ analysisTiles: tiles
20438
+ };
20439
+ }
20440
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
20441
+ var FULL_FRAME_QUALITY = 80;
20442
+ /**
20443
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
20444
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
20445
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
20446
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
20447
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
20448
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
20449
+ */
20450
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
20451
+ return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
20452
+ fit: "inside",
20453
+ withoutEnlargement: true
20454
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
20455
+ }
20456
+ //#endregion
20457
+ //#region src/notification-center/still-shelf.ts
20458
+ /**
20459
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
20460
+ * name owns a frame.
20461
+ *
20462
+ * ## Why a trigger needs one at all
20463
+ *
20464
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
20465
+ * object or package event owns its crops, a closed track owns its best shot, a
20466
+ * doorbell press owns the marker track the same press projected
20467
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
20468
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
20469
+ * same reason in both cases — the subject is an ABSENCE:
20470
+ *
20471
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
20472
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
20473
+ * about sound that has already stopped, and `event-intake.ts` states why
20474
+ * replaying it later would be wrong.
20475
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
20476
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
20477
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
20478
+ *
20479
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
20480
+ * of the trigger. Not of the sound, not of the object that left — of what the
20481
+ * camera can see now that it happened. For a freed parking space that is
20482
+ * exactly the answer the operator wants: the space, empty.
20483
+ *
20484
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
20485
+ * and the only thing that differs between the two triggers is the GATE deciding
20486
+ * that a photograph is owed at all — which belongs at the trigger site, where
20487
+ * the rules are, and not here. A twin module would be a second derivation of
20488
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
20489
+ * can say which absence they are about.
20490
+ *
20491
+ * ## Three properties, and each one is a decision
20492
+ *
20493
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
20494
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
20495
+ * alternative was the doorbell's: materialise a synthetic marker track through
20496
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
20497
+ * a durable Track on the camera's timeline for every confirmed window and every
20498
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
20499
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
20500
+ * precisely to bound how many audio markers a camera may emit). A notification
20501
+ * must not manufacture timeline history as a side effect of wanting a picture.
20502
+ *
20503
+ * **The capture STARTS immediately and is never awaited.** The subject is
20504
+ * transient — a scream is over before a snapshot round-trip completes, and a
20505
+ * freed space is about to be taken by the next car — so the fetch is kicked off
20506
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
20507
+ * synchronously so the outbox row can name it. The bytes land while the row
20508
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
20509
+ * pause its own footage render already costs) picks them up. A camera that
20510
+ * never answers costs the picture and never the notification.
20511
+ *
20512
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
20513
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
20514
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
20515
+ * Without a reuse window this would photograph a camera at whatever rate the
20516
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
20517
+ * outbox rows are never mistaken for one subject; they merely point at the same
20518
+ * frame, which is the truth — the scene did not change in ten seconds. The
20519
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
20520
+ * emptied zone ten seconds apart are two claims about one scene.
20521
+ *
20522
+ * Nothing here is silent: a capture that lands and a camera that refuses each
20523
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
20524
+ * and 615 not" is the only form that question is ever asked in.
20525
+ */
20526
+ /**
20527
+ * The owner-id namespace. It is what routes a lookup here instead of to the
20528
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
20529
+ * branch — `getMediaForOwner` answers for all of them under one signature.
20530
+ */
20531
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
20532
+ /** True for an owner id this shelf minted. */
20533
+ function isStillShelfId(id) {
20534
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
20535
+ }
20536
+ /**
20537
+ * How long the bytes are held.
20538
+ *
20539
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
20540
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
20541
+ * ships text-only, which is the correct degradation for a photograph of a scene
20542
+ * that is a quarter of an hour stale anyway.
20543
+ */
20544
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
20545
+ /** A hanging snapshot cap must not pin a capture slot forever. */
20546
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
20547
+ /**
20548
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
20549
+ * one inside a single TTL; the oldest is dropped first, which costs a
20550
+ * fifteen-minute-old picture nobody is waiting for.
20551
+ */
20552
+ var MAX_CAPTURES = 64;
20553
+ var NcStillShelf = class {
20554
+ deps;
20555
+ /** captureId → the photograph. */
20556
+ captures = /* @__PURE__ */ new Map();
20557
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
20558
+ owners = /* @__PURE__ */ new Map();
20559
+ /** deviceId → its newest capture, for the reuse window. */
20560
+ newest = /* @__PURE__ */ new Map();
20561
+ constructor(deps) {
20562
+ this.deps = deps;
20563
+ }
20564
+ /**
20565
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
20566
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
20567
+ * path and the outbox row is built from what this returns.
20568
+ */
20569
+ capture(deviceId, atMs, trigger) {
20570
+ const now = this.deps.now();
20571
+ this.prune(now);
20572
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
20573
+ const recent = this.newest.get(deviceId);
20574
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
20575
+ this.owners.set(ownerId, recent.captureId);
20576
+ return ownerId;
20577
+ }
20578
+ const captureId = (0, node_crypto.randomUUID)();
20579
+ this.captures.set(captureId, {
20580
+ deviceId,
20581
+ trigger,
20582
+ startedAt: now,
20583
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
20584
+ files: []
20585
+ });
20586
+ this.newest.set(deviceId, {
20587
+ captureId,
20588
+ at: now
20589
+ });
20590
+ this.owners.set(ownerId, captureId);
20591
+ this.fetch(captureId, deviceId, atMs, trigger);
20592
+ return ownerId;
20593
+ }
20594
+ /**
20595
+ * The media an owner id holds, or `undefined` when this shelf never minted
20596
+ * it. An EMPTY array is a different answer: the capture exists and has not
20597
+ * landed (or never will), which is exactly the case the dispatcher's bounded
20598
+ * wait and its `no still could be resolved` line are for.
20599
+ */
20600
+ get(ownerId) {
20601
+ const captureId = this.owners.get(ownerId);
20602
+ if (captureId === void 0) return void 0;
20603
+ return this.captures.get(captureId)?.files ?? [];
20604
+ }
20605
+ /** Drop expired captures and the owners that named them. */
20606
+ prune(now) {
20607
+ for (const [captureId, held] of this.captures) {
20608
+ if (held.expiresAt > now) continue;
20609
+ this.captures.delete(captureId);
20610
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
20611
+ }
20612
+ while (this.captures.size > MAX_CAPTURES) {
20613
+ const oldest = this.captures.keys().next();
20614
+ if (oldest.done === true) break;
20615
+ const held = this.captures.get(oldest.value);
20616
+ this.captures.delete(oldest.value);
20617
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
20618
+ }
20619
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
20620
+ }
20621
+ /** Drop everything (shutdown). */
20622
+ clear() {
20623
+ this.captures.clear();
20624
+ this.owners.clear();
20625
+ this.newest.clear();
20626
+ }
20627
+ async fetch(captureId, deviceId, atMs, trigger) {
20628
+ try {
20629
+ const shot = await withTimeout$4(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
20630
+ if (shot === null || shot.base64.length === 0) {
20631
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
20632
+ return;
20633
+ }
20634
+ const raw = Buffer.from(shot.base64, "base64");
20635
+ let data = raw;
20636
+ try {
20637
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
20638
+ } catch {}
20639
+ const held = this.captures.get(captureId);
20640
+ if (held === void 0) return;
20641
+ this.captures.set(captureId, {
20642
+ ...held,
20643
+ files: stillShelfMedia(data, atMs)
20644
+ });
20645
+ this.deps.logger.info(`${trigger} still captured`, {
20646
+ tags: { deviceId },
20647
+ meta: {
20648
+ trigger,
20649
+ bytes: data.byteLength,
20650
+ tookMs: this.deps.now() - held.startedAt
20651
+ }
20652
+ });
20653
+ } catch (err) {
20654
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
20655
+ }
20656
+ }
20657
+ /**
20658
+ * A branch that drops work says so. This one costs the operator the picture
20659
+ * on a notification they DID receive, so it is a warn and it carries the
20660
+ * camera — the only key the question is ever asked with.
20661
+ */
20662
+ reportMiss(deviceId, trigger, reason) {
20663
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
20664
+ tags: { deviceId },
20665
+ meta: {
20666
+ trigger,
20667
+ reason
20668
+ }
20669
+ });
20670
+ }
20671
+ };
20672
+ /**
20673
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
20674
+ *
20675
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
20676
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
20677
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
20678
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
20679
+ * NOTHING and the notification ships text-only with the ordinary line saying
20680
+ * so. Inventing a centre crop would answer a different question from the one
20681
+ * the operator asked.
20682
+ */
20683
+ function stillShelfMedia(data, timestamp) {
20684
+ const base64 = data.toString("base64");
20685
+ const file = (kind) => ({
20686
+ mediaId: nextUnstoredMediaId(),
20687
+ kind,
20688
+ base64,
20689
+ sizeBytes: data.byteLength,
20690
+ timestamp
20691
+ });
20692
+ return [
20693
+ file("keyFrameSmall"),
20694
+ file("keyFrame"),
20695
+ file("fullFrame")
20696
+ ];
20697
+ }
20698
+ /** Reject if `promise` does not settle within `ms`. */
20699
+ function withTimeout$4(promise, ms) {
20700
+ return new Promise((resolve, reject) => {
20701
+ const timer = setTimeout(() => {
20702
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
20703
+ }, ms);
20704
+ promise.then((value) => {
20705
+ clearTimeout(timer);
20706
+ resolve(value);
20707
+ }, (err) => {
20708
+ clearTimeout(timer);
20709
+ reject(err instanceof Error ? err : new Error(String(err)));
20710
+ });
20711
+ });
20712
+ }
20713
+ /** The canvas behind a tile whose aspect does not fill its cell. */
20714
+ var MOSAIC_BACKGROUND = {
20715
+ r: 17,
20716
+ g: 17,
20717
+ b: 17
20718
+ };
20719
+ /** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
20720
+ var TILE_ASPECT = 9 / 16;
20721
+ /** Height of the per-tile label strip, as a fraction of the tile height. */
20722
+ var LABEL_BAND_FRACTION = .14;
20723
+ var LABEL_MIN_PX = 11;
20724
+ /** Height of the mosaic's own title band, as a fraction of a tile's height. */
20725
+ var TITLE_BAND_FRACTION = .22;
20726
+ var TITLE_MIN_PX = 16;
20727
+ /**
20728
+ * Average glyph width as a fraction of the font size, for bold sans-serif.
20729
+ *
20730
+ * An ESTIMATE, and it has to be: librsvg lays the text out long after this
20731
+ * string is written, so there are no metrics to measure against. The same
20732
+ * number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
20733
+ * GENEROUS, because a label that shrank slightly more than it had to is
20734
+ * invisible and one that overflowed runs into the tile beside it. The first
20735
+ * live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
20736
+ * to `…· anima` at the tile edge, mid-word.
20737
+ */
20738
+ var GLYPH_ADVANCE = .56;
20739
+ /** Fraction of the band a label may occupy, leaving the padding either side. */
20740
+ var LABEL_USABLE_FRACTION = .94;
20741
+ /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
20742
+ * turn the overlay into a document librsvg refuses. */
20743
+ function escapeXml$1(s) {
20744
+ return s.replace(/[<>&'"]/g, (ch) => {
20745
+ switch (ch) {
20746
+ case "<": return "&lt;";
20747
+ case ">": return "&gt;";
20748
+ case "&": return "&amp;";
20749
+ case "'": return "&apos;";
20750
+ default: return "&quot;";
20751
+ }
20752
+ });
20753
+ }
20754
+ /**
20755
+ * The single decision about drawn text — and the home of the DEFAULT.
20756
+ *
20757
+ * `undefined` means `none`, not `all`: a rule stored before this option existed
20758
+ * has no `captions` key, and the operator's request is that such a rule deliver
20759
+ * pictures. The schema materialises the same default on read
20760
+ * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
20761
+ * had a rule — the test bridge and the spec. Both agreeing is the point.
20762
+ */
20763
+ function mosaicTextPlan(captions) {
20764
+ if (captions === "all") return {
20765
+ tileLabels: true,
20766
+ titleBand: true
20767
+ };
20768
+ if (captions === "tiles") return {
20769
+ tileLabels: true,
20770
+ titleBand: false
20771
+ };
20772
+ return {
20773
+ tileLabels: false,
20774
+ titleBand: false
20775
+ };
20776
+ }
20777
+ /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
20778
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
20779
+ *
20780
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
20781
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
20782
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
20783
+ function mosaicGrid(n) {
20784
+ const count = Math.max(1, Math.floor(n));
20785
+ const cols = Math.ceil(Math.sqrt(count));
20786
+ return {
20787
+ cols,
20788
+ rows: Math.ceil(count / cols)
20789
+ };
20790
+ }
20791
+ /**
20792
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
20793
+ * can no longer have a hole in it.
20794
+ *
20795
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
20796
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
20797
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
20798
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
20799
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
20800
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
20801
+ * elementi non dovrebbe renderizzare buchi neri"*.
20802
+ *
20803
+ * Counting ROWS instead removes the failure by construction rather than by
20804
+ * patching it. Every row spans the full width and divides it among the tiles it
20805
+ * actually has, so a row is full whatever the remainder and there is no such
20806
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
20807
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
20808
+ *
20809
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
20810
+ * so row 0 is the head of the ranking; putting the remainder at the top would
20811
+ * make the WORST tile the big one.
20812
+ *
20813
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
20814
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
20815
+ * 16:9 picture centred in a double-height cell with bars over and under it.
20816
+ */
20817
+ function mosaicRowCounts(n) {
20818
+ const count = Math.max(1, Math.floor(n));
20819
+ const { rows } = mosaicGrid(count);
20820
+ const base = Math.floor(count / rows);
20821
+ const remainder = count % rows;
20822
+ const counts = [];
20823
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
20824
+ return counts;
20825
+ }
20826
+ /**
20827
+ * Where every tile lands. Pure — no bytes, no sharp, no clock.
20828
+ *
20829
+ * Exported because the rectangles are DATA the artefact should carry: a viewer
20830
+ * that wants a tappable mosaic reads these rather than re-deriving a layout
20831
+ * that could drift from the one actually rendered.
20832
+ *
20833
+ * The rectangles COVER the picture area exactly — that is the property the
20834
+ * spec asserts, and it is the one the old grid broke.
20835
+ */
20836
+ function mosaicLayout(input) {
20837
+ const rowCounts = mosaicRowCounts(input.tileCount);
20838
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
20839
+ const firstRowCount = rowCounts[0] ?? 1;
20840
+ const tileWidth = Math.floor(width / firstRowCount);
20841
+ const tileHeight = Math.round(tileWidth * TILE_ASPECT);
20842
+ const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
20843
+ const tiles = [];
20844
+ let y = titleHeight;
20845
+ let index = 0;
20846
+ for (const count of rowCounts) {
20847
+ const nominalWidth = Math.floor(width / count);
20848
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
20849
+ let x = 0;
20850
+ for (let inRow = 0; inRow < count; inRow += 1) {
20851
+ const w = inRow === count - 1 ? width - x : nominalWidth;
20852
+ tiles.push({
20853
+ index,
20854
+ x,
20855
+ y,
20856
+ w,
20857
+ h: rowHeight
20858
+ });
20859
+ x += w;
20860
+ index += 1;
20861
+ }
20862
+ y += rowHeight;
20863
+ }
20864
+ return {
20865
+ width,
20866
+ height: y,
20867
+ cols: Math.max(...rowCounts),
20868
+ rows: rowCounts.length,
20869
+ rowCounts,
20870
+ tileWidth,
20871
+ tileHeight,
20872
+ titleHeight,
20873
+ tiles
20874
+ };
20875
+ }
20876
+ /**
20877
+ * Shrink, then ELIDE, until the line fits `usablePx`.
20878
+ *
20879
+ * Shrinking is tried first because a slightly smaller label still says
20880
+ * everything; only past `minPx` is the tail cut, and then with an ellipsis so
20881
+ * the reader can SEE that something was cut. The failure this replaces was
20882
+ * silent: SVG simply clips at the viewport, so an overlong label lost its last
20883
+ * word mid-glyph and looked like a rendering bug rather than a truncation.
20884
+ *
20885
+ * Pure and exported so the arithmetic is testable without rendering a pixel.
20886
+ */
20887
+ function fitLine(text, usablePx, basePx, minPx) {
20888
+ const flat = text.trim().replace(/\s+/g, " ");
20889
+ const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
20890
+ let fontPx = Math.max(minPx, Math.round(basePx));
20891
+ while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
20892
+ if (fits(flat, fontPx)) return {
20893
+ text: flat,
20894
+ fontPx
20895
+ };
20896
+ const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
20897
+ return {
20898
+ text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
20899
+ fontPx
20900
+ };
20901
+ }
20902
+ /** The label strip drawn across the bottom of one tile. */
20903
+ function tileLabelSvg(width, height, label) {
20904
+ const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
20905
+ const basePx = Math.round(bandHeight * .62);
20906
+ const padding = Math.round(basePx * .6);
20907
+ const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
20908
+ const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
20909
+ return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
20910
+ <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
20911
+ <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
20912
+ </svg>`);
20913
+ }
20914
+ /** The mosaic's own title band. */
20915
+ function titleSvg(width, height, caption) {
20916
+ const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
20917
+ const baseline = Math.round(height / 2 + fitted.fontPx * .36);
20918
+ return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
20919
+ <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
20920
+ <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
20921
+ </svg>`);
20922
+ }
20923
+ /**
20924
+ * Compose the mosaic.
20925
+ *
20926
+ * Every tile is fitted with `contain` onto the cell background rather than
20927
+ * cropped: a `cover` fit would silently cut the subject out of a portrait crop,
20928
+ * and a tile that does not show what it is about is worse than a letterboxed
20929
+ * one.
20930
+ *
20931
+ * A tile sharp cannot read is DROPPED and counted, never composited as a blank
20932
+ * cell — a black square in a digest reads as "the camera saw nothing", which is
20933
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
20934
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
20935
+ * remainder to leave empty either.
20936
+ *
20937
+ * @throws when every tile was dropped, and when sharp cannot produce the final
20938
+ * canvas at all; the caller treats both as "no mosaic" and still
20939
+ * delivers the counts.
20940
+ */
20941
+ async function renderMosaic(request, logger) {
20942
+ const plan = mosaicTextPlan(request.captions);
20943
+ const usable = [];
20944
+ for (const tile of request.tiles) try {
20945
+ await (0, sharp.default)(tile.jpeg).metadata();
20946
+ usable.push(tile);
20947
+ } catch (err) {
20948
+ logger.warn(`${request.purpose} mosaic: a tile could not be decoded and was dropped`, {
20949
+ tags: { deviceId: tile.deviceId },
20950
+ meta: {
20951
+ ...request.purpose === "summary" ? { trackId: tile.sourceId } : { sourceId: tile.sourceId },
20952
+ error: String(err)
20953
+ }
20954
+ });
20955
+ }
20956
+ const droppedTiles = request.tiles.length - usable.length;
20957
+ if (usable.length === 0) throw new Error(`${request.purpose} mosaic: no tile could be decoded (${request.tiles.length} offered)`);
20958
+ const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
20959
+ const layout = mosaicLayout({
20960
+ tileCount: usable.length,
20961
+ ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
20962
+ withTitle: caption.length > 0
20963
+ });
20964
+ const composites = await Promise.all(usable.map(async (tile, index) => {
20965
+ const rect = layout.tiles[index];
20966
+ if (rect === void 0) throw new Error(`${request.purpose} mosaic: layout lost a tile`);
20967
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
20968
+ const fitted = (0, sharp.default)(tile.jpeg).resize({
20969
+ width: rect.w,
20970
+ height: rect.h,
20971
+ fit: "contain",
20972
+ background: MOSAIC_BACKGROUND
20973
+ });
20974
+ return {
20975
+ input: await (label.length === 0 ? fitted : fitted.composite([{
20976
+ input: tileLabelSvg(rect.w, rect.h, label),
20977
+ top: 0,
20978
+ left: 0
20979
+ }])).jpeg({ quality: 82 }).toBuffer(),
20980
+ top: rect.y,
20981
+ left: rect.x
20982
+ };
20983
+ }));
20984
+ if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
20985
+ input: titleSvg(layout.width, layout.titleHeight, caption),
20986
+ top: 0,
20987
+ left: 0
20988
+ });
20989
+ return {
20990
+ jpeg: await (0, sharp.default)({ create: {
20991
+ width: layout.width,
20992
+ height: layout.height,
20993
+ channels: 3,
20994
+ background: MOSAIC_BACKGROUND
20995
+ } }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
20996
+ layout,
20997
+ droppedTiles
20998
+ };
20999
+ }
21000
+ //#endregion
21001
+ //#region src/notification-center/linked-stills.ts
21002
+ /**
21003
+ * The linked-camera shelf — the photographs a SENSOR notification carries of
21004
+ * every camera that links the sensor (ADR 0632).
21005
+ *
21006
+ * Same three properties as the still shelf (`still-shelf.ts`), for the same
21007
+ * reasons: not a record (RAM, TTL, no timeline track), the capture starts at the
21008
+ * trigger and is never awaited (owner ids minted synchronously), and nothing is
21009
+ * silent (one line per camera with its outcome, one per event on the sensor).
21010
+ * A separate module because the unit is a SET: exclusion, freshness and the
21011
+ * one composition are decisions about the set, and none of them belongs in a
21012
+ * shelf that photographs one camera for an absence.
21013
+ *
21014
+ * Two reads can only ever ADD a camera back, never take one away on their own
21015
+ * failure (D49): a wrapper-state read (run in parallel with the capture) that
21016
+ * misses or throws keeps the tile, and a freshness read that misses or throws
21017
+ * keeps every tile. Only a read that ANSWERED
21018
+ * `sleeping` / `disabled` / … or a capture time older than
21019
+ * {@link SENSOR_LINKED_MAX_FRAME_AGE_MS} removes a tile — and says so, on that
21020
+ * camera.
21021
+ */
21022
+ var NC_LINKED_MOSAIC_PREFIX = "nc-mosaic:";
21023
+ var NC_LINKED_CAMERA_PREFIX = "nc-linked:";
21024
+ function isLinkedStillId(id) {
21025
+ return id.startsWith("nc-mosaic:") || id.startsWith("nc-linked:");
21026
+ }
21027
+ /** Per camera — the marker's own bound (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`). */
21028
+ var SENSOR_LINKED_CAPTURE_TIMEOUT_MS = 2500;
21029
+ /** The capture-free wrapper-state read, per camera. It runs IN PARALLEL with
21030
+ * the capture and under the capture's own bound, so it costs no extra time and
21031
+ * a slow-but-honest answer is still heard: a blocking reason EXCLUDES the tile
21032
+ * even when the capture returned bytes (they are a state card, not a
21033
+ * photograph). A read that misses the bound or fails is UNKNOWN and keeps the
21034
+ * tile (D49). */
21035
+ var SENSOR_LINKED_STATE_READ_TIMEOUT_MS = SENSOR_LINKED_CAPTURE_TIMEOUT_MS;
21036
+ Math.max(SENSOR_LINKED_STATE_READ_TIMEOUT_MS, SENSOR_LINKED_CAPTURE_TIMEOUT_MS) + 400;
21037
+ /** Hard bound on sets held at once, on top of the TTL. */
21038
+ var MAX_SETS = 32;
21039
+ function isLandedTile(r) {
21040
+ return r.outcome === "landed" && r.jpeg !== void 0;
21041
+ }
21042
+ var LinkedReadTimeout = class extends Error {
21043
+ constructor(what, ms) {
21044
+ super(`${what} timed out after ${String(ms)}ms`);
21045
+ this.name = "LinkedReadTimeout";
21046
+ }
21047
+ };
21048
+ var NcLinkedStillShelf = class {
21049
+ deps;
21050
+ sets = /* @__PURE__ */ new Map();
21051
+ owners = /* @__PURE__ */ new Map();
21052
+ constructor(deps) {
21053
+ this.deps = deps;
21054
+ }
21055
+ /** SYNCHRONOUS: the evaluation that follows names these owners. */
21056
+ captureSet(sensorId, cameraIds, atMs) {
21057
+ const now = this.deps.now();
21058
+ this.prune(now);
21059
+ const setId = (0, node_crypto.randomUUID)();
21060
+ const stills = {
21061
+ sensorId,
21062
+ cameraIds: [...cameraIds],
21063
+ mosaicOwnerId: `${NC_LINKED_MOSAIC_PREFIX}${(0, node_crypto.randomUUID)()}`,
21064
+ cameraOwnerIds: cameraIds.map(() => `${NC_LINKED_CAMERA_PREFIX}${(0, node_crypto.randomUUID)()}`)
21065
+ };
21066
+ this.sets.set(setId, {
21067
+ stills,
21068
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
21069
+ atMs,
21070
+ tiles: /* @__PURE__ */ new Map(),
21071
+ mosaic: []
21072
+ });
21073
+ this.owners.set(stills.mosaicOwnerId, {
21074
+ setId,
21075
+ camera: "mosaic"
21076
+ });
21077
+ stills.cameraOwnerIds.forEach((ownerId, i) => {
21078
+ const cameraId = cameraIds[i];
21079
+ if (cameraId !== void 0) this.owners.set(ownerId, {
21080
+ setId,
21081
+ camera: cameraId
21082
+ });
21083
+ });
21084
+ this.run(setId, stills, atMs, now).catch((err) => {
21085
+ this.deps.logger.warn("sensor notification pictures failed — it ships text-only", {
21086
+ tags: { deviceId: sensorId },
21087
+ meta: { error: String(err) }
21088
+ });
21089
+ });
21090
+ return stills;
21091
+ }
21092
+ /** `undefined` = not an owner this shelf minted (or pruned); `[]` = nothing (yet). */
21093
+ get(ownerId) {
21094
+ const ref = this.owners.get(ownerId);
21095
+ if (ref === void 0) return void 0;
21096
+ const held = this.sets.get(ref.setId);
21097
+ if (held === void 0) return [];
21098
+ if (ref.camera === "mosaic") return held.mosaic;
21099
+ return held.tiles.get(ref.camera) ?? [];
21100
+ }
21101
+ prune(now) {
21102
+ for (const [setId, held] of this.sets) if (held.expiresAt <= now) this.sets.delete(setId);
21103
+ while (this.sets.size > MAX_SETS) {
21104
+ const oldest = this.sets.keys().next();
21105
+ if (oldest.done === true) break;
21106
+ this.sets.delete(oldest.value);
21107
+ }
21108
+ for (const [ownerId, ref] of this.owners) if (!this.sets.has(ref.setId)) this.owners.delete(ownerId);
21109
+ }
21110
+ clear() {
21111
+ this.sets.clear();
21112
+ this.owners.clear();
21113
+ }
21114
+ async run(setId, stills, atMs, startedAt) {
21115
+ const results = await Promise.all(stills.cameraIds.map((id) => this.captureOne(id, startedAt)));
21116
+ const judged = await this.judgeFreshness(stills.sensorId, results, atMs);
21117
+ for (const r of judged) this.logTile(stills.sensorId, r);
21118
+ const landed = judged.filter(isLandedTile);
21119
+ const composed = await this.compose(stills.sensorId, landed, atMs);
21120
+ const undrawable = landed.length - composed.drawn;
21121
+ this.deps.logger.info(composed.mosaic.length === 0 ? "sensor notification has no linked picture — it ships text-only" : "sensor notification pictures ready", {
21122
+ tags: { deviceId: stills.sensorId },
21123
+ meta: {
21124
+ cameras: stills.cameraIds.length,
21125
+ landed: composed.drawn,
21126
+ ...undrawable > 0 ? { undrawable } : {},
21127
+ dropped: Object.fromEntries(judged.filter((r) => r.outcome !== "landed").map((r) => [String(r.cameraId), r.outcome])),
21128
+ tookMs: this.deps.now() - startedAt
21129
+ }
21130
+ });
21131
+ const held = this.sets.get(setId);
21132
+ if (held === void 0) return;
21133
+ this.sets.set(setId, {
21134
+ ...held,
21135
+ tiles: new Map(composed.servable.map((r) => [r.cameraId, stillShelfMedia(r.jpeg, atMs)])),
21136
+ mosaic: composed.mosaic
21137
+ });
21138
+ }
21139
+ async captureOne(cameraId, startedAt) {
21140
+ const took = () => this.deps.now() - startedAt;
21141
+ if (this.deps.isOnline(cameraId) === false) return {
21142
+ cameraId,
21143
+ outcome: "excluded",
21144
+ reason: "offline",
21145
+ tookMs: took()
21146
+ };
21147
+ const [verdict, capture] = await Promise.all([this.wrapperVerdict(cameraId), this.captureStill(cameraId)]);
21148
+ if (verdict.reason !== null) return {
21149
+ cameraId,
21150
+ outcome: "excluded",
21151
+ reason: verdict.reason,
21152
+ tookMs: took()
21153
+ };
21154
+ return {
21155
+ cameraId,
21156
+ ...capture,
21157
+ ...verdict.unknown !== null ? { stateUnknown: verdict.unknown } : {},
21158
+ tookMs: took()
21159
+ };
21160
+ }
21161
+ async captureStill(cameraId) {
21162
+ try {
21163
+ const shot = await withTimeout$3(this.deps.getSnapshot(cameraId), SENSOR_LINKED_CAPTURE_TIMEOUT_MS, "snapshot cap");
21164
+ if (shot === null || shot.base64.length === 0) return { outcome: "empty" };
21165
+ const jpeg = await this.decodableStill(Buffer.from(shot.base64, "base64"));
21166
+ if (jpeg === null) return {
21167
+ outcome: "error",
21168
+ reason: "undecodable"
21169
+ };
21170
+ return {
21171
+ outcome: "landed",
21172
+ jpeg
21173
+ };
21174
+ } catch (err) {
21175
+ return {
21176
+ outcome: err instanceof LinkedReadTimeout ? "timeout" : "error",
21177
+ reason: String(err)
21178
+ };
21179
+ }
21180
+ }
21181
+ /**
21182
+ * The downscaled still, or null when the bytes are not a picture. A resize
21183
+ * that fails on bytes sharp can still READ keeps them raw (a worse picture,
21184
+ * never a missing one); bytes it cannot read never ship — a decode failure is
21185
+ * a fact about the bytes, not a failed read.
21186
+ */
21187
+ async decodableStill(raw) {
21188
+ try {
21189
+ return await downscaleFullFrameJpeg(raw, 960, 540);
21190
+ } catch {
21191
+ try {
21192
+ await (0, sharp.default)(raw).metadata();
21193
+ return raw;
21194
+ } catch {
21195
+ return null;
21196
+ }
21197
+ }
21198
+ }
21199
+ /** A failed, slow or empty read is UNKNOWN and never excludes (D49); the
21200
+ * camera's own outcome line then carries `stateUnknown` with the cause. */
21201
+ async wrapperVerdict(cameraId) {
21202
+ const read = this.deps.getSnapshotState;
21203
+ if (read === void 0) return {
21204
+ reason: null,
21205
+ unknown: null
21206
+ };
21207
+ try {
21208
+ const state = await withTimeout$3(read(cameraId), SENSOR_LINKED_STATE_READ_TIMEOUT_MS, "snapshot state read");
21209
+ return state === void 0 ? {
21210
+ reason: null,
21211
+ unknown: "empty"
21212
+ } : {
21213
+ reason: state.reason,
21214
+ unknown: null
21215
+ };
21216
+ } catch (err) {
21217
+ return {
21218
+ reason: null,
21219
+ unknown: err instanceof LinkedReadTimeout ? "timeout" : "error"
21220
+ };
21221
+ }
21222
+ }
21223
+ async judgeFreshness(sensorId, results, atMs) {
21224
+ const read = this.deps.getFrameCapturedAt;
21225
+ const landedIds = results.filter((r) => r.outcome === "landed").map((r) => r.cameraId);
21226
+ if (read === void 0 || landedIds.length === 0) return results;
21227
+ let capturedAt;
21228
+ try {
21229
+ capturedAt = await withTimeout$3(read(landedIds), 400, "frame freshness read");
21230
+ } catch (err) {
21231
+ this.deps.logger.warn("linked pictures freshness unverified — keeping every tile", {
21232
+ tags: { deviceId: sensorId },
21233
+ meta: { error: String(err) }
21234
+ });
21235
+ return results;
21236
+ }
21237
+ return results.map((r) => {
21238
+ if (r.outcome !== "landed") return r;
21239
+ const at = capturedAt.get(r.cameraId);
21240
+ if (at === void 0 || at === null) return r;
21241
+ if (at >= atMs - 1e4) return r;
21242
+ return {
21243
+ cameraId: r.cameraId,
21244
+ outcome: "stale",
21245
+ reason: `frame ${String(atMs - at)} ms older than the event`,
21246
+ tookMs: r.tookMs
21247
+ };
21248
+ });
21249
+ }
21250
+ async compose(sensorId, landed, atMs) {
21251
+ const first = landed[0];
21252
+ if (first === void 0) return {
21253
+ mosaic: [],
21254
+ servable: [],
21255
+ drawn: 0
21256
+ };
21257
+ if (landed.length === 1) return {
21258
+ mosaic: stillShelfMedia(first.jpeg, atMs),
21259
+ servable: landed,
21260
+ drawn: 1
21261
+ };
21262
+ try {
21263
+ const { jpeg, droppedTiles } = await renderMosaic({
21264
+ purpose: "sensor",
21265
+ tiles: landed.map((r) => ({
21266
+ jpeg: r.jpeg,
21267
+ deviceId: r.cameraId,
21268
+ sourceId: String(r.cameraId)
21269
+ }))
21270
+ }, this.deps.logger);
21271
+ return {
21272
+ mosaic: stillShelfMedia(jpeg, atMs),
21273
+ servable: landed,
21274
+ drawn: landed.length - droppedTiles
21275
+ };
21276
+ } catch (err) {
21277
+ this.deps.logger.warn("sensor mosaic could not be composed — shipping the first camera", {
21278
+ tags: { deviceId: sensorId },
21279
+ meta: {
21280
+ error: String(err),
21281
+ offered: landed.length,
21282
+ shipped: first.cameraId
21283
+ }
21284
+ });
21285
+ return {
21286
+ mosaic: stillShelfMedia(first.jpeg, atMs),
21287
+ servable: landed,
21288
+ drawn: 1
21289
+ };
21290
+ }
21291
+ }
21292
+ logTile(sensorId, r) {
21293
+ const extras = {
21294
+ tags: { deviceId: r.cameraId },
21295
+ meta: {
21296
+ sensorId,
21297
+ outcome: r.outcome,
21298
+ tookMs: r.tookMs,
21299
+ ...r.reason !== void 0 ? { reason: r.reason } : {},
21300
+ ...r.stateUnknown !== void 0 ? { stateUnknown: r.stateUnknown } : {}
21301
+ }
21302
+ };
21303
+ if (r.outcome === "excluded") this.deps.logger.info("linked camera excluded from the sensor notification", extras);
21304
+ else if (r.outcome === "landed" && r.stateUnknown !== void 0) this.deps.logger.info("linked camera photographed for the sensor notification — state unknown, tile kept", extras);
21305
+ else if (r.outcome === "landed") this.deps.logger.debug("linked camera photographed for the sensor notification", extras);
21306
+ else this.deps.logger.warn("linked camera gave no usable picture for the sensor notification", extras);
21307
+ }
21308
+ };
21309
+ /** Reject with {@link LinkedReadTimeout} if `promise` does not settle within `ms`. */
21310
+ function withTimeout$3(promise, ms, what) {
21311
+ return new Promise((resolve, reject) => {
21312
+ const timer = setTimeout(() => reject(new LinkedReadTimeout(what, ms)), ms);
21313
+ promise.then((value) => {
21314
+ clearTimeout(timer);
21315
+ resolve(value);
21316
+ }, (err) => {
21317
+ clearTimeout(timer);
21318
+ reject(err instanceof Error ? err : new Error(String(err)));
21319
+ });
21320
+ });
21321
+ }
21322
+ //#endregion
20134
21323
  //#region src/notification-center/liveness-ledger.ts
20135
21324
  /**
20136
21325
  * @durable class=ledger owner=notification-center
@@ -22970,279 +24159,6 @@ var NcSnoozeStore = class {
22970
24159
  return parsed.success ? parsed.data : null;
22971
24160
  }
22972
24161
  };
22973
- /** JPEG quality for the downscaled full frame — matches the crop path. */
22974
- var FULL_FRAME_QUALITY = 80;
22975
- /**
22976
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
22977
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
22978
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
22979
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
22980
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
22981
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
22982
- */
22983
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
22984
- return (0, sharp.default)(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
22985
- fit: "inside",
22986
- withoutEnlargement: true
22987
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
22988
- }
22989
- //#endregion
22990
- //#region src/notification-center/still-shelf.ts
22991
- /**
22992
- * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
22993
- * name owns a frame.
22994
- *
22995
- * ## Why a trigger needs one at all
22996
- *
22997
- * The attachment ladder resolves media by OWNER, and most triggers have one: an
22998
- * object or package event owns its crops, a closed track owns its best shot, a
22999
- * doorbell press owns the marker track the same press projected
23000
- * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
23001
- * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
23002
- * same reason in both cases — the subject is an ABSENCE:
23003
- *
23004
- * - an **audio** match: nothing was boxed, nothing was tracked, and —
23005
- * deliberately — nothing is persisted at all. A confirmed window is a claim
23006
- * about sound that has already stopped, and `event-intake.ts` states why
23007
- * replaying it later would be wrong.
23008
- * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
23009
- * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
23010
- * nobody and the ladder logs `no still could be resolved … owners=[]`.
23011
- *
23012
- * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
23013
- * of the trigger. Not of the sound, not of the object that left — of what the
23014
- * camera can see now that it happened. For a freed parking space that is
23015
- * exactly the answer the operator wants: the space, empty.
23016
- *
23017
- * **One shelf, not two.** The mechanism is identical down to the reuse window,
23018
- * and the only thing that differs between the two triggers is the GATE deciding
23019
- * that a photograph is owed at all — which belongs at the trigger site, where
23020
- * the rules are, and not here. A twin module would be a second derivation of
23021
- * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
23022
- * can say which absence they are about.
23023
- *
23024
- * ## Three properties, and each one is a decision
23025
- *
23026
- * **It is not a record.** The bytes live here, in RAM, under an owner id and a
23027
- * TTL that covers the outbox's whole retry horizon — and nowhere else. The
23028
- * alternative was the doorbell's: materialise a synthetic marker track through
23029
- * `SyntheticTrackMaterializer` and let the notification name it. That would put
23030
- * a durable Track on the camera's timeline for every confirmed window and every
23031
- * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
23032
- * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
23033
- * precisely to bound how many audio markers a camera may emit). A notification
23034
- * must not manufacture timeline history as a side effect of wanting a picture.
23035
- *
23036
- * **The capture STARTS immediately and is never awaited.** The subject is
23037
- * transient — a scream is over before a snapshot round-trip completes, and a
23038
- * freed space is about to be taken by the next car — so the fetch is kicked off
23039
- * at the trigger, before the rule evaluation runs, and the owner id is minted
23040
- * synchronously so the outbox row can name it. The bytes land while the row
23041
- * waits in the queue, and the dispatcher's existing bounded still-wait (or the
23042
- * pause its own footage render already costs) picks them up. A camera that
23043
- * never answers costs the picture and never the notification.
23044
- *
23045
- * **Two triggers seconds apart share ONE capture.** A barking dog confirms
23046
- * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
23047
- * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
23048
- * Without a reuse window this would photograph a camera at whatever rate the
23049
- * sound happens to occur. Each trigger still gets its OWN owner id, so two
23050
- * outbox rows are never mistaken for one subject; they merely point at the same
23051
- * frame, which is the truth — the scene did not change in ten seconds. The
23052
- * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
23053
- * emptied zone ten seconds apart are two claims about one scene.
23054
- *
23055
- * Nothing here is silent: a capture that lands and a camera that refuses each
23056
- * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
23057
- * and 615 not" is the only form that question is ever asked in.
23058
- */
23059
- /**
23060
- * The owner-id namespace. It is what routes a lookup here instead of to the
23061
- * media store, and it is the reason the dispatcher needs no audio or occupancy
23062
- * branch — `getMediaForOwner` answers for all of them under one signature.
23063
- */
23064
- var NC_STILL_SHELF_PREFIX = "nc-still:";
23065
- /** True for an owner id this shelf minted. */
23066
- function isStillShelfId(id) {
23067
- return id.startsWith(NC_STILL_SHELF_PREFIX);
23068
- }
23069
- /**
23070
- * How long the bytes are held.
23071
- *
23072
- * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
23073
- * ten minutes; fifteen covers that with room for a slow drain. Past it the row
23074
- * ships text-only, which is the correct degradation for a photograph of a scene
23075
- * that is a quarter of an hour stale anyway.
23076
- */
23077
- var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
23078
- /** A hanging snapshot cap must not pin a capture slot forever. */
23079
- var SNAPSHOT_TIMEOUT_MS = 8e3;
23080
- /**
23081
- * Hard bound on held captures. Reached only if every camera on the hub triggers
23082
- * one inside a single TTL; the oldest is dropped first, which costs a
23083
- * fifteen-minute-old picture nobody is waiting for.
23084
- */
23085
- var MAX_CAPTURES = 64;
23086
- var NcStillShelf = class {
23087
- deps;
23088
- /** captureId → the photograph. */
23089
- captures = /* @__PURE__ */ new Map();
23090
- /** ownerId → captureId. Several owners may name one capture (the reuse window). */
23091
- owners = /* @__PURE__ */ new Map();
23092
- /** deviceId → its newest capture, for the reuse window. */
23093
- newest = /* @__PURE__ */ new Map();
23094
- constructor(deps) {
23095
- this.deps = deps;
23096
- }
23097
- /**
23098
- * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
23099
- * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
23100
- * path and the outbox row is built from what this returns.
23101
- */
23102
- capture(deviceId, atMs, trigger) {
23103
- const now = this.deps.now();
23104
- this.prune(now);
23105
- const ownerId = `${NC_STILL_SHELF_PREFIX}${(0, node_crypto.randomUUID)()}`;
23106
- const recent = this.newest.get(deviceId);
23107
- if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
23108
- this.owners.set(ownerId, recent.captureId);
23109
- return ownerId;
23110
- }
23111
- const captureId = (0, node_crypto.randomUUID)();
23112
- this.captures.set(captureId, {
23113
- deviceId,
23114
- trigger,
23115
- startedAt: now,
23116
- expiresAt: now + NC_STILL_SHELF_TTL_MS,
23117
- files: []
23118
- });
23119
- this.newest.set(deviceId, {
23120
- captureId,
23121
- at: now
23122
- });
23123
- this.owners.set(ownerId, captureId);
23124
- this.fetch(captureId, deviceId, atMs, trigger);
23125
- return ownerId;
23126
- }
23127
- /**
23128
- * The media an owner id holds, or `undefined` when this shelf never minted
23129
- * it. An EMPTY array is a different answer: the capture exists and has not
23130
- * landed (or never will), which is exactly the case the dispatcher's bounded
23131
- * wait and its `no still could be resolved` line are for.
23132
- */
23133
- get(ownerId) {
23134
- const captureId = this.owners.get(ownerId);
23135
- if (captureId === void 0) return void 0;
23136
- return this.captures.get(captureId)?.files ?? [];
23137
- }
23138
- /** Drop expired captures and the owners that named them. */
23139
- prune(now) {
23140
- for (const [captureId, held] of this.captures) {
23141
- if (held.expiresAt > now) continue;
23142
- this.captures.delete(captureId);
23143
- if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
23144
- }
23145
- while (this.captures.size > MAX_CAPTURES) {
23146
- const oldest = this.captures.keys().next();
23147
- if (oldest.done === true) break;
23148
- const held = this.captures.get(oldest.value);
23149
- this.captures.delete(oldest.value);
23150
- if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
23151
- }
23152
- for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
23153
- }
23154
- /** Drop everything (shutdown). */
23155
- clear() {
23156
- this.captures.clear();
23157
- this.owners.clear();
23158
- this.newest.clear();
23159
- }
23160
- async fetch(captureId, deviceId, atMs, trigger) {
23161
- try {
23162
- const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
23163
- if (shot === null || shot.base64.length === 0) {
23164
- this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
23165
- return;
23166
- }
23167
- const raw = Buffer.from(shot.base64, "base64");
23168
- let data = raw;
23169
- try {
23170
- data = await downscaleFullFrameJpeg(raw, 960, 540);
23171
- } catch {}
23172
- const held = this.captures.get(captureId);
23173
- if (held === void 0) return;
23174
- this.captures.set(captureId, {
23175
- ...held,
23176
- files: stillShelfMedia(data, atMs)
23177
- });
23178
- this.deps.logger.info(`${trigger} still captured`, {
23179
- tags: { deviceId },
23180
- meta: {
23181
- trigger,
23182
- bytes: data.byteLength,
23183
- tookMs: this.deps.now() - held.startedAt
23184
- }
23185
- });
23186
- } catch (err) {
23187
- this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
23188
- }
23189
- }
23190
- /**
23191
- * A branch that drops work says so. This one costs the operator the picture
23192
- * on a notification they DID receive, so it is a warn and it carries the
23193
- * camera — the only key the question is ever asked with.
23194
- */
23195
- reportMiss(deviceId, trigger, reason) {
23196
- this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
23197
- tags: { deviceId },
23198
- meta: {
23199
- trigger,
23200
- reason
23201
- }
23202
- });
23203
- }
23204
- };
23205
- /**
23206
- * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
23207
- *
23208
- * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
23209
- * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
23210
- * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
23211
- * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
23212
- * NOTHING and the notification ships text-only with the ordinary line saying
23213
- * so. Inventing a centre crop would answer a different question from the one
23214
- * the operator asked.
23215
- */
23216
- function stillShelfMedia(data, timestamp) {
23217
- const base64 = data.toString("base64");
23218
- const file = (kind) => ({
23219
- mediaId: nextUnstoredMediaId(),
23220
- kind,
23221
- base64,
23222
- sizeBytes: data.byteLength,
23223
- timestamp
23224
- });
23225
- return [
23226
- file("keyFrameSmall"),
23227
- file("keyFrame"),
23228
- file("fullFrame")
23229
- ];
23230
- }
23231
- /** Reject if `promise` does not settle within `ms`. */
23232
- function withTimeout$3(promise, ms) {
23233
- return new Promise((resolve, reject) => {
23234
- const timer = setTimeout(() => {
23235
- reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
23236
- }, ms);
23237
- promise.then((value) => {
23238
- clearTimeout(timer);
23239
- resolve(value);
23240
- }, (err) => {
23241
- clearTimeout(timer);
23242
- reject(err instanceof Error ? err : new Error(String(err)));
23243
- });
23244
- });
23245
- }
23246
24162
  //#endregion
23247
24163
  //#region src/notification-center/summary/summary-ai.ts
23248
24164
  /**
@@ -23734,293 +24650,6 @@ function findNextWindowOpen(input) {
23734
24650
  endMs: null
23735
24651
  };
23736
24652
  }
23737
- /** The canvas behind a tile whose aspect does not fill its cell. */
23738
- var MOSAIC_BACKGROUND = {
23739
- r: 17,
23740
- g: 17,
23741
- b: 17
23742
- };
23743
- /** Tile aspect — cameras are 16:9 and a square cell would letterbox every one. */
23744
- var TILE_ASPECT = 9 / 16;
23745
- /** Height of the per-tile label strip, as a fraction of the tile height. */
23746
- var LABEL_BAND_FRACTION = .14;
23747
- var LABEL_MIN_PX = 11;
23748
- /** Height of the mosaic's own title band, as a fraction of a tile's height. */
23749
- var TITLE_BAND_FRACTION = .22;
23750
- var TITLE_MIN_PX = 16;
23751
- /**
23752
- * Average glyph width as a fraction of the font size, for bold sans-serif.
23753
- *
23754
- * An ESTIMATE, and it has to be: librsvg lays the text out long after this
23755
- * string is written, so there are no metrics to measure against. The same
23756
- * number and the same reasoning as `preview-frame.ts#fitTitle` — deliberately
23757
- * GENEROUS, because a label that shrank slightly more than it had to is
23758
- * invisible and one that overflowed runs into the tile beside it. The first
23759
- * live mosaic did exactly that: `Videocamera cortile · 07:49 · animal` was cut
23760
- * to `…· anima` at the tile edge, mid-word.
23761
- */
23762
- var GLYPH_ADVANCE = .56;
23763
- /** Fraction of the band a label may occupy, leaving the padding either side. */
23764
- var LABEL_USABLE_FRACTION = .94;
23765
- /** `<`, `>`, `&`, `'`, `"` in a camera name or an operator caption would each
23766
- * turn the overlay into a document librsvg refuses. */
23767
- function escapeXml$1(s) {
23768
- return s.replace(/[<>&'"]/g, (ch) => {
23769
- switch (ch) {
23770
- case "<": return "&lt;";
23771
- case ">": return "&gt;";
23772
- case "&": return "&amp;";
23773
- case "'": return "&apos;";
23774
- default: return "&quot;";
23775
- }
23776
- });
23777
- }
23778
- /**
23779
- * The single decision about drawn text — and the home of the DEFAULT.
23780
- *
23781
- * `undefined` means `none`, not `all`: a rule stored before this option existed
23782
- * has no `captions` key, and the operator's request is that such a rule deliver
23783
- * pictures. The schema materialises the same default on read
23784
- * (`NcSummaryRuleInputSchema`), so this arm only fires for a caller that never
23785
- * had a rule — the test bridge and the spec. Both agreeing is the point.
23786
- */
23787
- function mosaicTextPlan(captions) {
23788
- if (captions === "all") return {
23789
- tileLabels: true,
23790
- titleBand: true
23791
- };
23792
- if (captions === "tiles") return {
23793
- tileLabels: true,
23794
- titleBand: false
23795
- };
23796
- return {
23797
- tileLabels: false,
23798
- titleBand: false
23799
- };
23800
- }
23801
- /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
23802
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
23803
- *
23804
- * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
23805
- * distributes into. `cols` describes the WIDEST row, and is no longer a cell
23806
- * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
23807
- function mosaicGrid(n) {
23808
- const count = Math.max(1, Math.floor(n));
23809
- const cols = Math.ceil(Math.sqrt(count));
23810
- return {
23811
- cols,
23812
- rows: Math.ceil(count / cols)
23813
- };
23814
- }
23815
- /**
23816
- * How many tiles each ROW carries, top to bottom — and the reason the mosaic
23817
- * can no longer have a hole in it.
23818
- *
23819
- * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
23820
- * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
23821
- * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
23822
- * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
23823
- * three-tile digest was a quarter black. Measured on the shipped renderer:
23824
- * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
23825
- * elementi non dovrebbe renderizzare buchi neri"*.
23826
- *
23827
- * Counting ROWS instead removes the failure by construction rather than by
23828
- * patching it. Every row spans the full width and divides it among the tiles it
23829
- * actually has, so a row is full whatever the remainder and there is no such
23830
- * thing as a spare cell. The shapes fall out of it, and they are the ones asked
23831
- * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
23832
- *
23833
- * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
23834
- * so row 0 is the head of the ranking; putting the remainder at the top would
23835
- * make the WORST tile the big one.
23836
- *
23837
- * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
23838
- * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
23839
- * 16:9 picture centred in a double-height cell with bars over and under it.
23840
- */
23841
- function mosaicRowCounts(n) {
23842
- const count = Math.max(1, Math.floor(n));
23843
- const { rows } = mosaicGrid(count);
23844
- const base = Math.floor(count / rows);
23845
- const remainder = count % rows;
23846
- const counts = [];
23847
- for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
23848
- return counts;
23849
- }
23850
- /**
23851
- * Where every tile lands. Pure — no bytes, no sharp, no clock.
23852
- *
23853
- * Exported because the rectangles are DATA the artefact should carry: a viewer
23854
- * that wants a tappable mosaic reads these rather than re-deriving a layout
23855
- * that could drift from the one actually rendered.
23856
- *
23857
- * The rectangles COVER the picture area exactly — that is the property the
23858
- * spec asserts, and it is the one the old grid broke.
23859
- */
23860
- function mosaicLayout(input) {
23861
- const rowCounts = mosaicRowCounts(input.tileCount);
23862
- const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
23863
- const firstRowCount = rowCounts[0] ?? 1;
23864
- const tileWidth = Math.floor(width / firstRowCount);
23865
- const tileHeight = Math.round(tileWidth * TILE_ASPECT);
23866
- const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
23867
- const tiles = [];
23868
- let y = titleHeight;
23869
- let index = 0;
23870
- for (const count of rowCounts) {
23871
- const nominalWidth = Math.floor(width / count);
23872
- const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
23873
- let x = 0;
23874
- for (let inRow = 0; inRow < count; inRow += 1) {
23875
- const w = inRow === count - 1 ? width - x : nominalWidth;
23876
- tiles.push({
23877
- index,
23878
- x,
23879
- y,
23880
- w,
23881
- h: rowHeight
23882
- });
23883
- x += w;
23884
- index += 1;
23885
- }
23886
- y += rowHeight;
23887
- }
23888
- return {
23889
- width,
23890
- height: y,
23891
- cols: Math.max(...rowCounts),
23892
- rows: rowCounts.length,
23893
- rowCounts,
23894
- tileWidth,
23895
- tileHeight,
23896
- titleHeight,
23897
- tiles
23898
- };
23899
- }
23900
- /**
23901
- * Shrink, then ELIDE, until the line fits `usablePx`.
23902
- *
23903
- * Shrinking is tried first because a slightly smaller label still says
23904
- * everything; only past `minPx` is the tail cut, and then with an ellipsis so
23905
- * the reader can SEE that something was cut. The failure this replaces was
23906
- * silent: SVG simply clips at the viewport, so an overlong label lost its last
23907
- * word mid-glyph and looked like a rendering bug rather than a truncation.
23908
- *
23909
- * Pure and exported so the arithmetic is testable without rendering a pixel.
23910
- */
23911
- function fitLine(text, usablePx, basePx, minPx) {
23912
- const flat = text.trim().replace(/\s+/g, " ");
23913
- const fits = (s, fontPx) => s.length * fontPx * GLYPH_ADVANCE <= usablePx;
23914
- let fontPx = Math.max(minPx, Math.round(basePx));
23915
- while (fontPx > minPx && !fits(flat, fontPx)) fontPx = Math.max(minPx, fontPx - 1);
23916
- if (fits(flat, fontPx)) return {
23917
- text: flat,
23918
- fontPx
23919
- };
23920
- const budget = Math.max(1, Math.floor(usablePx / (fontPx * GLYPH_ADVANCE)));
23921
- return {
23922
- text: `${flat.slice(0, Math.max(1, budget - 1)).trimEnd()}…`,
23923
- fontPx
23924
- };
23925
- }
23926
- /** The label strip drawn across the bottom of one tile. */
23927
- function tileLabelSvg(width, height, label) {
23928
- const bandHeight = Math.max(18, Math.round(height * LABEL_BAND_FRACTION));
23929
- const basePx = Math.round(bandHeight * .62);
23930
- const padding = Math.round(basePx * .6);
23931
- const fitted = fitLine(label, width * LABEL_USABLE_FRACTION - padding, basePx, LABEL_MIN_PX);
23932
- const baseline = height - Math.round((bandHeight - fitted.fontPx) / 2) - Math.round(fitted.fontPx * .18);
23933
- return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
23934
- <rect x="0" y="${height - bandHeight}" width="${width}" height="${bandHeight}" fill="black" fill-opacity="0.55"/>
23935
- <text x="${padding}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" fill="white">${escapeXml$1(fitted.text)}</text>
23936
- </svg>`);
23937
- }
23938
- /** The mosaic's own title band. */
23939
- function titleSvg(width, height, caption) {
23940
- const fitted = fitLine(caption, width * LABEL_USABLE_FRACTION, Math.round(height * .5), TITLE_MIN_PX);
23941
- const baseline = Math.round(height / 2 + fitted.fontPx * .36);
23942
- return Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
23943
- <rect x="0" y="0" width="${width}" height="${height}" fill="#111111"/>
23944
- <text x="${Math.round(width / 2)}" y="${baseline}" font-size="${fitted.fontPx}" font-family="sans-serif" font-weight="bold" text-anchor="middle" fill="white">${escapeXml$1(fitted.text)}</text>
23945
- </svg>`);
23946
- }
23947
- /**
23948
- * Compose the mosaic.
23949
- *
23950
- * Every tile is fitted with `contain` onto the cell background rather than
23951
- * cropped: a `cover` fit would silently cut the subject out of a portrait crop,
23952
- * and a tile that does not show what it is about is worse than a letterboxed
23953
- * one.
23954
- *
23955
- * A tile sharp cannot read is DROPPED and counted, never composited as a blank
23956
- * cell — a black square in a digest reads as "the camera saw nothing", which is
23957
- * the opposite of what happened. The layout is computed from the SURVIVORS, and
23958
- * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
23959
- * remainder to leave empty either.
23960
- *
23961
- * @throws when every tile was dropped, and when sharp cannot produce the final
23962
- * canvas at all; the caller treats both as "no mosaic" and still
23963
- * delivers the counts.
23964
- */
23965
- async function renderMosaic(request, logger) {
23966
- const plan = mosaicTextPlan(request.captions);
23967
- const usable = [];
23968
- for (const tile of request.tiles) try {
23969
- await (0, sharp.default)(tile.jpeg).metadata();
23970
- usable.push(tile);
23971
- } catch (err) {
23972
- logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
23973
- tags: { deviceId: tile.deviceId },
23974
- meta: {
23975
- trackId: tile.trackId,
23976
- error: String(err)
23977
- }
23978
- });
23979
- }
23980
- const droppedTiles = request.tiles.length - usable.length;
23981
- if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
23982
- const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
23983
- const layout = mosaicLayout({
23984
- tileCount: usable.length,
23985
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
23986
- withTitle: caption.length > 0
23987
- });
23988
- const composites = await Promise.all(usable.map(async (tile, index) => {
23989
- const rect = layout.tiles[index];
23990
- if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
23991
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
23992
- const fitted = (0, sharp.default)(tile.jpeg).resize({
23993
- width: rect.w,
23994
- height: rect.h,
23995
- fit: "contain",
23996
- background: MOSAIC_BACKGROUND
23997
- });
23998
- return {
23999
- input: await (label.length === 0 ? fitted : fitted.composite([{
24000
- input: tileLabelSvg(rect.w, rect.h, label),
24001
- top: 0,
24002
- left: 0
24003
- }])).jpeg({ quality: 82 }).toBuffer(),
24004
- top: rect.y,
24005
- left: rect.x
24006
- };
24007
- }));
24008
- if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
24009
- input: titleSvg(layout.width, layout.titleHeight, caption),
24010
- top: 0,
24011
- left: 0
24012
- });
24013
- return {
24014
- jpeg: await (0, sharp.default)({ create: {
24015
- width: layout.width,
24016
- height: layout.height,
24017
- channels: 3,
24018
- background: MOSAIC_BACKGROUND
24019
- } }).composite(composites).jpeg({ quality: 82 }).toBuffer(),
24020
- layout,
24021
- droppedTiles
24022
- };
24023
- }
24024
24653
  //#endregion
24025
24654
  //#region src/notification-center/summary/summary-ai-subject-view.ts
24026
24655
  /**
@@ -25344,11 +25973,12 @@ var NcSummaryProducer = class {
25344
25973
  try {
25345
25974
  const plan = mosaicTextPlan(rule.captions);
25346
25975
  rendered = await renderMosaic({
25976
+ purpose: "summary",
25347
25977
  tiles: tiles.map(({ candidate, jpeg }) => ({
25348
25978
  jpeg,
25349
25979
  ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$1(candidate.firstSeen)} · ${candidate.className}` } : {},
25350
25980
  deviceId: candidate.deviceId,
25351
- trackId: candidate.trackId
25981
+ sourceId: candidate.trackId
25352
25982
  })),
25353
25983
  captions: rule.captions,
25354
25984
  ...plan.titleBand ? { caption: summaryCaptionText({
@@ -27711,6 +28341,12 @@ var NotificationCenter = class NotificationCenter {
27711
28341
  */
27712
28342
  stills;
27713
28343
  /**
28344
+ * The photographs a SENSOR notification carries of every camera linking the
28345
+ * sensor (`linked-stills.ts`, ADR 0632). Null when this wiring has no
28346
+ * snapshot cap — such a notification then ships text-only.
28347
+ */
28348
+ linkedStills;
28349
+ /**
27714
28350
  * Rate limit for the "matched NO rule" report — see `reportNoMatch`. Keyed by
27715
28351
  * {@link noMatchReportKey} (device + subject kind + class set), NOT by device:
27716
28352
  * a person's verdict must never consume the window a cat's verdict needed on
@@ -27721,6 +28357,10 @@ var NotificationCenter = class NotificationCenter {
27721
28357
  /** Same rate-limit pair, same key, for the per-camera mute drop — see `reportMutedDrop`. */
27722
28358
  lastMutedReportAt = /* @__PURE__ */ new Map();
27723
28359
  mutedSuppressed = /* @__PURE__ */ new Map();
28360
+ /** Same pair, keyed by CAMERA, for a muted camera left out of a linked set —
28361
+ * see `reportLinkedCameraMuted`. */
28362
+ lastLinkedMutedReportAt = /* @__PURE__ */ new Map();
28363
+ linkedMutedSuppressed = /* @__PURE__ */ new Map();
27724
28364
  /**
27725
28365
  * RAM-only same-edge dedup for the families the durable ledger does NOT
27726
28366
  * govern — stream health and the operator's disable switch. Both are already
@@ -28371,6 +29011,14 @@ var NotificationCenter = class NotificationCenter {
28371
29011
  logger: this.logger.child("still-shelf"),
28372
29012
  now: this.now
28373
29013
  }) : null;
29014
+ this.linkedStills = getSnapshot !== void 0 ? new NcLinkedStillShelf({
29015
+ getSnapshot,
29016
+ ...deps.getSnapshotState !== void 0 ? { getSnapshotState: deps.getSnapshotState } : {},
29017
+ ...deps.getFrameCapturedAt !== void 0 ? { getFrameCapturedAt: deps.getFrameCapturedAt } : {},
29018
+ isOnline: (id) => this.deviceDirectory.get(id)?.online,
29019
+ logger: this.logger.child("linked-stills"),
29020
+ now: this.now
29021
+ }) : null;
28374
29022
  this.rules = new NcRuleStore({
28375
29023
  store: deps.store,
28376
29024
  logger: this.logger.child("rules"),
@@ -28488,6 +29136,8 @@ var NotificationCenter = class NotificationCenter {
28488
29136
  if (held !== void 0) return held.files;
28489
29137
  const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
28490
29138
  if (shelved !== void 0) return shelved;
29139
+ const linked = isLinkedStillId(ownerId) ? this.linkedStills?.get(ownerId) : void 0;
29140
+ if (linked !== void 0) return linked;
28491
29141
  return deps.dispatcher.getMediaForOwner(ownerKind, ownerId);
28492
29142
  },
28493
29143
  ...deps.now !== void 0 ? { now: deps.now } : {},
@@ -28886,6 +29536,7 @@ var NotificationCenter = class NotificationCenter {
28886
29536
  this.timelapseScheduler?.stop();
28887
29537
  this.summaryProducer?.stop();
28888
29538
  this.stills?.clear();
29539
+ this.linkedStills?.clear();
28889
29540
  this.addonUpdates.dispose();
28890
29541
  this.livenessHoldoff.dispose();
28891
29542
  this.evaluationActive = false;
@@ -29214,6 +29865,110 @@ var NotificationCenter = class NotificationCenter {
29214
29865
  this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
29215
29866
  }
29216
29867
  /**
29868
+ * Start photographing every camera that links `sensorId`, and return the
29869
+ * owners the sensor-level evaluation names — or `undefined` when there is no
29870
+ * set (≤1 linked camera: today's marker is the picture), nothing to show
29871
+ * (every linked camera muted), the event will be dropped as muted (the
29872
+ * SENSOR is muted, or there were row cameras and every one is muted — see
29873
+ * the evaluation's mute gate), or no enabled device-event rule that could
29874
+ * fire on this sensor KIND wants a picture (the gate is the RULE's media
29875
+ * policy, scope and kind conditions, read from the in-memory mirror — D49,
29876
+ * as `audioStillOwner`; an occupancy rule, which rides the same delivery,
29877
+ * never fires on a sensor). Coarse on purpose, in one direction only: a
29878
+ * capture for a rule whose other conditions then fail is one wasted
29879
+ * snapshot, a missing capture is a notification without its picture. The
29880
+ * kind check is what keeps a chatty multi-linked motion sensor from
29881
+ * composing a mosaic on every edge for a doorbell rule.
29882
+ *
29883
+ * A MUTED camera (D62) is never dialled: its tile is left out, said on that
29884
+ * camera (once per window), and the primary is the first linked camera that
29885
+ * is not muted — the same derivation {@link onSensorEvent} makes.
29886
+ *
29887
+ * `cameraIds` is every linking camera, UNFILTERED; `rowCameraIds` the ones
29888
+ * that take a timeline row for the change — with the sensor, the device
29889
+ * scope the evaluation will match on (`matchesDeviceScope`).
29890
+ */
29891
+ beginLinkedStills(request) {
29892
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
29893
+ const shelf = this.linkedStills;
29894
+ if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29895
+ if (this.deviceMutes.isMuted(sensorId)) return void 0;
29896
+ const rowShown = this.unmutedCameras(rowCameraIds);
29897
+ if (rowCameraIds.length > 0 && rowShown.length === 0) return void 0;
29898
+ const shown = this.unmutedCameras(cameraIds);
29899
+ const involved = [sensorId, ...rowShown];
29900
+ if (!this.rules.listEnabled("device-event").some((rule) => {
29901
+ if (rule.media.attach === "none") return false;
29902
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
29903
+ const devices = rule.conditions.devices;
29904
+ return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29905
+ })) return void 0;
29906
+ for (const cameraId of cameraIds) if (!shown.includes(cameraId)) this.reportLinkedCameraMuted(cameraId, sensorId);
29907
+ return shown.length > 0 ? shelf.captureSet(sensorId, shown, atMs) : void 0;
29908
+ }
29909
+ /**
29910
+ * A muted camera left out of a linked set — one INFO line per camera per
29911
+ * window, the suppressed count folded into the next one. A chatty contact
29912
+ * linked to a muted camera must not write a line on every change.
29913
+ */
29914
+ reportLinkedCameraMuted(cameraId, sensorId) {
29915
+ const now = this.now();
29916
+ const last = this.lastLinkedMutedReportAt.get(cameraId);
29917
+ const suppressed = this.linkedMutedSuppressed.get(cameraId) ?? 0;
29918
+ if (last !== void 0 && now - last < NO_MATCH_REPORT_INTERVAL_MS) {
29919
+ this.linkedMutedSuppressed.set(cameraId, suppressed + 1);
29920
+ return;
29921
+ }
29922
+ this.lastLinkedMutedReportAt.set(cameraId, now);
29923
+ this.linkedMutedSuppressed.set(cameraId, 0);
29924
+ this.logger.info("linked camera muted — left out of the sensor notification", {
29925
+ tags: { deviceId: cameraId },
29926
+ meta: {
29927
+ sensorId,
29928
+ reason: "muted",
29929
+ ...suppressed > 0 ? { alsoLeftOutSinceLastReport: suppressed } : {}
29930
+ }
29931
+ });
29932
+ }
29933
+ /**
29934
+ * ONE evaluation for one sensor state change (ADR 0632) — replaces the
29935
+ * per-camera fan-out when the sensor is linked to more than one camera.
29936
+ *
29937
+ * The linked cameras widen the MEDIA only — which cameras' stills ship —
29938
+ * never which rules fire. Matching is on the SENSOR and the cameras that
29939
+ * took a timeline row (`rowCameraIds`, not muted) — D630 §6, whatever the
29940
+ * number of linked cameras. The primary is only the camera the notification
29941
+ * leads with.
29942
+ *
29943
+ * The primary is re-derived here as the first linked camera that is NOT
29944
+ * muted (D62): a camera's mute drops only its own tile, and its marker is
29945
+ * then not the picture. Every linked camera muted ⇒ the sensor is its own
29946
+ * device and the notification is text-only. The MUTE of the event itself is
29947
+ * judged on the sensor ({@link NcRuleSubject.linkedSensorId}). A
29948
+ * `rule-device` cooldown is keyed by the primary. Same lossy boundary as
29949
+ * {@link onSensorEventPersisted}.
29950
+ */
29951
+ onSensorEvent(input) {
29952
+ this.observeOpeningEvent(input.event);
29953
+ const shown = this.unmutedCameras(input.linkedCameraIds);
29954
+ const muted = input.linkedCameraIds.filter((id) => !shown.includes(id));
29955
+ const primary = shown[0] ?? input.event.sourceDeviceId;
29956
+ const marker = primary === input.event.deviceId ? input.markerTrackId : void 0;
29957
+ this.consumeEvent(incomingFromSensorEvent({
29958
+ ...input.event,
29959
+ deviceId: primary
29960
+ }, marker, {
29961
+ linkedCameraIds: shown,
29962
+ rowCameraIds: this.unmutedCameras(input.rowCameraIds),
29963
+ mutedRowCameraIds: input.rowCameraIds.filter((id) => this.deviceMutes.isMuted(id)),
29964
+ ...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
29965
+ }, muted));
29966
+ }
29967
+ /** The linked cameras a sensor notification may show — not muted (D62), in order. */
29968
+ unmutedCameras(cameraIds) {
29969
+ return cameraIds.filter((id) => !this.deviceMutes.isMuted(id));
29970
+ }
29971
+ /**
29217
29972
  * The opening reading a sensor row carries — a contact, a cover or a lock,
29218
29973
  * read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
29219
29974
  * panel. A row that is not an opening, or whose word is unknown, hands over
@@ -29499,6 +30254,10 @@ var NotificationCenter = class NotificationCenter {
29499
30254
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
29500
30255
  createRule: async ({ rule, caller }) => {
29501
30256
  assertRuleHasAddressee(rule.targets, rule.targetUsers);
30257
+ guardRuleFamily(rule, {
30258
+ logger: this.logger,
30259
+ by: caller.userId
30260
+ });
29502
30261
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
29503
30262
  const created = await this.rules.create(rule, caller.userId);
29504
30263
  this.logger.info("notification rule created", { meta: {
@@ -29510,7 +30269,13 @@ var NotificationCenter = class NotificationCenter {
29510
30269
  },
29511
30270
  updateRule: async ({ ruleId, patch, clear, caller }) => {
29512
30271
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
29513
- assertPatchKeepsAddressee(this.rules.get(ruleId), patch, clear ?? []);
30272
+ const existing = this.rules.get(ruleId);
30273
+ assertPatchKeepsAddressee(existing, patch, clear ?? []);
30274
+ guardPatchFamily(existing, patch, {
30275
+ logger: this.logger,
30276
+ by: caller.userId,
30277
+ ruleId
30278
+ });
29514
30279
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
29515
30280
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
29516
30281
  this.logger.info("notification rule updated", { meta: {
@@ -30340,8 +31105,9 @@ var NotificationCenter = class NotificationCenter {
30340
31105
  outcomes,
30341
31106
  ...extra
30342
31107
  });
30343
- if (this.deviceMutes.isMuted(subject.deviceId) && controls.bypassMute !== true) {
30344
- this.reportMutedDrop(subject, kind, this.now());
31108
+ const muteDeviceId = this.mutedAuthority(subject);
31109
+ if (muteDeviceId !== void 0 && controls.bypassMute !== true) {
31110
+ this.reportMutedDrop(subject, muteDeviceId, kind, this.now());
30345
31111
  return report({ droppedBy: "muted" });
30346
31112
  }
30347
31113
  const delivery = deliveryForKind(kind);
@@ -30512,7 +31278,7 @@ var NotificationCenter = class NotificationCenter {
30512
31278
  buttons: unresolvable.join(", ")
30513
31279
  }
30514
31280
  });
30515
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
31281
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
30516
31282
  noOwnerReported = true;
30517
31283
  const linked = subject.linkedCameraIds;
30518
31284
  const meta = {
@@ -30521,7 +31287,19 @@ var NotificationCenter = class NotificationCenter {
30521
31287
  ruleId: rule.id,
30522
31288
  rule: rule.name
30523
31289
  };
30524
- if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
31290
+ if (linked !== void 0 && linked.length === 0 && isLinkedSetSubject(subject)) this.logger.info("every linked camera is muted — this sensor notification ships text-only", {
31291
+ tags: { deviceId: subject.linkedSensorId ?? subject.deviceId },
31292
+ meta
31293
+ });
31294
+ else if (linked !== void 0 && isLinkedSetSubject(subject)) this.logger.warn("sensor event has no linked-camera pictures and no marker — this notification ships text-only", {
31295
+ tags: { deviceId: subject.sourceDeviceId ?? subject.deviceId },
31296
+ meta: {
31297
+ ...meta,
31298
+ linkedCameraIds: [...linked],
31299
+ snapshotWired: this.linkedStills !== null
31300
+ }
31301
+ });
31302
+ else if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
30525
31303
  tags: { deviceId: subject.deviceId },
30526
31304
  meta: {
30527
31305
  ...meta,
@@ -30778,14 +31556,26 @@ var NotificationCenter = class NotificationCenter {
30778
31556
  });
30779
31557
  }
30780
31558
  /**
31559
+ * The muted device that drops this subject, or `undefined` when none does.
31560
+ * A per-camera subject: its camera. A linked set: the sensor; else, when
31561
+ * the change took rows and every row camera is muted, the sensor too — the
31562
+ * line names the muted row cameras.
31563
+ */
31564
+ mutedAuthority(subject) {
31565
+ const sensorId = subject.linkedSensorId;
31566
+ if (sensorId === void 0) return this.deviceMutes.isMuted(subject.deviceId) ? subject.deviceId : void 0;
31567
+ if (this.deviceMutes.isMuted(sensorId)) return sensorId;
31568
+ return (subject.rowCameraIds ?? []).length === 0 && (subject.mutedRowCameraIds ?? []).length > 0 ? sensorId : void 0;
31569
+ }
31570
+ /**
30781
31571
  * One INFO line when a MUTED camera's event is dropped, bounded to one per
30782
31572
  * camera per window with the suppressed count folded into the next report —
30783
31573
  * identical shape to `reportNoMatch`, because the question an operator asks
30784
31574
  * is identical: "why did this camera not notify me?".
30785
31575
  */
30786
- reportMutedDrop(subject, kind, now) {
31576
+ reportMutedDrop(subject, mutedDeviceId, kind, now) {
30787
31577
  const bucket = noMatchReportKey({
30788
- deviceId: subject.deviceId,
31578
+ deviceId: mutedDeviceId,
30789
31579
  kind,
30790
31580
  classNames: subject.classNames
30791
31581
  });
@@ -30797,10 +31587,12 @@ var NotificationCenter = class NotificationCenter {
30797
31587
  }
30798
31588
  this.lastMutedReportAt.set(bucket, now);
30799
31589
  this.mutedSuppressed.set(bucket, 0);
30800
- this.logger.info("notifications muted for this camera — event dropped before rule evaluation", {
30801
- tags: { deviceId: subject.deviceId },
31590
+ const rowsMuted = subject.linkedSensorId !== void 0 && !this.deviceMutes.isMuted(subject.linkedSensorId);
31591
+ this.logger.info(subject.linkedSensorId === void 0 ? "notifications muted for this camera — event dropped before rule evaluation" : rowsMuted ? "every camera that took its row is muted — sensor event dropped before rule evaluation" : "notifications muted for this sensor — event dropped before rule evaluation", {
31592
+ tags: { deviceId: mutedDeviceId },
30802
31593
  meta: {
30803
31594
  kind,
31595
+ ...rowsMuted ? { mutedRowCameraIds: [...subject.mutedRowCameraIds ?? []] } : {},
30804
31596
  classes: subject.classNames,
30805
31597
  eventId: subject.recordId,
30806
31598
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
@@ -30851,6 +31643,7 @@ var NotificationCenter = class NotificationCenter {
30851
31643
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
30852
31644
  const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
30853
31645
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
31646
+ const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
30854
31647
  return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
30855
31648
  const base = {
30856
31649
  ruleName: rule.name,
@@ -30881,7 +31674,12 @@ var NotificationCenter = class NotificationCenter {
30881
31674
  ...target.params !== void 0 ? { params: target.params } : {},
30882
31675
  subject: {
30883
31676
  deviceId: subject.deviceId,
30884
- ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31677
+ ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : linkedOwners !== void 0 ? {
31678
+ trackId: linkedOwners.trackId,
31679
+ ...linkedOwners.extraStills.length > 0 ? { extraStills: linkedOwners.extraStills.map((x) => ({ ...x })) } : {},
31680
+ analysisTiles: linkedOwners.analysisTiles.map((x) => ({ ...x }))
31681
+ } : subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31682
+ ...subject.linkedCameraIds !== void 0 && subject.linkedCameraIds.length > 1 && isLinkedSetSubject(subject) ? { linkedCameraIds: [...subject.linkedCameraIds] } : {},
30885
31683
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
30886
31684
  className: subject.classNames[0] ?? subject.sensorKind ?? "event",
30887
31685
  ...subject.label !== void 0 ? { label: subject.label } : {},
@@ -72260,6 +73058,20 @@ function hasWrappedDetection(entries) {
72260
73058
  return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
72261
73059
  }
72262
73060
  //#endregion
73061
+ //#region src/pipeline-analytics/runtime/linked-snapshot-reads.ts
73062
+ function buildLinkedSnapshotReads(snapshot) {
73063
+ return {
73064
+ getSnapshotState: async (deviceId) => {
73065
+ return { reason: (await snapshot.getDebugState.query({ deviceId })).state.reason };
73066
+ },
73067
+ getFrameCapturedAt: async (deviceIds) => {
73068
+ if (deviceIds.length === 0) return /* @__PURE__ */ new Map();
73069
+ const rows = await snapshot.getSnapshotOverview.query({ deviceIds: [...deviceIds] });
73070
+ return new Map(rows.map((r) => [r.deviceId, r.lastCapturedAt]));
73071
+ }
73072
+ };
73073
+ }
73074
+ //#endregion
72263
73075
  //#region src/pipeline-analytics/runtime/native-crop-dedupe.ts
72264
73076
  var NativeCropDedupe = class {
72265
73077
  config;
@@ -73868,6 +74680,59 @@ async function resolveSensorEventCameras(cache, data) {
73868
74680
  };
73869
74681
  }
73870
74682
  /**
74683
+ * The cameras a NOTIFICATION about this sensor shows — the emitting device
74684
+ * itself when it is a camera that may self-emit this cap, then every camera
74685
+ * that links it, opt-in or not (ADR 0632). The order is the mosaic's tile order
74686
+ * and names the PRIMARY camera (the first). Deliberately a sibling of
74687
+ * resolveSensorEventCameras rather than a flag on it: that one decides which
74688
+ * timeline rows exist and must keep honouring the opt-in.
74689
+ *
74690
+ * `[]` is NOT knowledge. It answers both "no camera links this sensor" and
74691
+ * "the camera index has not answered yet" (cold start, no camera list read —
74692
+ * this function does not consult `answered()`). A caller must read `[]` (and a
74693
+ * single camera) as "no mosaic this time, fall back to the plain path", never
74694
+ * as a durable "linked to nothing" that drops or deletes work (D49).
74695
+ */
74696
+ async function resolveLinkedMediaCameras(cache, data) {
74697
+ const linked = await cache.allCamerasFor(data.deviceId);
74698
+ return [...await cache.isCamera(data.deviceId) && selfCameraMayEmit(data.capName) ? [data.deviceId] : [], ...linked.filter((id) => id !== data.deviceId)];
74699
+ }
74700
+ var NO_SENSOR_NOTIFICATION_CAMERAS = {
74701
+ mediaCameraIds: [],
74702
+ rowCameraIds: []
74703
+ };
74704
+ /**
74705
+ * Which cameras a notification about this change shows, known BEFORE the
74706
+ * ingest runs, so the capture can start at the trigger.
74707
+ *
74708
+ * Only a change the ingest EVALUATES has any: a mapped cap that takes rows
74709
+ * (`dropReason` null) or an unowned sensor (`no-camera`, D630 §6). A camera's
74710
+ * own `motion` no camera opted in (`camera-native-cap`) and a cold index
74711
+ * (`camera-index-unknown`) are drops today, and a camera merely LINKING the
74712
+ * device must not turn a drop into a notification. Asks the SAME resolution
74713
+ * the ingest asks (`resolveSensorEventCameras`), off the same cache.
74714
+ */
74715
+ async function resolveSensorNotificationCameras(cache, data) {
74716
+ if (require_dist.EVENT_KIND_BY_CAP[data.capName] === void 0) return NO_SENSOR_NOTIFICATION_CAMERAS;
74717
+ const rows = await resolveSensorEventCameras(cache, data);
74718
+ if (rows.dropReason !== null && rows.dropReason !== "no-camera") return NO_SENSOR_NOTIFICATION_CAMERAS;
74719
+ return {
74720
+ mediaCameraIds: await resolveLinkedMediaCameras(cache, data),
74721
+ rowCameraIds: rows.cameraIds
74722
+ };
74723
+ }
74724
+ /**
74725
+ * A sensor slice as a row's `value`: the object it is, or `null` for anything
74726
+ * else (absent, a primitive, an array) — one narrowing for every sensor event
74727
+ * built from a `DeviceStateChanged`.
74728
+ */
74729
+ function sensorSliceRecord(slice) {
74730
+ return isSliceRecord(slice) ? slice : null;
74731
+ }
74732
+ function isSliceRecord(slice) {
74733
+ return typeof slice === "object" && slice !== null && !Array.isArray(slice);
74734
+ }
74735
+ /**
73871
74736
  * One `DeviceStateChanged` → N history rows, one per camera the event belongs
73872
74737
  * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
73873
74738
  * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
@@ -73883,8 +74748,7 @@ async function ingestSensorStateChange(deps, data, timestamp) {
73883
74748
  const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
73884
74749
  if (descriptor === void 0) return 0;
73885
74750
  const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
73886
- const slice = data.slice;
73887
- const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
74751
+ const value = sensorSliceRecord(data.slice);
73888
74752
  const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
73889
74753
  if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
73890
74754
  const ev = {
@@ -74863,8 +75727,14 @@ var MotionEventSnapshotCapturer = class {
74863
75727
  /**
74864
75728
  * Project one `DeviceStateChanged` of a mapped sensor/control cap into a
74865
75729
  * synthetic track per camera the event belongs to. Returns the number of
74866
- * markers that landed. Best-effort (telemetry, D8) — a materializer that
74867
- * throws propagates to the caller, which logs it.
75730
+ * markers that landed. Best-effort (telemetry, D8).
75731
+ *
75732
+ * Every camera is materialized CONCURRENTLY — the notification about this
75733
+ * event waits for the markers, so N cameras cost one snapshot bound, not N —
75734
+ * and reported afterwards in camera order. A materializer that throws for one
75735
+ * camera no longer propagates to the caller (where it was logged against the
75736
+ * SENSOR): it is reported as `not-materialized` on ITS camera, carrying the
75737
+ * rejection's message in `error`, and the other cameras still land.
74868
75738
  */
74869
75739
  async function projectSensorMarkers(deps, data, timestamp) {
74870
75740
  const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
@@ -74890,26 +75760,30 @@ async function projectSensorMarkers(deps, data, timestamp) {
74890
75760
  return 0;
74891
75761
  }
74892
75762
  const producingDeviceName = await deps.producingDeviceName(data.deviceId);
75763
+ const settled = await Promise.allSettled(cameraIds.map((cameraId) => deps.markers.materialize({
75764
+ cameraId,
75765
+ sourceDeviceId: data.deviceId,
75766
+ source: "sensor",
75767
+ kind: descriptor.kind,
75768
+ timestamp,
75769
+ snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
75770
+ ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
75771
+ })));
74893
75772
  let landed = 0;
74894
- for (const cameraId of cameraIds) {
74895
- const track = await deps.markers.materialize({
74896
- cameraId,
74897
- sourceDeviceId: data.deviceId,
74898
- source: "sensor",
74899
- kind: descriptor.kind,
74900
- timestamp,
74901
- snapshotTimeoutMs: deps.snapshotTimeoutMs ?? 2500,
74902
- ...producingDeviceName !== void 0 ? { producingDeviceName } : {}
74903
- });
75773
+ settled.forEach((outcome, i) => {
75774
+ const cameraId = cameraIds[i];
75775
+ if (cameraId === void 0) return;
75776
+ const track = outcome.status === "fulfilled" ? outcome.value : null;
74904
75777
  if (track === null) {
74905
75778
  deps.onSkipped({
74906
75779
  cameraId,
74907
75780
  sourceDeviceId: data.deviceId,
74908
75781
  capName: data.capName,
74909
75782
  kind: descriptor.kind,
74910
- reason: "not-materialized"
75783
+ reason: "not-materialized",
75784
+ ...outcome.status === "rejected" ? { error: require_dist.errMsg(outcome.reason) } : {}
74911
75785
  });
74912
- continue;
75786
+ return;
74913
75787
  }
74914
75788
  landed++;
74915
75789
  deps.onProjected({
@@ -74921,7 +75795,7 @@ async function projectSensorMarkers(deps, data, timestamp) {
74921
75795
  selfCamera: cameraId === data.deviceId,
74922
75796
  hasMedia: track.snapshots.length > 0
74923
75797
  });
74924
- }
75798
+ });
74925
75799
  return landed;
74926
75800
  }
74927
75801
  //#endregion
@@ -80070,6 +80944,14 @@ var RETRAIN_EXPORT_MAX = 2e4;
80070
80944
  * surface to turn the refinement pipeline on/off for a camera.
80071
80945
  */
80072
80946
  var TTL_SWEEP_INTERVAL_MS = 5e3;
80947
+ /**
80948
+ * How long shutdown waits for the sensor handlers already running. Just above
80949
+ * the marker's bounded snapshot fetch (`SENSOR_MARKER_SNAPSHOT_TIMEOUT_MS`,
80950
+ * 2.5 s), the one long await a handler makes, and BELOW the kernel's SIGKILL
80951
+ * fallback (process-service.ts: 3 s after SIGTERM on the stop-all path, 5 s on
80952
+ * the others) — a longer drain would be killed mid-way and log nothing.
80953
+ */
80954
+ var SENSOR_HANDLER_DRAIN_MS = 2700;
80073
80955
  /** Throttle for the group-shot gate's window line (D470). */
80074
80956
  var GROUP_SHOT_METRIC_INTERVAL_MS = 3e4;
80075
80957
  /**
@@ -81140,6 +82022,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81140
82022
  getNativeKeyFrameRgb = null;
81141
82023
  shuttingDown = false;
81142
82024
  /**
82025
+ * Sensor handlers the bus dispatched under `void` that have not finished.
82026
+ * `onShutdown` DRAINS them before it tears anything down: a handler awaits a
82027
+ * snapshot for its marker before its last context read, and one left running
82028
+ * across the teardown threw "ctx accessed before initialize()" as an
82029
+ * unhandled rejection with no device on it.
82030
+ */
82031
+ sensorHandlersInFlight = /* @__PURE__ */ new Set();
82032
+ /**
81143
82033
  * Per-device delivery-lag windows, folded at `handleInferenceResult` entry.
81144
82034
  * The instrument that tells a late DELIVERY (starved) apart from a slow
81145
82035
  * `processFrame` (waiting) — see `frame-lag-observer.ts`. Costs one map read
@@ -81686,7 +82576,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
81686
82576
  onMotionAnalysis: (deviceId, data, timestampMs) => this.handleMotionAnalysis(deviceId, data, timestampMs),
81687
82577
  onOnboardMotion: (deviceId, data, timestampMs) => this.handleOnboardMotion(deviceId, data, timestampMs),
81688
82578
  onBatterySample: (deviceId, percentage, at) => this.lts?.note("battery", deviceId, percentage, at),
81689
- onSensorStateChanged: (data, timestampMs) => this.handleSensorStateChanged(data, timestampMs),
82579
+ onSensorStateChanged: (data, timestampMs) => this.dispatchSensorStateChanged(data, timestampMs),
81690
82580
  onBindingsChanged: (data) => this.handleBindingsChangedEvent(data),
81691
82581
  onDeviceUnregistered: (deviceId) => this.handleDeviceUnregisteredEvent(deviceId),
81692
82582
  onClusterTopology: (nodes) => this.nodeLiveness.applySnapshot(nodes, Date.now()),
@@ -82376,10 +83266,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
82376
83266
  snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
82377
83267
  media: mediaStore,
82378
83268
  tracks: trackStore,
82379
- onError: (scope, err) => logger.warn("synthetic track error", { meta: {
82380
- scope,
82381
- error: require_dist.errMsg(err)
82382
- } })
83269
+ onError: (scope, err, deviceId) => logger.warn("synthetic track error", {
83270
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
83271
+ meta: {
83272
+ scope,
83273
+ error: require_dist.errMsg(err)
83274
+ }
83275
+ })
82383
83276
  });
82384
83277
  this.syntheticTracks = syntheticTracks;
82385
83278
  this.motionEventSnapshots = new MotionEventSnapshotCapturer({
@@ -82686,6 +83579,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
82686
83579
  },
82687
83580
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
82688
83581
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
83582
+ ...buildLinkedSnapshotReads(api.snapshot),
82689
83583
  identityIdsByName: async () => {
82690
83584
  const byName = /* @__PURE__ */ new Map();
82691
83585
  const identities = await stores.identityStore.listIdentities();
@@ -84221,7 +85115,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
84221
85115
  this.alarmPanelConvergence = null;
84222
85116
  for (const unsub of this.subscriptionUnsubs) unsub();
84223
85117
  this.subscriptionUnsubs = [];
84224
- await this.motionEpisodes?.closeAll("addon-shutdown");
85118
+ await Promise.all([this.drainSensorHandlers(), this.motionEpisodes?.closeAll("addon-shutdown")]);
84225
85119
  await this.embeddingDispatcher?.stop();
84226
85120
  this.embeddingDispatcher = null;
84227
85121
  await this.notificationCenter?.stop();
@@ -88531,11 +89425,118 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
88531
89425
  return this.queryFacade.getSensorEvents(input);
88532
89426
  }
88533
89427
  /**
89428
+ * Start the linked set's capture (ADR 0632) with the UNFILTERED camera list:
89429
+ * the centre applies the mute itself and says so per camera.
89430
+ */
89431
+ startLinkedStills(data, timestamp, cameras) {
89432
+ const sensorKind = require_dist.EVENT_KIND_BY_CAP[data.capName]?.kind;
89433
+ if (sensorKind === void 0) return void 0;
89434
+ try {
89435
+ return this.notificationCenter?.beginLinkedStills({
89436
+ sensorId: data.deviceId,
89437
+ sensorKind,
89438
+ cameraIds: cameras.mediaCameraIds,
89439
+ rowCameraIds: cameras.rowCameraIds,
89440
+ atMs: timestamp
89441
+ });
89442
+ } catch (err) {
89443
+ this.ctx.logger.warn("linked-camera capture could not start — the notification ships without it", {
89444
+ tags: { deviceId: data.deviceId },
89445
+ meta: {
89446
+ capName: data.capName,
89447
+ cameraIds: [...cameras.mediaCameraIds],
89448
+ error: require_dist.errMsg(err)
89449
+ }
89450
+ });
89451
+ return;
89452
+ }
89453
+ }
89454
+ /**
89455
+ * The ONE sensor-level evaluation of a linked set (ADR 0632). Its record is
89456
+ * the primary camera's row when that camera has one, else another camera's
89457
+ * row, else a camera-attributed event that is NOT a timeline row (no linking
89458
+ * camera opted the sensor in). The cameras that took a row travel as
89459
+ * `rowCameraIds`: with the sensor, what a camera-scoped rule matches on
89460
+ * (D630 §6).
89461
+ */
89462
+ handOffLinkedSensorEvent(input) {
89463
+ const { data, timestamp, persisted } = input;
89464
+ const primary = input.mediaCameraIds[0];
89465
+ if (primary === void 0) return;
89466
+ const event = persisted.find((ev) => ev.deviceId === primary) ?? persisted[0] ?? {
89467
+ id: `pa-sensor-${(0, node_crypto.randomUUID)()}`,
89468
+ deviceId: primary,
89469
+ sourceDeviceId: data.deviceId,
89470
+ kind: require_dist.EVENT_KIND_BY_CAP[data.capName]?.kind ?? data.capName,
89471
+ value: sensorSliceRecord(data.slice),
89472
+ timestamp
89473
+ };
89474
+ const markerTrackId = input.markerByCamera.get(primary)?.trackId;
89475
+ try {
89476
+ this.notificationCenter?.onSensorEvent({
89477
+ event: {
89478
+ ...event,
89479
+ deviceId: primary
89480
+ },
89481
+ linkedCameraIds: input.mediaCameraIds,
89482
+ rowCameraIds: persisted.map((ev) => ev.deviceId),
89483
+ ...markerTrackId !== void 0 ? { markerTrackId } : {},
89484
+ ...input.linkedStills !== void 0 ? { linkedStills: input.linkedStills } : {}
89485
+ });
89486
+ } catch (err) {
89487
+ this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
89488
+ tags: { deviceId: data.deviceId },
89489
+ meta: {
89490
+ kind: event.kind,
89491
+ linkedCameraIds: [...input.mediaCameraIds],
89492
+ error: require_dist.errMsg(err)
89493
+ }
89494
+ });
89495
+ }
89496
+ }
89497
+ /**
89498
+ * The bus's door into {@link handleSensorStateChanged}: tracked, so shutdown
89499
+ * can drain it, and caught, so a throw is a tagged line instead of an
89500
+ * unhandled rejection. Refused once shutdown began.
89501
+ */
89502
+ dispatchSensorStateChanged(data, timestampMs) {
89503
+ if (this.shuttingDown) return Promise.resolve();
89504
+ const run = this.handleSensorStateChanged(data, timestampMs).catch((err) => {
89505
+ this.ctxIfReady?.logger.warn("sensor state change handler failed", {
89506
+ tags: { deviceId: data.deviceId },
89507
+ meta: {
89508
+ capName: data.capName,
89509
+ error: require_dist.errMsg(err)
89510
+ }
89511
+ });
89512
+ });
89513
+ this.sensorHandlersInFlight.add(run);
89514
+ run.finally(() => this.sensorHandlersInFlight.delete(run));
89515
+ return run;
89516
+ }
89517
+ /** Wait for the in-flight sensor handlers, bounded — see {@link sensorHandlersInFlight}. */
89518
+ async drainSensorHandlers() {
89519
+ const inFlight = [...this.sensorHandlersInFlight];
89520
+ if (inFlight.length === 0) return;
89521
+ let timer;
89522
+ const bound = new Promise((resolve) => {
89523
+ timer = setTimeout(() => resolve("bound"), SENSOR_HANDLER_DRAIN_MS);
89524
+ });
89525
+ const outcome = await Promise.race([Promise.all(inFlight).then(() => "drained"), bound]);
89526
+ clearTimeout(timer);
89527
+ if (outcome === "bound") this.ctx.logger.warn("shutdown: sensor handlers still running past the drain bound", { meta: {
89528
+ inFlight: this.sensorHandlersInFlight.size,
89529
+ boundMs: SENSOR_HANDLER_DRAIN_MS
89530
+ } });
89531
+ }
89532
+ /**
88534
89533
  * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
88535
89534
  * mapped sensor cap. Resolves the camera set through the TTL cache (the
88536
89535
  * device's LINKED cameras, plus the device itself when it is a camera
88537
89536
  * carrying the cap) and inserts ONE row per camera. Best-effort
88538
- * (telemetry-lossy).
89537
+ * (telemetry-lossy). The NOTIFICATION is not per row when more than one
89538
+ * camera links the sensor (ADR 0632): the linked cameras are photographed at
89539
+ * the trigger and the change is evaluated ONCE, at the sensor level.
88539
89540
  *
88540
89541
  * D8/D11 boundary, stated plainly: the ride here is the LOSSY
88541
89542
  * `DeviceStateChanged` bus, and there is no reconcile that replays a missed
@@ -88556,6 +89557,18 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
88556
89557
  const cache = this.linkedCamerasCache;
88557
89558
  if (store === null || cache === null) return;
88558
89559
  if (!this.sensorSliceCarriedATransition(data)) return;
89560
+ const cameras = await resolveSensorNotificationCameras(cache, data).catch((err) => {
89561
+ this.ctx.logger.debug("linked-camera resolution failed — evaluated per row", {
89562
+ tags: { deviceId: data.deviceId },
89563
+ meta: {
89564
+ capName: data.capName,
89565
+ error: require_dist.errMsg(err)
89566
+ }
89567
+ });
89568
+ return NO_SENSOR_NOTIFICATION_CAMERAS;
89569
+ });
89570
+ const linkedSet = cameras.mediaCameraIds.length > 1;
89571
+ const linkedStills = linkedSet ? this.startLinkedStills(data, timestamp, cameras) : void 0;
88559
89572
  const persisted = [];
88560
89573
  const unowned = [];
88561
89574
  try {
@@ -88600,18 +89613,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
88600
89613
  }
88601
89614
  });
88602
89615
  },
88603
- onSkipped: (r) => this.ctx.logger.debug("synthetic sensor-track NOT projected", {
88604
- tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
88605
- meta: {
88606
- capName: r.capName,
88607
- kind: r.kind,
88608
- reason: r.reason,
88609
- sourceDeviceId: r.sourceDeviceId
89616
+ onSkipped: (r) => {
89617
+ if (r.error !== void 0) {
89618
+ this.ctx.logger.warn("synthetic sensor-track materialize rejected", {
89619
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
89620
+ meta: {
89621
+ capName: r.capName,
89622
+ kind: r.kind,
89623
+ reason: r.reason,
89624
+ sourceDeviceId: r.sourceDeviceId,
89625
+ error: r.error
89626
+ }
89627
+ });
89628
+ return;
88610
89629
  }
88611
- })
89630
+ this.ctx.logger.debug("synthetic sensor-track NOT projected", {
89631
+ tags: { deviceId: r.cameraId ?? r.sourceDeviceId },
89632
+ meta: {
89633
+ capName: r.capName,
89634
+ kind: r.kind,
89635
+ reason: r.reason,
89636
+ sourceDeviceId: r.sourceDeviceId
89637
+ }
89638
+ });
89639
+ }
88612
89640
  }, data, timestamp);
88613
89641
  } catch (err) {
88614
- this.ctx.logger.warn("synthetic sensor-track materialize failed", {
89642
+ this.ctx.logger.warn("synthetic sensor-track camera resolution / producing-device name read failed", {
88615
89643
  tags: { deviceId: data.deviceId },
88616
89644
  meta: {
88617
89645
  capName: data.capName,
@@ -88619,6 +89647,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
88619
89647
  }
88620
89648
  });
88621
89649
  }
89650
+ if (linkedSet) {
89651
+ this.handOffLinkedSensorEvent({
89652
+ data,
89653
+ timestamp,
89654
+ mediaCameraIds: cameras.mediaCameraIds,
89655
+ persisted,
89656
+ markerByCamera,
89657
+ ...linkedStills !== void 0 ? { linkedStills } : {}
89658
+ });
89659
+ return;
89660
+ }
88622
89661
  for (const ev of persisted) try {
88623
89662
  this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
88624
89663
  } catch (err) {