@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
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BbPssgI6.js");
6
+ const require_dist = require("../../dist-DWNlc-PG.js");
7
7
  let zod = require("zod");
8
8
  let _camstack_types_node = require("@camstack/types/node");
9
9
  let node_path = require("node:path");
@@ -260,9 +260,17 @@ var GridInstanceSchema = zod.z.object({
260
260
  cameraStableId: zod.z.string().min(1).max(256),
261
261
  name: zod.z.string().min(1).max(160),
262
262
  enabled: zod.z.boolean(),
263
- /** Canvas size — the composite camera's own resolution. */
264
- width: zod.z.number().int().min(160).max(7680),
265
- height: zod.z.number().int().min(120).max(4320),
263
+ /**
264
+ * NO canvas size. It is DERIVED, per profile, from the cells and the sources'
265
+ * own dimensions (`grid-canvas-derivation.ts`) — the two numbers that used to
266
+ * live here were typed into the editor and silently decided both how much the
267
+ * composite cost and how sharp it was.
268
+ *
269
+ * Rows written before that change still carry `width`/`height`; zod drops
270
+ * unknown keys, so they are ignored rather than migrated. Nothing reads them,
271
+ * and a migration that rewrote every row to delete two fields would be a
272
+ * write with no reader.
273
+ */
266
274
  fps: zod.z.number().int().min(1).max(60),
267
275
  /**
268
276
  * Empty is the NORMAL freshly-created state: a grid is created from the
@@ -325,10 +333,7 @@ function gridPlanFor(instance, encode) {
325
333
  cell: cell.cell
326
334
  };
327
335
  });
328
- const canvas = encode.canvas ?? {
329
- width: instance.width,
330
- height: instance.height
331
- };
336
+ const canvas = encode.canvas;
332
337
  return {
333
338
  layout: {
334
339
  width: canvas.width,
@@ -926,51 +931,239 @@ var GridLastFrameFileStore = class {
926
931
  }
927
932
  };
928
933
  //#endregion
929
- //#region src/builtins/camera-grid/grid-profiles.ts
934
+ //#region src/builtins/camera-grid/grid-canvas.ts
935
+ /** Bytes one yuv420p frame of this size occupies. */
936
+ function yuv420pSize(width, height) {
937
+ return width * height + 2 * (width / 2) * (height / 2);
938
+ }
930
939
  /**
931
- * One id, for every grid, forever.
940
+ * Round DOWN to even.
932
941
  *
933
- * D519's reason is what keeps this id bare: a per-instance or newly-spelled id
934
- * mints a second cam-stream and orphans whatever profile assignment the
935
- * operator had made. It was `high` on the live hub before the per-profile
936
- * detour and it is `high` again, so nothing an operator assigned is disturbed.
942
+ * Down and not nearest: a tile that rounded UP would claim a row of samples the
943
+ * decoder was never asked to produce, and the copy would either overrun or be
944
+ * refused at the moment the picture is wanted.
937
945
  */
938
- var GRID_CAM_STREAM_ID = "grid";
946
+ function evenDown(value) {
947
+ return Math.max(0, Math.floor(value / 2) * 2);
948
+ }
949
+ var GridCanvas = class {
950
+ width;
951
+ height;
952
+ buffer;
953
+ lumaBytes;
954
+ chromaBytes;
955
+ constructor(width, height) {
956
+ this.width = width;
957
+ this.height = height;
958
+ this.lumaBytes = width * height;
959
+ this.chromaBytes = width / 2 * (height / 2);
960
+ this.buffer = Buffer.alloc(yuv420pSize(width, height));
961
+ this.clear();
962
+ }
963
+ /**
964
+ * Legible black — Y 0, chroma 128 (neutral).
965
+ *
966
+ * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
967
+ * strong green. A cell nobody has filled yet has to look like an empty cell,
968
+ * not like a fault.
969
+ */
970
+ clear() {
971
+ this.buffer.fill(0, 0, this.lumaBytes);
972
+ this.buffer.fill(128, this.lumaBytes);
973
+ }
974
+ /**
975
+ * Copy one decoded tile in at `origin`.
976
+ *
977
+ * Returns false — never throws, never writes a partial tile — when the tile
978
+ * does not fit, is the wrong size for the dimensions it claims, or sits on an
979
+ * odd boundary. This runs on the output tick, so a bad frame from one source
980
+ * must cost that source's rectangle and nothing else.
981
+ */
982
+ blit(tile, size, origin) {
983
+ if (!isEven(size.width) || !isEven(size.height)) return false;
984
+ if (!isEven(origin.x) || !isEven(origin.y)) return false;
985
+ if (size.width <= 0 || size.height <= 0) return false;
986
+ if (origin.x < 0 || origin.y < 0) return false;
987
+ if (origin.x + size.width > this.width) return false;
988
+ if (origin.y + size.height > this.height) return false;
989
+ if (tile.length !== yuv420pSize(size.width, size.height)) return false;
990
+ for (let row = 0; row < size.height; row += 1) {
991
+ const from = row * size.width;
992
+ const to = (origin.y + row) * this.width + origin.x;
993
+ tile.copy(this.buffer, to, from, from + size.width);
994
+ }
995
+ const tileLuma = size.width * size.height;
996
+ const tileChroma = size.width / 2 * (size.height / 2);
997
+ const halfW = size.width / 2;
998
+ const halfH = size.height / 2;
999
+ const canvasHalfW = this.width / 2;
1000
+ for (let plane = 0; plane < 2; plane += 1) {
1001
+ const tileBase = tileLuma + plane * tileChroma;
1002
+ const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1003
+ for (let row = 0; row < halfH; row += 1) {
1004
+ const from = tileBase + row * halfW;
1005
+ const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1006
+ tile.copy(this.buffer, to, from, from + halfW);
1007
+ }
1008
+ }
1009
+ return true;
1010
+ }
1011
+ };
1012
+ function isEven(value) {
1013
+ return Number.isInteger(value) && value % 2 === 0;
1014
+ }
1015
+ //#endregion
1016
+ //#region src/builtins/camera-grid/grid-canvas-derivation.ts
939
1017
  /**
940
- * The slot the single composition claims.
1018
+ * The canvas is a RESULT, not a field.
1019
+ *
1020
+ * ## Why the operator stopped typing it
1021
+ *
1022
+ * A grid's resolution used to be two numbers in the editor, beside FPS. They
1023
+ * looked harmless and were not: that number silently decided how much the
1024
+ * composite cost and how sharp it was. An operator typing 1920x1080 was, without
1025
+ * being told, committing the hub to reading two 4 MP streams — or, with the
1026
+ * rule before it, to upscaling a 640x360 sub-stream by 3x. Neither is a thing
1027
+ * anyone can be expected to work out from a text field.
1028
+ *
1029
+ * ## What it is derived from
1030
+ *
1031
+ * The operator draws two rectangles per camera — which part of the source to
1032
+ * take, and where it lands — and both are fractions, so nothing is pixels yet.
1033
+ * Exactly one number is missing: how many pixels the whole canvas is worth.
941
1034
  *
942
- * NOT decoration. A device publishing one stream with no hint is ranked by
943
- * pixel count and lands in `mid` — the slot `recordings` does not select on its
944
- * own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
945
- * robot's single 720p relay landed exactly there and its footage was written
946
- * where nothing looked.
1035
+ * A cell has `sourceResolution x crop` REAL pixels behind it. Showing them 1:1
1036
+ * neither invents nor discards anything, so the cell should be exactly that
1037
+ * many pixels — and since the cell is a fraction `f` of the canvas, the canvas
1038
+ * it implies is `real / f`. Each cell proposes one, and the LARGEST wins: the
1039
+ * hungriest cell is served 1:1 and the others scale down by a hair, never up.
1040
+ *
1041
+ * Gaps change nothing. Cells need not cover the canvas, and an uncovered part
1042
+ * is background — it has no pixels to propose.
1043
+ *
1044
+ * ## One per profile
1045
+ *
1046
+ * A source is a different size at each profile, so each profile derives its own
1047
+ * canvas: a grid's `low` composes its sources' `low` onto the canvas their
1048
+ * `low` implies. That is what makes the three outputs 1:1 with what they are
1049
+ * made of, and what lets a viewer's adaptive pick a rung without the grid
1050
+ * needing to know it exists.
1051
+ */
1052
+ /**
1053
+ * What an encoder will take. Not a taste: `libx264` refuses beyond 8192 in
1054
+ * either axis, and a canvas that large is a composition nobody can decode
1055
+ * anyway. Reached only by a cell that shows a sliver of a source at full
1056
+ * canvas width, which is a layout, not a mistake — so it is CLAMPED rather
1057
+ * than refused, and clamped in both axes together so the picture the operator
1058
+ * drew keeps its shape.
1059
+ */
1060
+ var MAX_CANVAS_WIDTH = 7680;
1061
+ var MAX_CANVAS_HEIGHT = 4320;
1062
+ /** Below this a composition is not a cheaper picture, it is no picture. */
1063
+ var MIN_CANVAS_EDGE = 160;
1064
+ /**
1065
+ * The canvas these cells imply, or `null` when they imply none.
1066
+ *
1067
+ * `null` for a grid with no cells — the normal freshly-created state — and for
1068
+ * a layout whose every rectangle is degenerate. Both are "there is no picture
1069
+ * to size", which the caller turns into "this profile is not on offer" rather
1070
+ * than into a guess.
947
1071
  */
948
- var GRID_OUTPUT_PROFILE = "high";
949
- /** Cheapest first — the order a source is read in. */
950
- var CHEAPEST_FIRST = [
951
- "low",
1072
+ function deriveGridCanvas(cells) {
1073
+ let width = 0;
1074
+ let height = 0;
1075
+ for (const entry of cells) {
1076
+ if (entry.cell.width <= 0 || entry.cell.height <= 0) continue;
1077
+ if (entry.sourceSize.width <= 0 || entry.sourceSize.height <= 0) continue;
1078
+ const realWidth = entry.sourceSize.width * entry.source.width;
1079
+ const realHeight = entry.sourceSize.height * entry.source.height;
1080
+ if (realWidth <= 0 || realHeight <= 0) continue;
1081
+ width = Math.max(width, realWidth / entry.cell.width);
1082
+ height = Math.max(height, realHeight / entry.cell.height);
1083
+ }
1084
+ if (width <= 0 || height <= 0) return null;
1085
+ const overshoot = Math.max(width / MAX_CANVAS_WIDTH, height / MAX_CANVAS_HEIGHT, 1);
1086
+ const factor = Math.max(MIN_CANVAS_EDGE / width, MIN_CANVAS_EDGE / height, 1) / overshoot;
1087
+ return {
1088
+ width: Math.max(2, evenDown(width * factor)),
1089
+ height: Math.max(2, evenDown(height * factor))
1090
+ };
1091
+ }
1092
+ //#endregion
1093
+ //#region src/builtins/camera-grid/grid-profiles.ts
1094
+ /**
1095
+ * One id per profile, for every grid, forever.
1096
+ *
1097
+ * D519's reason is what keeps `high`'s id bare: a newly-spelled id mints a
1098
+ * second cam-stream and orphans whatever profile assignment the operator had
1099
+ * made. `grid` was `high` on the live hub before any of this and still is.
1100
+ */
1101
+ var GRID_CAM_STREAM_ID = "grid";
1102
+ /** Every profile a grid can compose, in descending quality. */
1103
+ var GRID_PROFILES = [
1104
+ "high",
952
1105
  "mid",
953
- "high"
1106
+ "low"
954
1107
  ];
955
- /** The cheapest stream this source serves, or `null` when it serves none. */
956
- function lowestServedProfile(served) {
957
- return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
1108
+ /** The cam-stream id a grid publishes for one profile. */
1109
+ function gridCamStreamId(profile) {
1110
+ return profile === "high" ? GRID_CAM_STREAM_ID : `${GRID_CAM_STREAM_ID}-${profile}`;
958
1111
  }
959
- /** Which stream to read each source at, and who cannot be read at all. */
960
- function gridSourceProfiles(input) {
961
- const profileBySource = /* @__PURE__ */ new Map();
962
- const missingSources = [];
963
- for (const deviceId of input.sourceDeviceIds) {
964
- const served = input.profilesServedBy(deviceId);
965
- const chosen = served === null ? null : lowestServedProfile(served);
966
- if (chosen === null) missingSources.push(deviceId);
967
- else profileBySource.set(deviceId, chosen);
968
- }
969
- return {
970
- offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
971
- profileBySource,
972
- missingSources
973
- };
1112
+ /**
1113
+ * What this grid can honestly publish, per profile, with the canvas or the
1114
+ * refusal.
1115
+ *
1116
+ * Every profile gets a row whether it is offered or not: an absent row says
1117
+ * nothing, and the caller logging a refusal needs the reason more than the
1118
+ * caller publishing an offer needs the offer.
1119
+ */
1120
+ function gridProfilePlans(input) {
1121
+ const sourceDeviceIds = [...new Set(input.cells.map((cell) => cell.deviceId))];
1122
+ const servedBy = /* @__PURE__ */ new Map();
1123
+ for (const deviceId of sourceDeviceIds) servedBy.set(deviceId, input.profilesServedBy(deviceId));
1124
+ return GRID_PROFILES.map((profile) => {
1125
+ if (sourceDeviceIds.length === 0) return {
1126
+ profile,
1127
+ offered: false,
1128
+ missingSources: []
1129
+ };
1130
+ const sizeBySource = /* @__PURE__ */ new Map();
1131
+ const missingSources = [];
1132
+ for (const deviceId of sourceDeviceIds) {
1133
+ const match = (servedBy.get(deviceId) ?? null)?.find((entry) => entry.profile === profile);
1134
+ if (match === void 0 || match.width <= 0 || match.height <= 0) {
1135
+ missingSources.push(deviceId);
1136
+ continue;
1137
+ }
1138
+ sizeBySource.set(deviceId, {
1139
+ width: match.width,
1140
+ height: match.height
1141
+ });
1142
+ }
1143
+ if (missingSources.length > 0) return {
1144
+ profile,
1145
+ offered: false,
1146
+ missingSources
1147
+ };
1148
+ const canvas = deriveGridCanvas(input.cells.map((cell) => ({
1149
+ source: cell.source,
1150
+ cell: cell.cell,
1151
+ sourceSize: sizeBySource.get(cell.deviceId) ?? {
1152
+ width: 0,
1153
+ height: 0
1154
+ }
1155
+ })));
1156
+ if (canvas === null) return {
1157
+ profile,
1158
+ offered: false,
1159
+ missingSources: []
1160
+ };
1161
+ return {
1162
+ profile,
1163
+ offered: true,
1164
+ canvas
1165
+ };
1166
+ });
974
1167
  }
975
1168
  //#endregion
976
1169
  //#region src/builtins/camera-grid/grid-sentry-sources.ts
@@ -1135,15 +1328,24 @@ var PATH_PREFIX = "/grid/";
1135
1328
  var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
1136
1329
  /** How long a reader may refuse to drain before it is cut. */
1137
1330
  var SLOW_READER_MS = 5e3;
1138
- /** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
1331
+ function asProfile(raw) {
1332
+ return GRID_PROFILES.find((profile) => profile === raw) ?? null;
1333
+ }
1334
+ /** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
1139
1335
  function parseGridStreamPath(url) {
1140
1336
  const path = (url ?? "").split("?")[0] ?? "";
1141
1337
  if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
1142
1338
  const raw = path.slice(6, path.length - PATH_SUFFIX.length);
1143
- if (raw.length === 0 || raw.includes("/")) return null;
1339
+ const at = raw.lastIndexOf("/");
1340
+ if (at <= 0) return null;
1341
+ const profile = asProfile(raw.slice(at + 1));
1342
+ if (profile === null) return null;
1144
1343
  try {
1145
- const instanceId = decodeURIComponent(raw);
1146
- return instanceId.length > 0 ? { instanceId } : null;
1344
+ const instanceId = decodeURIComponent(raw.slice(0, at));
1345
+ return instanceId.length > 0 ? {
1346
+ instanceId,
1347
+ profile
1348
+ } : null;
1147
1349
  } catch {
1148
1350
  return null;
1149
1351
  }
@@ -1182,13 +1384,16 @@ var GridStreamRelay = class {
1182
1384
  streamUrl(request) {
1183
1385
  if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
1184
1386
  const instance = encodeURIComponent(request.instanceId);
1185
- return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
1387
+ return `${this.baseUrl}${PATH_PREFIX}${instance}/${request.profile}${PATH_SUFFIX}`;
1186
1388
  }
1187
1389
  async serve(request, res) {
1188
- const { instanceId } = request;
1390
+ const { instanceId, profile } = request;
1189
1391
  const session = this.deps.sessionFor(request);
1190
1392
  if (session === null) {
1191
- this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
1393
+ this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
1394
+ instanceId,
1395
+ profile
1396
+ } });
1192
1397
  res.writeHead(404).end();
1193
1398
  return;
1194
1399
  }
@@ -1222,6 +1427,7 @@ var GridStreamRelay = class {
1222
1427
  tags: { deviceId: session.deviceId },
1223
1428
  meta: {
1224
1429
  instanceId,
1430
+ profile,
1225
1431
  stalledForMs: SLOW_READER_MS
1226
1432
  }
1227
1433
  });
@@ -1234,6 +1440,7 @@ var GridStreamRelay = class {
1234
1440
  tags: { deviceId: session.deviceId },
1235
1441
  meta: {
1236
1442
  instanceId,
1443
+ profile,
1237
1444
  reason
1238
1445
  }
1239
1446
  });
@@ -1246,6 +1453,7 @@ var GridStreamRelay = class {
1246
1453
  tags: { deviceId: session.deviceId },
1247
1454
  meta: {
1248
1455
  instanceId,
1456
+ profile,
1249
1457
  error: require_dist.errMsg(error)
1250
1458
  }
1251
1459
  });
@@ -1281,13 +1489,13 @@ var GridStreamRelay = class {
1281
1489
  * scrub never reads. The Dreame robot's one 720p relay landed exactly there and
1282
1490
  * its footage was written nowhere anything looked.
1283
1491
  *
1284
- * So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
1285
- * It is also the slot this stream held before the per-profile detour, which is
1286
- * what keeps an operator's existing assignment intact.
1492
+ * With several compositions published the hint does a second job: it says which
1493
+ * slot each one is FOR. Ranked by pixels a grid's `mid` would take the `high`
1494
+ * slot on any grid whose `high` was not offered.
1287
1495
  */
1288
1496
  function gridStreamDescriptor(input) {
1289
1497
  return {
1290
- camStreamId: GRID_CAM_STREAM_ID,
1498
+ camStreamId: gridCamStreamId(input.profile),
1291
1499
  kind: GRID_WIRE_STREAM_KIND,
1292
1500
  url: input.url,
1293
1501
  codec: "h264",
@@ -1296,8 +1504,8 @@ function gridStreamDescriptor(input) {
1296
1504
  height: input.height
1297
1505
  },
1298
1506
  fps: input.fps,
1299
- label: "Grid composition",
1300
- profileHint: GRID_OUTPUT_PROFILE,
1507
+ label: `Grid composition (${input.profile})`,
1508
+ profileHint: input.profile,
1301
1509
  autoEligible: true
1302
1510
  };
1303
1511
  }
@@ -1800,88 +2008,6 @@ async function silenceAnalysisFor(deps, deviceId) {
1800
2008
  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("; ")})`);
1801
2009
  }
1802
2010
  //#endregion
1803
- //#region src/builtins/camera-grid/grid-canvas.ts
1804
- /** Bytes one yuv420p frame of this size occupies. */
1805
- function yuv420pSize(width, height) {
1806
- return width * height + 2 * (width / 2) * (height / 2);
1807
- }
1808
- /**
1809
- * Round DOWN to even.
1810
- *
1811
- * Down and not nearest: a tile that rounded UP would claim a row of samples the
1812
- * decoder was never asked to produce, and the copy would either overrun or be
1813
- * refused at the moment the picture is wanted.
1814
- */
1815
- function evenDown(value) {
1816
- return Math.max(0, Math.floor(value / 2) * 2);
1817
- }
1818
- var GridCanvas = class {
1819
- width;
1820
- height;
1821
- buffer;
1822
- lumaBytes;
1823
- chromaBytes;
1824
- constructor(width, height) {
1825
- this.width = width;
1826
- this.height = height;
1827
- this.lumaBytes = width * height;
1828
- this.chromaBytes = width / 2 * (height / 2);
1829
- this.buffer = Buffer.alloc(yuv420pSize(width, height));
1830
- this.clear();
1831
- }
1832
- /**
1833
- * Legible black — Y 0, chroma 128 (neutral).
1834
- *
1835
- * `Buffer.alloc` already zeroes, and zeroed CHROMA is not black: it is a
1836
- * strong green. A cell nobody has filled yet has to look like an empty cell,
1837
- * not like a fault.
1838
- */
1839
- clear() {
1840
- this.buffer.fill(0, 0, this.lumaBytes);
1841
- this.buffer.fill(128, this.lumaBytes);
1842
- }
1843
- /**
1844
- * Copy one decoded tile in at `origin`.
1845
- *
1846
- * Returns false — never throws, never writes a partial tile — when the tile
1847
- * does not fit, is the wrong size for the dimensions it claims, or sits on an
1848
- * odd boundary. This runs on the output tick, so a bad frame from one source
1849
- * must cost that source's rectangle and nothing else.
1850
- */
1851
- blit(tile, size, origin) {
1852
- if (!isEven(size.width) || !isEven(size.height)) return false;
1853
- if (!isEven(origin.x) || !isEven(origin.y)) return false;
1854
- if (size.width <= 0 || size.height <= 0) return false;
1855
- if (origin.x < 0 || origin.y < 0) return false;
1856
- if (origin.x + size.width > this.width) return false;
1857
- if (origin.y + size.height > this.height) return false;
1858
- if (tile.length !== yuv420pSize(size.width, size.height)) return false;
1859
- for (let row = 0; row < size.height; row += 1) {
1860
- const from = row * size.width;
1861
- const to = (origin.y + row) * this.width + origin.x;
1862
- tile.copy(this.buffer, to, from, from + size.width);
1863
- }
1864
- const tileLuma = size.width * size.height;
1865
- const tileChroma = size.width / 2 * (size.height / 2);
1866
- const halfW = size.width / 2;
1867
- const halfH = size.height / 2;
1868
- const canvasHalfW = this.width / 2;
1869
- for (let plane = 0; plane < 2; plane += 1) {
1870
- const tileBase = tileLuma + plane * tileChroma;
1871
- const canvasBase = this.lumaBytes + plane * this.chromaBytes;
1872
- for (let row = 0; row < halfH; row += 1) {
1873
- const from = tileBase + row * halfW;
1874
- const to = canvasBase + (origin.y / 2 + row) * canvasHalfW + origin.x / 2;
1875
- tile.copy(this.buffer, to, from, from + halfW);
1876
- }
1877
- }
1878
- return true;
1879
- }
1880
- };
1881
- function isEven(value) {
1882
- return Number.isInteger(value) && value % 2 === 0;
1883
- }
1884
- //#endregion
1885
2011
  //#region src/builtins/camera-grid/grid-tile-feed.ts
1886
2012
  /**
1887
2013
  * One source's decoded tiles, of which only the LATEST is ever wanted.
@@ -2383,18 +2509,26 @@ var DEFAULTS = {
2383
2509
  cameraGrids: [],
2384
2510
  cameraGridTombstones: []
2385
2511
  };
2512
+ /** One composition per (grid, profile) — a grid composes each separately. */
2513
+ function sessionKey(instanceId, profile) {
2514
+ return `${instanceId}::${profile}`;
2515
+ }
2386
2516
  /**
2387
- * One grid's composition — there is exactly one, keyed by the grid itself.
2517
+ * One grid's composition of one profile.
2388
2518
  *
2389
- * It used to be one per profile, and the key carried the profile. That shape
2390
- * is what made a viewer's adaptive step tear a composition down and build
2391
- * another; see `grid-profiles.ts` for the log that showed it.
2519
+ * The profile is back in the key after a spell with a single output. What made
2520
+ * the per-profile shape painful was never the count: it was that a rebuild
2521
+ * meant a blank screen until the slowest source produced a key frame. An output
2522
+ * clock (D530) shows the picture at once and fills the cells in, so a rung the
2523
+ * adaptive can stand on costs what it is worth.
2392
2524
  */
2393
2525
  var GridSessionEntry = class {
2394
2526
  instanceId;
2527
+ profile;
2395
2528
  session;
2396
- constructor(instanceId, session) {
2529
+ constructor(instanceId, profile, session) {
2397
2530
  this.instanceId = instanceId;
2531
+ this.profile = profile;
2398
2532
  this.session = session;
2399
2533
  }
2400
2534
  isRunning() {
@@ -2418,7 +2552,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2418
2552
  * (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
2419
2553
  * — that only changes when an operator changes it.
2420
2554
  */
2421
- planByInstanceId = /* @__PURE__ */ new Map();
2555
+ plansByInstanceId = /* @__PURE__ */ new Map();
2422
2556
  activity = null;
2423
2557
  snapshots = null;
2424
2558
  frameSampler = null;
@@ -2651,9 +2785,9 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2651
2785
  if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
2652
2786
  targets.push({
2653
2787
  deviceId,
2654
- canvas: {
2655
- width: instance.width,
2656
- height: instance.height
2788
+ canvas: this.canvasFor(instanceId) ?? {
2789
+ width: 0,
2790
+ height: 0
2657
2791
  },
2658
2792
  cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
2659
2793
  source: cell.source,
@@ -2688,17 +2822,25 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2688
2822
  for (const slot of slots) {
2689
2823
  if (slot.sourceCamStreamId === null) continue;
2690
2824
  const profiles = servedBy.get(slot.deviceId) ?? [];
2691
- profiles.push(slot.profile);
2825
+ profiles.push({
2826
+ profile: slot.profile,
2827
+ width: slot.resolution?.width ?? 0,
2828
+ height: slot.resolution?.height ?? 0
2829
+ });
2692
2830
  servedBy.set(slot.deviceId, profiles);
2693
2831
  }
2694
2832
  for (const instance of instances) {
2695
- const plan = gridSourceProfiles({
2696
- sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
2833
+ const plans = gridProfilePlans({
2834
+ cells: instance.cells.map((cell) => ({
2835
+ deviceId: cell.deviceId,
2836
+ source: cell.source,
2837
+ cell: cell.cell
2838
+ })),
2697
2839
  profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
2698
2840
  });
2699
- const previous = this.planByInstanceId.get(instance.id);
2700
- this.planByInstanceId.set(instance.id, plan);
2701
- this.logSourcePlan(instance, plan, previous);
2841
+ const previous = this.plansByInstanceId.get(instance.id);
2842
+ this.plansByInstanceId.set(instance.id, plans);
2843
+ this.logSourcePlan(instance, plans, previous);
2702
2844
  }
2703
2845
  }
2704
2846
  /**
@@ -2714,48 +2856,65 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2714
2856
  * Logged on the EDGE: a grid that cannot be composed would otherwise write a
2715
2857
  * line a minute for ever.
2716
2858
  */
2717
- logSourcePlan(instance, plan, previous) {
2718
- const shape = (value) => JSON.stringify([
2719
- value.offered,
2720
- [...value.profileBySource],
2721
- value.missingSources
2722
- ]);
2723
- if (previous !== void 0 && shape(previous) === shape(plan)) return;
2859
+ logSourcePlan(instance, plans, previous) {
2860
+ const shape = (value) => JSON.stringify(value);
2861
+ if (previous !== void 0 && shape(previous) === shape(plans)) return;
2724
2862
  const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
2725
- if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
2726
- tags: { deviceId },
2727
- meta: {
2728
- instanceId: instance.id,
2729
- missingSources: [...plan.missingSources]
2730
- }
2731
- });
2732
- this.ctx.logger.info("camera grid: composing", {
2863
+ for (const row of plans) {
2864
+ if (row.offered || row.missingSources.length === 0) continue;
2865
+ this.ctx.logger.info("camera grid: a profile is NOT on offer", {
2866
+ tags: { deviceId },
2867
+ meta: {
2868
+ instanceId: instance.id,
2869
+ profile: row.profile,
2870
+ missingSources: [...row.missingSources]
2871
+ }
2872
+ });
2873
+ }
2874
+ this.ctx.logger.info("camera grid: published profiles", {
2733
2875
  tags: { deviceId },
2734
2876
  meta: {
2735
2877
  instanceId: instance.id,
2736
- offered: plan.offered,
2737
- readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
2878
+ offered: Object.fromEntries(plans.filter((row) => row.offered).map((row) => [row.profile, row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""]))
2738
2879
  }
2739
2880
  });
2740
2881
  }
2741
- planFor(instanceId) {
2742
- return this.planByInstanceId.get(instanceId) ?? null;
2882
+ plansFor(instanceId) {
2883
+ return this.plansByInstanceId.get(instanceId) ?? null;
2884
+ }
2885
+ planFor(instanceId, profile) {
2886
+ return this.plansFor(instanceId)?.find((row) => row.profile === profile) ?? null;
2887
+ }
2888
+ /**
2889
+ * The canvas of the BEST profile this grid offers.
2890
+ *
2891
+ * Used where a single geometry is needed for something that is not a
2892
+ * composition — the detection boxes a grid forwards (`grid-activity`), which
2893
+ * are normalized against whatever picture the viewer is looking at. The best
2894
+ * offered one is the honest default; a grid offering nothing has no geometry
2895
+ * and says so.
2896
+ */
2897
+ canvasFor(instanceId) {
2898
+ for (const row of this.plansFor(instanceId) ?? []) if (row.offered) return row.canvas;
2899
+ return null;
2743
2900
  }
2744
2901
  ensureSessions(instanceId, deviceId) {
2745
- const plan = this.planFor(instanceId);
2746
- if (plan === null || !plan.offered) {
2747
- const stale = this.sessions.get(instanceId);
2748
- if (stale) {
2749
- this.sessions.delete(instanceId);
2750
- stale.session.dispose();
2902
+ for (const profile of GRID_PROFILES) {
2903
+ const key = sessionKey(instanceId, profile);
2904
+ const plan = this.planFor(instanceId, profile);
2905
+ if (plan === null || !plan.offered) {
2906
+ const stale = this.sessions.get(key);
2907
+ if (stale) {
2908
+ this.sessions.delete(key);
2909
+ stale.session.dispose();
2910
+ }
2911
+ continue;
2751
2912
  }
2752
- return;
2913
+ if (this.sessions.has(key)) continue;
2914
+ this.sessions.set(key, new GridSessionEntry(instanceId, profile, this.makeSession(instanceId, deviceId, profile)));
2753
2915
  }
2754
- if (this.sessions.has(instanceId)) return;
2755
- const session = this.makeSession(instanceId, deviceId);
2756
- this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
2757
2916
  }
2758
- makeSession(instanceId, deviceId) {
2917
+ makeSession(instanceId, deviceId, profile) {
2759
2918
  const childContext = {
2760
2919
  binaryPath: this.ffmpegBinaryPath,
2761
2920
  logger: this.ctx.logger,
@@ -2766,8 +2925,6 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2766
2925
  deps: {
2767
2926
  logger: this.ctx.logger,
2768
2927
  acquireSource: async (sourceDeviceId) => {
2769
- const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
2770
- if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
2771
2928
  const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
2772
2929
  deviceId: sourceDeviceId,
2773
2930
  video: "copy",
@@ -2814,10 +2971,12 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2814
2971
  },
2815
2972
  resolvePlan: () => {
2816
2973
  const instance = this.instanceById(instanceId);
2817
- if (!instance) return null;
2974
+ const plan = this.planFor(instanceId, profile);
2975
+ if (!instance || plan === null || !plan.offered) return null;
2818
2976
  return gridPlanFor(instance, {
2819
2977
  encoder: "libx264",
2820
- decodeHwAccel: null
2978
+ decodeHwAccel: null,
2979
+ canvas: plan.canvas
2821
2980
  });
2822
2981
  }
2823
2982
  });
@@ -2825,14 +2984,18 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2825
2984
  }
2826
2985
  /** The session a dial is asking for, or `null` - which the relay answers 404 to. */
2827
2986
  sessionFor(request) {
2828
- return this.sessions.get(request.instanceId)?.session ?? null;
2987
+ return this.sessions.get(sessionKey(request.instanceId, request.profile))?.session ?? null;
2829
2988
  }
2830
- /** This grid's composition, as the list the snapshot source expects. */
2989
+ /** This grid's sessions, in DESCENDING quality — the snapshot takes the first running one. */
2831
2990
  sessionsForDevice(deviceId) {
2832
2991
  const instanceId = this.instanceIdForDevice(deviceId);
2833
2992
  if (instanceId === null) return [];
2834
- const entry = this.sessions.get(instanceId);
2835
- return entry ? [entry] : [];
2993
+ const entries = [];
2994
+ for (const profile of GRID_PROFILES) {
2995
+ const entry = this.sessions.get(sessionKey(instanceId, profile));
2996
+ if (entry) entries.push(entry);
2997
+ }
2998
+ return entries;
2836
2999
  }
2837
3000
  /** A grid that is gone stops immediately - it must not keep N sources dialled. */
2838
3001
  async retireWithdrawnSessions(instances) {
@@ -2850,7 +3013,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2850
3013
  if (live.has(instanceId)) continue;
2851
3014
  this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
2852
3015
  this.deviceIdByInstanceId.delete(instanceId);
2853
- this.planByInstanceId.delete(instanceId);
3016
+ this.plansByInstanceId.delete(instanceId);
2854
3017
  }
2855
3018
  }
2856
3019
  /**
@@ -2898,15 +3061,18 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2898
3061
  descriptorsFor(instanceId) {
2899
3062
  const instance = this.instanceById(instanceId);
2900
3063
  const relay = this.relay;
2901
- const plan = this.planFor(instanceId);
2902
- if (!instance || !relay || plan === null) return null;
2903
- if (!plan.offered) return [];
2904
- return [gridStreamDescriptor({
2905
- width: instance.width,
2906
- height: instance.height,
3064
+ const plans = this.plansFor(instanceId);
3065
+ if (!instance || !relay || plans === null) return null;
3066
+ return plans.filter((row) => row.offered).map((row) => gridStreamDescriptor({
3067
+ profile: row.profile,
3068
+ width: row.offered ? row.canvas.width : 0,
3069
+ height: row.offered ? row.canvas.height : 0,
2907
3070
  fps: instance.fps,
2908
- url: relay.streamUrl({ instanceId })
2909
- })];
3071
+ url: relay.streamUrl({
3072
+ instanceId,
3073
+ profile: row.profile
3074
+ })
3075
+ }));
2910
3076
  }
2911
3077
  instanceIdForDevice(deviceId) {
2912
3078
  for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
@@ -2923,23 +3089,18 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2923
3089
  instanceId: instance.id,
2924
3090
  deviceId,
2925
3091
  name: instance.name,
2926
- width: instance.width,
2927
- height: instance.height,
2928
3092
  fps: instance.fps,
2929
3093
  cells: instance.cells.map((cell) => ({
2930
3094
  deviceId: cell.deviceId,
2931
3095
  source: cell.source,
2932
3096
  cell: cell.cell
2933
3097
  })),
2934
- profiles: (() => {
2935
- const plan = this.planByInstanceId.get(instance.id);
2936
- if (plan === void 0) return [];
2937
- return [{
2938
- profile: GRID_OUTPUT_PROFILE,
2939
- offered: plan.offered,
2940
- missingSources: [...plan.missingSources]
2941
- }];
2942
- })()
3098
+ profiles: (this.plansByInstanceId.get(instance.id) ?? []).map((row) => ({
3099
+ profile: row.profile,
3100
+ offered: row.offered,
3101
+ missingSources: row.offered ? [] : [...row.missingSources],
3102
+ canvas: row.offered ? `${String(row.canvas.width)}x${String(row.canvas.height)}` : ""
3103
+ }))
2943
3104
  };
2944
3105
  }
2945
3106
  async saveGridLayout(input) {
@@ -2948,8 +3109,6 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2948
3109
  const updated = {
2949
3110
  ...instance,
2950
3111
  ...input.name !== void 0 ? { name: input.name.trim() } : {},
2951
- ...input.width !== void 0 ? { width: input.width } : {},
2952
- ...input.height !== void 0 ? { height: input.height } : {},
2953
3112
  ...input.fps !== void 0 ? { fps: input.fps } : {},
2954
3113
  cells: input.cells.map((cell) => ({ ...cell }))
2955
3114
  };
@@ -2958,7 +3117,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2958
3117
  meta: {
2959
3118
  cells: updated.cells.length,
2960
3119
  sources: [...new Set(updated.cells.map((cell) => cell.deviceId))],
2961
- canvas: `${String(updated.width)}x${String(updated.height)}`
3120
+ fps: updated.fps
2962
3121
  }
2963
3122
  });
2964
3123
  await this.updateGlobalSettings({ cameraGrids: this.config.cameraGrids.map((row) => row["id"] === instance.id ? {
@@ -3297,7 +3456,7 @@ async function startGridChild(ctx, plan, sources, onEnded) {
3297
3456
  //#endregion
3298
3457
  exports.CameraGridAddon = CameraGridAddon;
3299
3458
  exports.GRID_CAM_STREAM_ID = GRID_CAM_STREAM_ID;
3300
- exports.GRID_OUTPUT_PROFILE = GRID_OUTPUT_PROFILE;
3459
+ exports.GRID_PROFILES = GRID_PROFILES;
3301
3460
  exports.GRID_SILENCED_CAP_NAMES = GRID_SILENCED_CAP_NAMES;
3302
3461
  exports.GridCameraDevice = GridCameraDevice;
3303
3462
  exports.GridFrameSampler = GridFrameSampler;
@@ -3308,15 +3467,16 @@ exports.GridStreamSession = GridStreamSession;
3308
3467
  exports.buildGridCameraDeclarations = buildGridCameraDeclarations;
3309
3468
  exports.buildGridFilterGraph = buildGridFilterGraph;
3310
3469
  exports.default = CameraGridAddon;
3470
+ exports.deriveGridCanvas = deriveGridCanvas;
3471
+ exports.gridCamStreamId = gridCamStreamId;
3311
3472
  exports.gridCameraReconciliationIndex = gridCameraReconciliationIndex;
3312
3473
  exports.gridPlanFor = gridPlanFor;
3474
+ exports.gridProfilePlans = gridProfilePlans;
3313
3475
  exports.gridSnapshotAnswerFor = gridSnapshotAnswerFor;
3314
3476
  exports.gridSourceDial = gridSourceDial;
3315
- exports.gridSourceProfiles = gridSourceProfiles;
3316
3477
  exports.gridStreamDescriptor = gridStreamDescriptor;
3317
3478
  exports.gridStreamInvocation = gridStreamInvocation;
3318
3479
  exports.installGridCameraRuntime = installGridCameraRuntime;
3319
- exports.lowestServedProfile = lowestServedProfile;
3320
3480
  exports.newGridCameraStableId = newGridCameraStableId;
3321
3481
  exports.normalizeGridRows = normalizeGridRows;
3322
3482
  exports.parseGridStreamPath = parseGridStreamPath;