@camstack/system 1.2.266 → 1.2.268

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.
Files changed (63) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/camera-grid/addon.d.ts +13 -2
  12. package/dist/builtins/camera-grid/grid-canvas-derivation.d.ts +19 -0
  13. package/dist/builtins/camera-grid/grid-instances.d.ts +0 -2
  14. package/dist/builtins/camera-grid/grid-plan.d.ts +8 -6
  15. package/dist/builtins/camera-grid/grid-profiles.d.ts +46 -33
  16. package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +7 -4
  17. package/dist/builtins/camera-grid/grid-stream-relay.d.ts +10 -7
  18. package/dist/builtins/camera-grid/index.d.ts +3 -2
  19. package/dist/builtins/camera-grid/index.js +378 -218
  20. package/dist/builtins/camera-grid/index.mjs +375 -216
  21. package/dist/builtins/console-logging/index.js +1 -1
  22. package/dist/builtins/console-logging/index.mjs +1 -1
  23. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  24. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  25. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  26. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  27. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  28. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  29. package/dist/builtins/hub-forwarder/index.js +1 -1
  30. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  31. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  32. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  33. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  34. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  35. package/dist/builtins/local-network/local-network.addon.js +1 -1
  36. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  37. package/dist/builtins/loki-logging/index.js +1 -1
  38. package/dist/builtins/loki-logging/index.mjs +1 -1
  39. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  40. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  41. package/dist/builtins/platform-probe/index.js +1 -1
  42. package/dist/builtins/platform-probe/index.mjs +1 -1
  43. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  44. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/snapshot/index.js +1 -1
  46. package/dist/builtins/snapshot/index.mjs +1 -1
  47. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  48. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  51. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  52. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  53. package/dist/builtins/system-config/system-config.addon.js +1 -1
  54. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  55. package/dist/builtins/winston-logging/index.js +1 -1
  56. package/dist/builtins/winston-logging/index.mjs +1 -1
  57. package/dist/{dist-Ck0rjYOQ.mjs → dist-BYnnRAYv.mjs} +16 -6
  58. package/dist/{dist-BbPssgI6.js → dist-DWNlc-PG.js} +16 -6
  59. package/dist/index.js +1 -1
  60. package/dist/index.mjs +1 -1
  61. package/dist/{retired-settings-keys-Dg_69cOY.js → retired-settings-keys-BjSmLqyQ.js} +1 -1
  62. package/dist/{retired-settings-keys-BZpDuJVh.mjs → retired-settings-keys-C_MtEB25.mjs} +1 -1
  63. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-Ck0rjYOQ.mjs";
1
+ import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-BYnnRAYv.mjs";
2
2
  import { z } from "zod";
3
3
  import { FfmpegProcess } from "@camstack/types/node";
4
4
  import { join } from "node:path";
@@ -255,9 +255,17 @@ var GridInstanceSchema = z.object({
255
255
  cameraStableId: z.string().min(1).max(256),
256
256
  name: z.string().min(1).max(160),
257
257
  enabled: z.boolean(),
258
- /** Canvas size — the composite camera's own resolution. */
259
- width: z.number().int().min(160).max(7680),
260
- height: z.number().int().min(120).max(4320),
258
+ /**
259
+ * NO canvas size. It is DERIVED, per profile, from the cells and the sources'
260
+ * own dimensions (`grid-canvas-derivation.ts`) — the two numbers that used to
261
+ * live here were typed into the editor and silently decided both how much the
262
+ * composite cost and how sharp it was.
263
+ *
264
+ * Rows written before that change still carry `width`/`height`; zod drops
265
+ * unknown keys, so they are ignored rather than migrated. Nothing reads them,
266
+ * and a migration that rewrote every row to delete two fields would be a
267
+ * write with no reader.
268
+ */
261
269
  fps: z.number().int().min(1).max(60),
262
270
  /**
263
271
  * Empty is the NORMAL freshly-created state: a grid is created from the
@@ -320,10 +328,7 @@ function gridPlanFor(instance, encode) {
320
328
  cell: cell.cell
321
329
  };
322
330
  });
323
- const canvas = encode.canvas ?? {
324
- width: instance.width,
325
- height: instance.height
326
- };
331
+ const canvas = encode.canvas;
327
332
  return {
328
333
  layout: {
329
334
  width: canvas.width,
@@ -921,51 +926,239 @@ var GridLastFrameFileStore = class {
921
926
  }
922
927
  };
923
928
  //#endregion
924
- //#region src/builtins/camera-grid/grid-profiles.ts
929
+ //#region src/builtins/camera-grid/grid-canvas.ts
930
+ /** Bytes one yuv420p frame of this size occupies. */
931
+ function yuv420pSize(width, height) {
932
+ return width * height + 2 * (width / 2) * (height / 2);
933
+ }
925
934
  /**
926
- * One id, for every grid, forever.
935
+ * Round DOWN to even.
927
936
  *
928
- * D519's reason is what keeps this id bare: a per-instance or newly-spelled id
929
- * mints a second cam-stream and orphans whatever profile assignment the
930
- * operator had made. It was `high` on the live hub before the per-profile
931
- * detour and it is `high` again, so nothing an operator assigned is disturbed.
937
+ * Down and not nearest: a tile that rounded UP would claim a row of samples the
938
+ * decoder was never asked to produce, and the copy would either overrun or be
939
+ * refused at the moment the picture is wanted.
932
940
  */
933
- var GRID_CAM_STREAM_ID = "grid";
941
+ function evenDown(value) {
942
+ return Math.max(0, Math.floor(value / 2) * 2);
943
+ }
944
+ var GridCanvas = class {
945
+ width;
946
+ height;
947
+ buffer;
948
+ lumaBytes;
949
+ chromaBytes;
950
+ constructor(width, height) {
951
+ this.width = width;
952
+ this.height = height;
953
+ this.lumaBytes = width * height;
954
+ this.chromaBytes = width / 2 * (height / 2);
955
+ this.buffer = Buffer.alloc(yuv420pSize(width, height));
956
+ this.clear();
957
+ }
958
+ /**
959
+ * Legible black — Y 0, chroma 128 (neutral).
960
+ *
961
+ * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
962
+ * strong green. A cell nobody has filled yet has to look like an empty cell,
963
+ * not like a fault.
964
+ */
965
+ clear() {
966
+ this.buffer.fill(0, 0, this.lumaBytes);
967
+ this.buffer.fill(128, this.lumaBytes);
968
+ }
969
+ /**
970
+ * Copy one decoded tile in at `origin`.
971
+ *
972
+ * Returns false — never throws, never writes a partial tile — when the tile
973
+ * does not fit, is the wrong size for the dimensions it claims, or sits on an
974
+ * odd boundary. This runs on the output tick, so a bad frame from one source
975
+ * must cost that source's rectangle and nothing else.
976
+ */
977
+ blit(tile, size, origin) {
978
+ if (!isEven(size.width) || !isEven(size.height)) return false;
979
+ if (!isEven(origin.x) || !isEven(origin.y)) return false;
980
+ if (size.width <= 0 || size.height <= 0) return false;
981
+ if (origin.x < 0 || origin.y < 0) return false;
982
+ if (origin.x + size.width > this.width) return false;
983
+ if (origin.y + size.height > this.height) return false;
984
+ if (tile.length !== yuv420pSize(size.width, size.height)) return false;
985
+ for (let row = 0; row < size.height; row += 1) {
986
+ const from = row * size.width;
987
+ const to = (origin.y + row) * this.width + origin.x;
988
+ tile.copy(this.buffer, to, from, from + size.width);
989
+ }
990
+ const tileLuma = size.width * size.height;
991
+ const tileChroma = size.width / 2 * (size.height / 2);
992
+ const halfW = size.width / 2;
993
+ const halfH = size.height / 2;
994
+ const canvasHalfW = this.width / 2;
995
+ for (let plane = 0; plane < 2; plane += 1) {
996
+ const tileBase = tileLuma + plane * tileChroma;
997
+ const canvasBase = this.lumaBytes + plane * this.chromaBytes;
998
+ for (let row = 0; row < halfH; row += 1) {
999
+ const from = tileBase + row * halfW;
1000
+ const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1001
+ tile.copy(this.buffer, to, from, from + halfW);
1002
+ }
1003
+ }
1004
+ return true;
1005
+ }
1006
+ };
1007
+ function isEven(value) {
1008
+ return Number.isInteger(value) && value % 2 === 0;
1009
+ }
1010
+ //#endregion
1011
+ //#region src/builtins/camera-grid/grid-canvas-derivation.ts
934
1012
  /**
935
- * The slot the single composition claims.
1013
+ * The canvas is a RESULT, not a field.
1014
+ *
1015
+ * ## Why the operator stopped typing it
1016
+ *
1017
+ * A grid's resolution used to be two numbers in the editor, beside FPS. They
1018
+ * looked harmless and were not: that number silently decided how much the
1019
+ * composite cost and how sharp it was. An operator typing 1920x1080 was, without
1020
+ * being told, committing the hub to reading two 4 MP streams — or, with the
1021
+ * rule before it, to upscaling a 640x360 sub-stream by 3x. Neither is a thing
1022
+ * anyone can be expected to work out from a text field.
1023
+ *
1024
+ * ## What it is derived from
1025
+ *
1026
+ * The operator draws two rectangles per camera — which part of the source to
1027
+ * take, and where it lands — and both are fractions, so nothing is pixels yet.
1028
+ * Exactly one number is missing: how many pixels the whole canvas is worth.
936
1029
  *
937
- * NOT decoration. A device publishing one stream with no hint is ranked by
938
- * pixel count and lands in `mid` — the slot `recordings` does not select on its
939
- * own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
940
- * robot's single 720p relay landed exactly there and its footage was written
941
- * where nothing looked.
1030
+ * A cell has `sourceResolution x crop` REAL pixels behind it. Showing them 1:1
1031
+ * neither invents nor discards anything, so the cell should be exactly that
1032
+ * many pixels — and since the cell is a fraction `f` of the canvas, the canvas
1033
+ * it implies is `real / f`. Each cell proposes one, and the LARGEST wins: the
1034
+ * hungriest cell is served 1:1 and the others scale down by a hair, never up.
1035
+ *
1036
+ * Gaps change nothing. Cells need not cover the canvas, and an uncovered part
1037
+ * is background — it has no pixels to propose.
1038
+ *
1039
+ * ## One per profile
1040
+ *
1041
+ * A source is a different size at each profile, so each profile derives its own
1042
+ * canvas: a grid's `low` composes its sources' `low` onto the canvas their
1043
+ * `low` implies. That is what makes the three outputs 1:1 with what they are
1044
+ * made of, and what lets a viewer's adaptive pick a rung without the grid
1045
+ * needing to know it exists.
1046
+ */
1047
+ /**
1048
+ * What an encoder will take. Not a taste: `libx264` refuses beyond 8192 in
1049
+ * either axis, and a canvas that large is a composition nobody can decode
1050
+ * anyway. Reached only by a cell that shows a sliver of a source at full
1051
+ * canvas width, which is a layout, not a mistake — so it is CLAMPED rather
1052
+ * than refused, and clamped in both axes together so the picture the operator
1053
+ * drew keeps its shape.
1054
+ */
1055
+ var MAX_CANVAS_WIDTH = 7680;
1056
+ var MAX_CANVAS_HEIGHT = 4320;
1057
+ /** Below this a composition is not a cheaper picture, it is no picture. */
1058
+ var MIN_CANVAS_EDGE = 160;
1059
+ /**
1060
+ * The canvas these cells imply, or `null` when they imply none.
1061
+ *
1062
+ * `null` for a grid with no cells — the normal freshly-created state — and for
1063
+ * a layout whose every rectangle is degenerate. Both are "there is no picture
1064
+ * to size", which the caller turns into "this profile is not on offer" rather
1065
+ * than into a guess.
942
1066
  */
943
- var GRID_OUTPUT_PROFILE = "high";
944
- /** Cheapest first — the order a source is read in. */
945
- var CHEAPEST_FIRST = [
946
- "low",
1067
+ function deriveGridCanvas(cells) {
1068
+ let width = 0;
1069
+ let height = 0;
1070
+ for (const entry of cells) {
1071
+ if (entry.cell.width <= 0 || entry.cell.height <= 0) continue;
1072
+ if (entry.sourceSize.width <= 0 || entry.sourceSize.height <= 0) continue;
1073
+ const realWidth = entry.sourceSize.width * entry.source.width;
1074
+ const realHeight = entry.sourceSize.height * entry.source.height;
1075
+ if (realWidth <= 0 || realHeight <= 0) continue;
1076
+ width = Math.max(width, realWidth / entry.cell.width);
1077
+ height = Math.max(height, realHeight / entry.cell.height);
1078
+ }
1079
+ if (width <= 0 || height <= 0) return null;
1080
+ const overshoot = Math.max(width / MAX_CANVAS_WIDTH, height / MAX_CANVAS_HEIGHT, 1);
1081
+ const factor = Math.max(MIN_CANVAS_EDGE / width, MIN_CANVAS_EDGE / height, 1) / overshoot;
1082
+ return {
1083
+ width: Math.max(2, evenDown(width * factor)),
1084
+ height: Math.max(2, evenDown(height * factor))
1085
+ };
1086
+ }
1087
+ //#endregion
1088
+ //#region src/builtins/camera-grid/grid-profiles.ts
1089
+ /**
1090
+ * One id per profile, for every grid, forever.
1091
+ *
1092
+ * D519's reason is what keeps `high`'s id bare: a newly-spelled id mints a
1093
+ * second cam-stream and orphans whatever profile assignment the operator had
1094
+ * made. `grid` was `high` on the live hub before any of this and still is.
1095
+ */
1096
+ var GRID_CAM_STREAM_ID = "grid";
1097
+ /** Every profile a grid can compose, in descending quality. */
1098
+ var GRID_PROFILES = [
1099
+ "high",
947
1100
  "mid",
948
- "high"
1101
+ "low"
949
1102
  ];
950
- /** The cheapest stream this source serves, or `null` when it serves none. */
951
- function lowestServedProfile(served) {
952
- return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
1103
+ /** The cam-stream id a grid publishes for one profile. */
1104
+ function gridCamStreamId(profile) {
1105
+ return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
953
1106
  }
954
- /** Which stream to read each source at, and who cannot be read at all. */
955
- function gridSourceProfiles(input) {
956
- const profileBySource = /* @__PURE__ */ new Map();
957
- const missingSources = [];
958
- for (const deviceId of input.sourceDeviceIds) {
959
- const served = input.profilesServedBy(deviceId);
960
- const chosen = served === null ? null : lowestServedProfile(served);
961
- if (chosen === null) missingSources.push(deviceId);
962
- else profileBySource.set(deviceId, chosen);
963
- }
964
- return {
965
- offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
966
- profileBySource,
967
- missingSources
968
- };
1107
+ /**
1108
+ * What this grid can honestly publish, per profile, with the canvas or the
1109
+ * refusal.
1110
+ *
1111
+ * Every profile gets a row whether it is offered or not: an absent row says
1112
+ * nothing, and the caller logging a refusal needs the reason more than the
1113
+ * caller publishing an offer needs the offer.
1114
+ */
1115
+ function gridProfilePlans(input) {
1116
+ const sourceDeviceIds = [...new Set(input.cells.map((cell) => cell.deviceId))];
1117
+ const servedBy = /* @__PURE__ */ new Map();
1118
+ for (const deviceId of sourceDeviceIds) servedBy.set(deviceId, input.profilesServedBy(deviceId));
1119
+ return GRID_PROFILES.map((profile) => {
1120
+ if (sourceDeviceIds.length === 0) return {
1121
+ profile,
1122
+ offered: false,
1123
+ missingSources: []
1124
+ };
1125
+ const sizeBySource = /* @__PURE__ */ new Map();
1126
+ const missingSources = [];
1127
+ for (const deviceId of sourceDeviceIds) {
1128
+ const match = (servedBy.get(deviceId) ?? null)?.find((entry) => entry.profile === profile);
1129
+ if (match === void 0 || match.width <= 0 || match.height <= 0) {
1130
+ missingSources.push(deviceId);
1131
+ continue;
1132
+ }
1133
+ sizeBySource.set(deviceId, {
1134
+ width: match.width,
1135
+ height: match.height
1136
+ });
1137
+ }
1138
+ if (missingSources.length > 0) return {
1139
+ profile,
1140
+ offered: false,
1141
+ missingSources
1142
+ };
1143
+ const canvas = deriveGridCanvas(input.cells.map((cell) => ({
1144
+ source: cell.source,
1145
+ cell: cell.cell,
1146
+ sourceSize: sizeBySource.get(cell.deviceId) ?? {
1147
+ width: 0,
1148
+ height: 0
1149
+ }
1150
+ })));
1151
+ if (canvas === null) return {
1152
+ profile,
1153
+ offered: false,
1154
+ missingSources: []
1155
+ };
1156
+ return {
1157
+ profile,
1158
+ offered: true,
1159
+ canvas
1160
+ };
1161
+ });
969
1162
  }
970
1163
  //#endregion
971
1164
  //#region src/builtins/camera-grid/grid-sentry-sources.ts
@@ -1130,15 +1323,24 @@ var PATH_PREFIX = "/grid/";
1130
1323
  var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
1131
1324
  /** How long a reader may refuse to drain before it is cut. */
1132
1325
  var SLOW_READER_MS = 5e3;
1133
- /** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
1326
+ function asProfile(raw) {
1327
+ return GRID_PROFILES.find((profile) => profile === raw) ?? null;
1328
+ }
1329
+ /** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
1134
1330
  function parseGridStreamPath(url) {
1135
1331
  const path = (url ?? "").split("?")[0] ?? "";
1136
1332
  if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
1137
1333
  const raw = path.slice(6, path.length - PATH_SUFFIX.length);
1138
- if (raw.length === 0 || raw.includes("/")) return null;
1334
+ const at = raw.lastIndexOf("/");
1335
+ if (at <= 0) return null;
1336
+ const profile = asProfile(raw.slice(at + 1));
1337
+ if (profile === null) return null;
1139
1338
  try {
1140
- const instanceId = decodeURIComponent(raw);
1141
- return instanceId.length > 0 ? { instanceId } : null;
1339
+ const instanceId = decodeURIComponent(raw.slice(0, at));
1340
+ return instanceId.length > 0 ? {
1341
+ instanceId,
1342
+ profile
1343
+ } : null;
1142
1344
  } catch {
1143
1345
  return null;
1144
1346
  }
@@ -1177,13 +1379,16 @@ var GridStreamRelay = class {
1177
1379
  streamUrl(request) {
1178
1380
  if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
1179
1381
  const instance = encodeURIComponent(request.instanceId);
1180
- return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
1382
+ return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
1181
1383
  }
1182
1384
  async serve(request, res) {
1183
- const { instanceId } = request;
1385
+ const { instanceId, profile } = request;
1184
1386
  const session = this.deps.sessionFor(request);
1185
1387
  if (session === null) {
1186
- this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
1388
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
1389
+ instanceId,
1390
+ profile
1391
+ } });
1187
1392
  res.writeHead(404).end();
1188
1393
  return;
1189
1394
  }
@@ -1217,6 +1422,7 @@ var GridStreamRelay = class {
1217
1422
  tags: { deviceId: session.deviceId },
1218
1423
  meta: {
1219
1424
  instanceId,
1425
+ profile,
1220
1426
  stalledForMs: SLOW_READER_MS
1221
1427
  }
1222
1428
  });
@@ -1229,6 +1435,7 @@ var GridStreamRelay = class {
1229
1435
  tags: { deviceId: session.deviceId },
1230
1436
  meta: {
1231
1437
  instanceId,
1438
+ profile,
1232
1439
  reason
1233
1440
  }
1234
1441
  });
@@ -1241,6 +1448,7 @@ var GridStreamRelay = class {
1241
1448
  tags: { deviceId: session.deviceId },
1242
1449
  meta: {
1243
1450
  instanceId,
1451
+ profile,
1244
1452
  error: errMsg(error)
1245
1453
  }
1246
1454
  });
@@ -1276,13 +1484,13 @@ var GridStreamRelay = class {
1276
1484
  * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
1277
1485
  * its footage was written nowhere anything looked.
1278
1486
  *
1279
- * So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
1280
- * It is also the slot this stream held before the per-profile detour, which is
1281
- * what keeps an operator's existing assignment intact.
1487
+ * With several compositions published the hint does a second job: it says which
1488
+ * slot each one is FOR. Ranked by pixels a grid's `mid` would take the `high`
1489
+ * slot on any grid whose `high` was not offered.
1282
1490
  */
1283
1491
  function gridStreamDescriptor(input) {
1284
1492
  return {
1285
- camStreamId: GRID_CAM_STREAM_ID,
1493
+ camStreamId: gridCamStreamId(input.profile),
1286
1494
  kind: GRID_WIRE_STREAM_KIND,
1287
1495
  url: input.url,
1288
1496
  codec: "h264",
@@ -1291,8 +1499,8 @@ function gridStreamDescriptor(input) {
1291
1499
  height: input.height
1292
1500
  },
1293
1501
  fps: input.fps,
1294
- label: "Grid composition",
1295
- profileHint: GRID_OUTPUT_PROFILE,
1502
+ label: `Grid composition (${input.profile})`,
1503
+ profileHint: input.profile,
1296
1504
  autoEligible: true
1297
1505
  };
1298
1506
  }
@@ -1795,88 +2003,6 @@ async function silenceAnalysisFor(deps, deviceId) {
1795
2003
  if (failures.length > 0) throw new Error(`camera grid ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
1796
2004
  }
1797
2005
  //#endregion
1798
- //#region src/builtins/camera-grid/grid-canvas.ts
1799
- /** Bytes one yuv420p frame of this size occupies. */
1800
- function yuv420pSize(width, height) {
1801
- return width * height + 2 * (width / 2) * (height / 2);
1802
- }
1803
- /**
1804
- * Round DOWN to even.
1805
- *
1806
- * Down and not nearest: a tile that rounded UP would claim a row of samples the
1807
- * decoder was never asked to produce, and the copy would either overrun or be
1808
- * refused at the moment the picture is wanted.
1809
- */
1810
- function evenDown(value) {
1811
- return Math.max(0, Math.floor(value / 2) * 2);
1812
- }
1813
- var GridCanvas = class {
1814
- width;
1815
- height;
1816
- buffer;
1817
- lumaBytes;
1818
- chromaBytes;
1819
- constructor(width, height) {
1820
- this.width = width;
1821
- this.height = height;
1822
- this.lumaBytes = width * height;
1823
- this.chromaBytes = width / 2 * (height / 2);
1824
- this.buffer = Buffer.alloc(yuv420pSize(width, height));
1825
- this.clear();
1826
- }
1827
- /**
1828
- * Legible black — Y 0, chroma 128 (neutral).
1829
- *
1830
- * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
1831
- * strong green. A cell nobody has filled yet has to look like an empty cell,
1832
- * not like a fault.
1833
- */
1834
- clear() {
1835
- this.buffer.fill(0, 0, this.lumaBytes);
1836
- this.buffer.fill(128, this.lumaBytes);
1837
- }
1838
- /**
1839
- * Copy one decoded tile in at `origin`.
1840
- *
1841
- * Returns false — never throws, never writes a partial tile — when the tile
1842
- * does not fit, is the wrong size for the dimensions it claims, or sits on an
1843
- * odd boundary. This runs on the output tick, so a bad frame from one source
1844
- * must cost that source's rectangle and nothing else.
1845
- */
1846
- blit(tile, size, origin) {
1847
- if (!isEven(size.width) || !isEven(size.height)) return false;
1848
- if (!isEven(origin.x) || !isEven(origin.y)) return false;
1849
- if (size.width <= 0 || size.height <= 0) return false;
1850
- if (origin.x < 0 || origin.y < 0) return false;
1851
- if (origin.x + size.width > this.width) return false;
1852
- if (origin.y + size.height > this.height) return false;
1853
- if (tile.length !== yuv420pSize(size.width, size.height)) return false;
1854
- for (let row = 0; row < size.height; row += 1) {
1855
- const from = row * size.width;
1856
- const to = (origin.y + row) * this.width + origin.x;
1857
- tile.copy(this.buffer, to, from, from + size.width);
1858
- }
1859
- const tileLuma = size.width * size.height;
1860
- const tileChroma = size.width / 2 * (size.height / 2);
1861
- const halfW = size.width / 2;
1862
- const halfH = size.height / 2;
1863
- const canvasHalfW = this.width / 2;
1864
- for (let plane = 0; plane < 2; plane += 1) {
1865
- const tileBase = tileLuma + plane * tileChroma;
1866
- const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1867
- for (let row = 0; row < halfH; row += 1) {
1868
- const from = tileBase + row * halfW;
1869
- const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1870
- tile.copy(this.buffer, to, from, from + halfW);
1871
- }
1872
- }
1873
- return true;
1874
- }
1875
- };
1876
- function isEven(value) {
1877
- return Number.isInteger(value) && value % 2 === 0;
1878
- }
1879
- //#endregion
1880
2006
  //#region src/builtins/camera-grid/grid-tile-feed.ts
1881
2007
  /**
1882
2008
  * One source's decoded tiles, of which only the LATEST is ever wanted.
@@ -2378,18 +2504,26 @@ var DEFAULTS = {
2378
2504
  cameraGrids: [],
2379
2505
  cameraGridTombstones: []
2380
2506
  };
2507
+ /** One composition per (grid, profile) — a grid composes each separately. */
2508
+ function sessionKey(instanceId, profile) {
2509
+ return `${instanceId}::${profile}`;
2510
+ }
2381
2511
  /**
2382
- * One grid's composition — there is exactly one, keyed by the grid itself.
2512
+ * One grid's composition of one profile.
2383
2513
  *
2384
- * It used to be one per profile, and the key carried the profile. That shape
2385
- * is what made a viewer's adaptive step tear a composition down and build
2386
- * another; see `grid-profiles.ts` for the log that showed it.
2514
+ * The profile is back in the key after a spell with a single output. What made
2515
+ * the per-profile shape painful was never the count: it was that a rebuild
2516
+ * meant a blank screen until the slowest source produced a key frame. An output
2517
+ * clock (D530) shows the picture at once and fills the cells in, so a rung the
2518
+ * adaptive can stand on costs what it is worth.
2387
2519
  */
2388
2520
  var GridSessionEntry = class {
2389
2521
  instanceId;
2522
+ profile;
2390
2523
  session;
2391
- constructor(instanceId, session) {
2524
+ constructor(instanceId, profile, session) {
2392
2525
  this.instanceId = instanceId;
2526
+ this.profile = profile;
2393
2527
  this.session = session;
2394
2528
  }
2395
2529
  isRunning() {
@@ -2413,7 +2547,7 @@ var CameraGridAddon = class extends BaseAddon {
2413
2547
  * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
2414
2548
  * — that only changes when an operator changes it.
2415
2549
  */
2416
- planByInstanceId = /* @__PURE__ */ new Map();
2550
+ plansByInstanceId = /* @__PURE__ */ new Map();
2417
2551
  activity = null;
2418
2552
  snapshots = null;
2419
2553
  frameSampler = null;
@@ -2646,9 +2780,9 @@ var CameraGridAddon = class extends BaseAddon {
2646
2780
  if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
2647
2781
  targets.push({
2648
2782
  deviceId,
2649
- canvas: {
2650
- width: instance.width,
2651
- height: instance.height
2783
+ canvas: this.canvasFor(instanceId) ?? {
2784
+ width: 0,
2785
+ height: 0
2652
2786
  },
2653
2787
  cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
2654
2788
  source: cell.source,
@@ -2683,17 +2817,25 @@ var CameraGridAddon = class extends BaseAddon {
2683
2817
  for (const slot of slots) {
2684
2818
  if (slot.sourceCamStreamId === null) continue;
2685
2819
  const profiles = servedBy.get(slot.deviceId) ?? [];
2686
- profiles.push(slot.profile);
2820
+ profiles.push({
2821
+ profile: slot.profile,
2822
+ width: slot.resolution?.width ?? 0,
2823
+ height: slot.resolution?.height ?? 0
2824
+ });
2687
2825
  servedBy.set(slot.deviceId, profiles);
2688
2826
  }
2689
2827
  for (const instance of instances) {
2690
- const plan = gridSourceProfiles({
2691
- sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
2828
+ const plans = gridProfilePlans({
2829
+ cells: instance.cells.map((cell) => ({
2830
+ deviceId: cell.deviceId,
2831
+ source: cell.source,
2832
+ cell: cell.cell
2833
+ })),
2692
2834
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2693
2835
  });
2694
- const previous = this.planByInstanceId.get(instance.id);
2695
- this.planByInstanceId.set(instance.id, plan);
2696
- this.logSourcePlan(instance, plan, previous);
2836
+ const previous = this.plansByInstanceId.get(instance.id);
2837
+ this.plansByInstanceId.set(instance.id, plans);
2838
+ this.logSourcePlan(instance, plans, previous);
2697
2839
  }
2698
2840
  }
2699
2841
  /**
@@ -2709,48 +2851,65 @@ var CameraGridAddon = class extends BaseAddon {
2709
2851
  * Logged on the EDGE: a grid that cannot be composed would otherwise write a
2710
2852
  * line a minute for ever.
2711
2853
  */
2712
- logSourcePlan(instance, plan, previous) {
2713
- const shape = (value) => JSON.stringify([
2714
- value.offered,
2715
- [...value.profileBySource],
2716
- value.missingSources
2717
- ]);
2718
- if (previous !== void 0 && shape(previous) === shape(plan)) return;
2854
+ logSourcePlan(instance, plans, previous) {
2855
+ const shape = (value) => JSON.stringify(value);
2856
+ if (previous !== void 0 && shape(previous) === shape(plans)) return;
2719
2857
  const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
2720
- if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
2721
- tags: { deviceId },
2722
- meta: {
2723
- instanceId: instance.id,
2724
- missingSources: [...plan.missingSources]
2725
- }
2726
- });
2727
- this.ctx.logger.info("camera grid: composing", {
2858
+ for (const row of plans) {
2859
+ if (row.offered || row.missingSources.length === 0) continue;
2860
+ this.ctx.logger.info("camera grid: a profile is NOT on offer", {
2861
+ tags: { deviceId },
2862
+ meta: {
2863
+ instanceId: instance.id,
2864
+ profile: row.profile,
2865
+ missingSources: [...row.missingSources]
2866
+ }
2867
+ });
2868
+ }
2869
+ this.ctx.logger.info("camera grid: published profiles", {
2728
2870
  tags: { deviceId },
2729
2871
  meta: {
2730
2872
  instanceId: instance.id,
2731
- offered: plan.offered,
2732
- readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
2873
+ offered: Object.fromEntries(plans.filter((row) => row.offered).map((row) => [row.profile, row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""]))
2733
2874
  }
2734
2875
  });
2735
2876
  }
2736
- planFor(instanceId) {
2737
- return this.planByInstanceId.get(instanceId) ?? null;
2877
+ plansFor(instanceId) {
2878
+ return this.plansByInstanceId.get(instanceId) ?? null;
2879
+ }
2880
+ planFor(instanceId, profile) {
2881
+ return this.plansFor(instanceId)?.find((row) => row.profile === profile) ?? null;
2882
+ }
2883
+ /**
2884
+ * The canvas of the BEST profile this grid offers.
2885
+ *
2886
+ * Used where a single geometry is needed for something that is not a
2887
+ * composition — the detection boxes a grid forwards (`grid-activity`), which
2888
+ * are normalized against whatever picture the viewer is looking at. The best
2889
+ * offered one is the honest default; a grid offering nothing has no geometry
2890
+ * and says so.
2891
+ */
2892
+ canvasFor(instanceId) {
2893
+ for (const row of this.plansFor(instanceId) ?? []) if (row.offered) return row.canvas;
2894
+ return null;
2738
2895
  }
2739
2896
  ensureSessions(instanceId, deviceId) {
2740
- const plan = this.planFor(instanceId);
2741
- if (plan === null || !plan.offered) {
2742
- const stale = this.sessions.get(instanceId);
2743
- if (stale) {
2744
- this.sessions.delete(instanceId);
2745
- stale.session.dispose();
2897
+ for (const profile of GRID_PROFILES) {
2898
+ const key = sessionKey(instanceId, profile);
2899
+ const plan = this.planFor(instanceId, profile);
2900
+ if (plan === null || !plan.offered) {
2901
+ const stale = this.sessions.get(key);
2902
+ if (stale) {
2903
+ this.sessions.delete(key);
2904
+ stale.session.dispose();
2905
+ }
2906
+ continue;
2746
2907
  }
2747
- return;
2908
+ if (this.sessions.has(key)) continue;
2909
+ this.sessions.set(key, new GridSessionEntry(instanceId, profile, this.makeSession(instanceId, deviceId, profile)));
2748
2910
  }
2749
- if (this.sessions.has(instanceId)) return;
2750
- const session = this.makeSession(instanceId, deviceId);
2751
- this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
2752
2911
  }
2753
- makeSession(instanceId, deviceId) {
2912
+ makeSession(instanceId, deviceId, profile) {
2754
2913
  const childContext = {
2755
2914
  binaryPath: this.ffmpegBinaryPath,
2756
2915
  logger: this.ctx.logger,
@@ -2761,8 +2920,6 @@ var CameraGridAddon = class extends BaseAddon {
2761
2920
  deps: {
2762
2921
  logger: this.ctx.logger,
2763
2922
  acquireSource: async (sourceDeviceId) => {
2764
- const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
2765
- if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
2766
2923
  const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2767
2924
  deviceId: sourceDeviceId,
2768
2925
  video: "copy",
@@ -2809,10 +2966,12 @@ var CameraGridAddon = class extends BaseAddon {
2809
2966
  },
2810
2967
  resolvePlan: () => {
2811
2968
  const instance = this.instanceById(instanceId);
2812
- if (!instance) return null;
2969
+ const plan = this.planFor(instanceId, profile);
2970
+ if (!instance || plan === null || !plan.offered) return null;
2813
2971
  return gridPlanFor(instance, {
2814
2972
  encoder: "libx264",
2815
- decodeHwAccel: null
2973
+ decodeHwAccel: null,
2974
+ canvas: plan.canvas
2816
2975
  });
2817
2976
  }
2818
2977
  });
@@ -2820,14 +2979,18 @@ var CameraGridAddon = class extends BaseAddon {
2820
2979
  }
2821
2980
  /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2822
2981
  sessionFor(request) {
2823
- return this.sessions.get(request.instanceId)?.session ?? null;
2982
+ return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2824
2983
  }
2825
- /** This grid's composition, as the list the snapshot source expects. */
2984
+ /** This grid's sessions, in DESCENDING quality — the snapshot takes the first running one. */
2826
2985
  sessionsForDevice(deviceId) {
2827
2986
  const instanceId = this.instanceIdForDevice(deviceId);
2828
2987
  if (instanceId === null) return [];
2829
- const entry = this.sessions.get(instanceId);
2830
- return entry ? [entry] : [];
2988
+ const entries = [];
2989
+ for (const profile of GRID_PROFILES) {
2990
+ const entry = this.sessions.get(sessionKey(instanceId, profile));
2991
+ if (entry) entries.push(entry);
2992
+ }
2993
+ return entries;
2831
2994
  }
2832
2995
  /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2833
2996
  async retireWithdrawnSessions(instances) {
@@ -2845,7 +3008,7 @@ var CameraGridAddon = class extends BaseAddon {
2845
3008
  if (live.has(instanceId)) continue;
2846
3009
  this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
2847
3010
  this.deviceIdByInstanceId.delete(instanceId);
2848
- this.planByInstanceId.delete(instanceId);
3011
+ this.plansByInstanceId.delete(instanceId);
2849
3012
  }
2850
3013
  }
2851
3014
  /**
@@ -2893,15 +3056,18 @@ var CameraGridAddon = class extends BaseAddon {
2893
3056
  descriptorsFor(instanceId) {
2894
3057
  const instance = this.instanceById(instanceId);
2895
3058
  const relay = this.relay;
2896
- const plan = this.planFor(instanceId);
2897
- if (!instance || !relay || plan === null) return null;
2898
- if (!plan.offered) return [];
2899
- return [gridStreamDescriptor({
2900
- width: instance.width,
2901
- height: instance.height,
3059
+ const plans = this.plansFor(instanceId);
3060
+ if (!instance || !relay || plans === null) return null;
3061
+ return plans.filter((row) => row.offered).map((row) => gridStreamDescriptor({
3062
+ profile: row.profile,
3063
+ width: row.offered ? row.canvas.width : 0,
3064
+ height: row.offered ? row.canvas.height : 0,
2902
3065
  fps: instance.fps,
2903
- url: relay.streamUrl({ instanceId })
2904
- })];
3066
+ url: relay.streamUrl({
3067
+ instanceId,
3068
+ profile: row.profile
3069
+ })
3070
+ }));
2905
3071
  }
2906
3072
  instanceIdForDevice(deviceId) {
2907
3073
  for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
@@ -2918,23 +3084,18 @@ var CameraGridAddon = class extends BaseAddon {
2918
3084
  instanceId: instance.id,
2919
3085
  deviceId,
2920
3086
  name: instance.name,
2921
- width: instance.width,
2922
- height: instance.height,
2923
3087
  fps: instance.fps,
2924
3088
  cells: instance.cells.map((cell) => ({
2925
3089
  deviceId: cell.deviceId,
2926
3090
  source: cell.source,
2927
3091
  cell: cell.cell
2928
3092
  })),
2929
- profiles: (() => {
2930
- const plan = this.planByInstanceId.get(instance.id);
2931
- if (plan === void 0) return [];
2932
- return [{
2933
- profile: GRID_OUTPUT_PROFILE,
2934
- offered: plan.offered,
2935
- missingSources: [...plan.missingSources]
2936
- }];
2937
- })()
3093
+ profiles: (this.plansByInstanceId.get(instance.id) ?? []).map((row) => ({
3094
+ profile: row.profile,
3095
+ offered: row.offered,
3096
+ missingSources: row.offered ? [] : [...row.missingSources],
3097
+ canvas: row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""
3098
+ }))
2938
3099
  };
2939
3100
  }
2940
3101
  async saveGridLayout(input) {
@@ -2943,8 +3104,6 @@ var CameraGridAddon = class extends BaseAddon {
2943
3104
  const updated = {
2944
3105
  ...instance,
2945
3106
  ...input.name !== void 0 ? { name: input.name.trim() } : {},
2946
- ...input.width !== void 0 ? { width: input.width } : {},
2947
- ...input.height !== void 0 ? { height: input.height } : {},
2948
3107
  ...input.fps !== void 0 ? { fps: input.fps } : {},
2949
3108
  cells: input.cells.map((cell) => ({ ...cell }))
2950
3109
  };
@@ -2953,7 +3112,7 @@ var CameraGridAddon = class extends BaseAddon {
2953
3112
  meta: {
2954
3113
  cells: updated.cells.length,
2955
3114
  sources: [...new Set(updated.cells.map((cell) => cell.deviceId))],
2956
- canvas: `${String(updated.width)}x${String(updated.height)}`
3115
+ fps: updated.fps
2957
3116
  }
2958
3117
  });
2959
3118
  await this.updateGlobalSettings({ cameraGrids: this.config.cameraGrids.map((row) => row["id"] === instance.id ? {
@@ -3290,4 +3449,4 @@ async function startGridChild(ctx, plan, sources, onEnded) {
3290
3449
  };
3291
3450
  }
3292
3451
  //#endregion
3293
- export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_OUTPUT_PROFILE, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, gridCameraReconciliationIndex, gridPlanFor, gridSnapshotAnswerFor, gridSourceDial, gridSourceProfiles, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, lowestServedProfile, newGridCameraStableId, normalizeGridRows, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };
3452
+ export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_PROFILES, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, deriveGridCanvas, gridCamStreamId, gridCameraReconciliationIndex, gridPlanFor, gridProfilePlans, gridSnapshotAnswerFor, gridSourceDial, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, newGridCameraStableId, normalizeGridRows, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };